/** * โ€” 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 = ""; private isDemo = false; private dragTaskId: string | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadWorkspaces(); this.render(); } private loadDemoData() { this.isDemo = true; this.workspaces = [{ slug: "rspace-dev", name: "rSpace Development", icon: "\u{1F680}", task_count: 11, member_count: 2 }]; this.view = "board"; this.workspaceSlug = "rspace-dev"; this.tasks = [ { id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" }, { id: "d2", title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], assignee: "Bob" }, { id: "d3", title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], assignee: "Alice" }, { id: "d4", title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], assignee: "Alice" }, { id: "d5", title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" }, { id: "d6", title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], assignee: "Bob" }, { id: "d7", title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], assignee: "Alice" }, { id: "d8", title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], assignee: "Alice" }, { id: "d9", title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], assignee: "Bob" }, { id: "d10", title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" }, { id: "d11", title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" }, ]; 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; if (this.isDemo) { const slug = name.trim().toLowerCase().replace(/\s+/g, "-"); this.workspaces.push({ slug, name: name.trim(), icon: "\u{1F4CB}", task_count: 0, member_count: 1 }); this.render(); 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; if (this.isDemo) { this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority: "MEDIUM", labels: [] }); this.render(); 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) { if (this.isDemo) { const task = this.tasks.find(t => t.id === taskId); if (task) { task.status = newStatus; this.render(); } return; } 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 = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.view === "list" ? this.renderList() : this.renderBoard()} `; this.attachListeners(); } private renderList(): string { return `
Workspaces
${this.workspaces.length > 0 ? `
${this.workspaces.map(ws => `
${this.esc(ws.icon || "๐Ÿ“‹")} ${this.esc(ws.name)}
${ws.task_count || 0} tasks ยท ${ws.member_count || 0} members
`).join("")}
` : `

No workspaces yet

Create a workspace to start managing tasks

`} `; } private renderBoard(): string { return `
${this.esc(this.workspaceSlug)}
${this.statuses.map(status => { const columnTasks = this.tasks.filter(t => t.status === status); return `
${this.esc(status.replace(/_/g, " "))} ${columnTasks.length}
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
`; }).join("")}
`; } private renderTaskCard(task: any, currentStatus: string): string { const otherStatuses = this.statuses.filter(s => s !== currentStatus); const priorityBadge = (p: string) => { const map: Record = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" }; return map[p] ? `${this.esc(p.toLowerCase())}` : ""; }; return `
${this.esc(task.title)}
${priorityBadge(task.priority || "")} ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join("")}
${task.assignee ? `
${this.esc(task.assignee)}
` : ""}
${otherStatuses.map(s => ``).join("")}
`; } 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!); }); }); // HTML5 drag-and-drop on task cards this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => { card.addEventListener("dragstart", (e) => { const el = card as HTMLElement; this.dragTaskId = el.dataset.taskId || null; el.classList.add("dragging"); (e as DragEvent).dataTransfer?.setData("text/plain", this.dragTaskId || ""); }); card.addEventListener("dragend", () => { (card as HTMLElement).classList.remove("dragging"); this.dragTaskId = null; }); }); this.shadow.querySelectorAll(".column[data-status]").forEach(col => { col.addEventListener("dragover", (e) => { e.preventDefault(); (col as HTMLElement).classList.add("drag-over"); }); col.addEventListener("dragleave", () => { (col as HTMLElement).classList.remove("drag-over"); }); col.addEventListener("drop", (e) => { e.preventDefault(); (col as HTMLElement).classList.remove("drag-over"); const status = (col as HTMLElement).dataset.status!; if (this.dragTaskId) { this.moveTask(this.dragTaskId, status); this.dragTaskId = null; } }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-work-board", FolkWorkBoard);