249 lines
8.9 KiB
TypeScript
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);
|