rspace-online/modules/work/components/folk-work-board.ts

249 lines
8.9 KiB
TypeScript

/**
* <folk-work-board> — kanban board for workspace task management.
*
* Views: workspace list → board with draggable columns.
* Supports task creation, status changes, and priority labels.
*/
class FolkWorkBoard extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "list" | "board" = "list";
private workspaceSlug = "";
private workspaces: any[] = [];
private tasks: any[] = [];
private statuses: string[] = ["TODO", "IN_PROGRESS", "REVIEW", "DONE"];
private loading = false;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadWorkspaces();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/work/);
return match ? `/${match[1]}/work` : "";
}
private async loadWorkspaces() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/spaces`);
if (res.ok) this.workspaces = await res.json();
} catch { this.workspaces = []; }
this.render();
}
private async loadTasks() {
if (!this.workspaceSlug) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`);
if (res.ok) this.tasks = await res.json();
const spaceRes = await fetch(`${base}/api/spaces/${this.workspaceSlug}`);
if (spaceRes.ok) {
const space = await spaceRes.json();
if (space.statuses?.length) this.statuses = space.statuses;
}
} catch { this.tasks = []; }
this.render();
}
private async createWorkspace() {
const name = prompt("Workspace name:");
if (!name?.trim()) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/spaces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (res.ok) this.loadWorkspaces();
} catch { this.error = "Failed to create workspace"; this.render(); }
}
private async createTask() {
const title = prompt("Task title:");
if (!title?.trim()) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim() }),
});
this.loadTasks();
} catch { this.error = "Failed to create task"; this.render(); }
}
private async moveTask(taskId: string, newStatus: string) {
try {
const base = this.getApiBase();
await fetch(`${base}/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
this.loadTasks();
} catch { this.error = "Failed to move task"; this.render(); }
}
private openBoard(slug: string) {
this.workspaceSlug = slug;
this.view = "board";
this.loadTasks();
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.header { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
.nav-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid #444; background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px; }
.nav-btn:hover { border-color: #666; }
.header-title { font-size: 18px; font-weight: 600; margin-left: 8px; flex: 1; }
.create-btn { padding: 8px 16px; border-radius: 8px; border: none; background: #6366f1; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.create-btn:hover { background: #4f46e5; }
.workspace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.workspace-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.workspace-card:hover { border-color: #555; }
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.ws-meta { font-size: 12px; color: #888; }
.board { display: flex; gap: 12px; overflow-x: auto; min-height: 400px; padding-bottom: 12px; }
.column {
min-width: 240px; max-width: 280px; flex-shrink: 0;
background: #16161e; border: 1px solid #2a2a3a; border-radius: 10px; padding: 12px;
}
.col-header { font-size: 13px; font-weight: 600; text-transform: uppercase; color: #888; margin-bottom: 10px; display: flex; justify-content: space-between; }
.col-count { background: #2a2a3a; border-radius: 10px; padding: 0 8px; font-size: 11px; }
.task-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 10px 12px; margin-bottom: 8px; cursor: grab;
}
.task-card:hover { border-color: #555; }
.task-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
.task-meta { display: flex; gap: 6px; flex-wrap: wrap; }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: #2a2a3a; color: #aaa; }
.badge-urgent { background: #3b1111; color: #f87171; }
.badge-high { background: #3b2611; color: #fb923c; }
.move-btns { display: flex; gap: 4px; margin-top: 6px; }
.move-btn { font-size: 10px; padding: 2px 6px; border-radius: 4px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; }
.move-btn:hover { border-color: #555; color: #ccc; }
.empty { text-align: center; color: #666; padding: 40px; }
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
${this.view === "list" ? this.renderList() : this.renderBoard()}
`;
this.attachListeners();
}
private renderList(): string {
return `
<div class="header">
<span class="header-title">Workspaces</span>
<button class="create-btn" id="create-ws">+ New Workspace</button>
</div>
${this.workspaces.length > 0 ? `<div class="workspace-grid">
${this.workspaces.map(ws => `
<div class="workspace-card" data-ws="${ws.slug}">
<div class="ws-name">${this.esc(ws.icon || "\u{1F4CB}")} ${this.esc(ws.name)}</div>
<div class="ws-meta">${ws.task_count || 0} tasks \u00B7 ${ws.member_count || 0} members</div>
</div>
`).join("")}
</div>` : `<div class="empty">
<p style="font-size:16px;margin-bottom:8px">No workspaces yet</p>
<p style="font-size:13px">Create a workspace to start managing tasks</p>
</div>`}
`;
}
private renderBoard(): string {
return `
<div class="header">
<button class="nav-btn" data-back="list">\u2190 Back</button>
<span class="header-title">${this.esc(this.workspaceSlug)}</span>
<button class="create-btn" id="create-task">+ New Task</button>
</div>
<div class="board">
${this.statuses.map(status => {
const columnTasks = this.tasks.filter(t => t.status === status);
return `
<div class="column">
<div class="col-header">
<span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span>
</div>
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
</div>
`;
}).join("")}
</div>
`;
}
private renderTaskCard(task: any, currentStatus: string): string {
const otherStatuses = this.statuses.filter(s => s !== currentStatus);
return `
<div class="task-card">
<div class="task-title">${this.esc(task.title)}</div>
<div class="task-meta">
${task.priority === "URGENT" ? '<span class="badge badge-urgent">URGENT</span>' : ""}
${task.priority === "HIGH" ? '<span class="badge badge-high">HIGH</span>' : ""}
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
</div>
<div class="move-btns">
${otherStatuses.map(s => `<button class="move-btn" data-move="${task.id}" data-to="${s}">\u2192 ${this.esc(s.replace(/_/g, " ").substring(0, 8))}</button>`).join("")}
</div>
</div>
`;
}
private attachListeners() {
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
this.shadow.getElementById("create-task")?.addEventListener("click", () => this.createTask());
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!));
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { this.view = "list"; this.loadWorkspaces(); });
});
this.shadow.querySelectorAll("[data-move]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const btn = el as HTMLElement;
this.moveTask(btn.dataset.move!, btn.dataset.to!);
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-work-board", FolkWorkBoard);