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

470 lines
19 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 = "";
private isDemo = false;
private dragTaskId: string | null = null;
private editingTaskId: string | null = null;
private showCreateForm = false;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
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 submitCreateTask(title: string, priority: string, description: string) {
if (!title.trim()) return;
if (this.isDemo) {
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority, labels: [], description: description.trim() || undefined });
this.showCreateForm = false;
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(), priority, description: description.trim() || undefined }),
});
this.showCreateForm = false;
this.loadTasks();
} catch { this.error = "Failed to create task"; this.render(); }
}
private async updateTask(taskId: string, fields: Record<string, string>) {
if (this.isDemo) {
const task = this.tasks.find(t => t.id === taskId);
if (task) Object.assign(task, fields);
this.editingTaskId = null;
this.render();
return;
}
try {
const base = this.getApiBase();
await fetch(`${base}/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fields),
});
this.editingTaskId = null;
this.loadTasks();
} catch { this.error = "Failed to update task"; this.render(); }
}
private cyclePriority(taskId: string) {
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
const idx = this.priorities.indexOf(task.priority || "MEDIUM");
const next = this.priorities[(idx + 1) % this.priorities.length];
this.updateTask(taskId, { priority: next });
}
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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; text-decoration: none; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #6366f1; }
.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; }
.column.drag-over { background: #1a1a2e; border-color: #4f46e5; }
.task-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 10px 12px; margin-bottom: 8px; cursor: grab;
transition: opacity 0.2s;
}
.task-card.dragging { opacity: 0.4; }
.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; }
.badge-medium { background: #3b3511; color: #facc15; }
.badge-low { background: #112a3b; color: #60a5fa; }
.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; }
.create-form { background: #1a1a2e; border: 1px solid #4f46e5; border-radius: 8px; padding: 10px; margin-bottom: 10px; }
.create-form input, .create-form select, .create-form textarea {
width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
background: #16161e; color: #e0e0e0; font-size: 13px; margin-bottom: 6px; outline: none; font-family: inherit;
}
.create-form input:focus, .create-form select:focus, .create-form textarea:focus { border-color: #6366f1; }
.create-form textarea { resize: vertical; min-height: 40px; }
.create-form-actions { display: flex; gap: 6px; }
.create-form-actions button { padding: 4px 12px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; font-weight: 600; }
.cf-submit { background: #4f46e5; color: #fff; }
.cf-cancel { background: transparent; color: #888; border: 1px solid #333 !important; }
.task-title-input {
width: 100%; padding: 4px 6px; border-radius: 4px; border: 1px solid #6366f1;
background: #16161e; color: #e0e0e0; font-size: 13px; font-weight: 500; outline: none; font-family: inherit;
}
.badge.clickable { cursor: pointer; transition: all 0.15s; }
.badge.clickable:hover { filter: brightness(1.3); transform: scale(1.1); }
.empty { text-align: center; color: #666; padding: 40px; }
@media (max-width: 768px) {
.board { flex-direction: column; overflow-x: visible; }
.column { min-width: 100%; max-width: 100%; }
}
</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="rapp-nav">
<span class="rapp-nav__title">Workspaces</span>
<button class="rapp-nav__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 || "📋")} ${this.esc(ws.name)}</div>
<div class="ws-meta">${ws.task_count || 0} tasks · ${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 renderCreateForm(): string {
if (!this.showCreateForm) return "";
return `
<div class="create-form" id="create-form">
<input type="text" id="cf-title" placeholder="Task title..." autofocus>
<select id="cf-priority">
<option value="LOW">Low</option>
<option value="MEDIUM" selected>Medium</option>
<option value="HIGH">High</option>
<option value="URGENT">Urgent</option>
</select>
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
<div class="create-form-actions">
<button class="cf-submit" id="cf-submit">Add Task</button>
<button class="cf-cancel" id="cf-cancel">Cancel</button>
</div>
</div>`;
}
private renderBoard(): string {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
<button class="rapp-nav__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" data-status="${status}">
<div class="col-header">
<span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span>
</div>
${status === "TODO" ? this.renderCreateForm() : ""}
${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);
const isEditing = this.editingTaskId === task.id;
const priorityBadge = (p: string) => {
const map: Record<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" };
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
};
return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}">
${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
<div class="task-meta">
${priorityBadge(task.priority || "")}
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
</div>
${task.assignee ? `<div style="font-size:11px;color:#888;margin-top:4px">${this.esc(task.assignee)}</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.showCreateForm = !this.showCreateForm;
this.render();
if (this.showCreateForm) {
setTimeout(() => this.shadow.getElementById("cf-title")?.focus(), 0);
}
});
// Create form handlers
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
});
this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => {
this.showCreateForm = false; this.render();
});
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
}
});
// Inline title editing
this.shadow.querySelectorAll("[data-start-edit]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.editingTaskId = (el as HTMLElement).dataset.startEdit!;
this.render();
setTimeout(() => {
const input = this.shadow.querySelector(`[data-edit-title="${this.editingTaskId}"]`) as HTMLInputElement;
if (input) { input.focus(); input.select(); }
}, 0);
});
});
this.shadow.querySelectorAll("[data-edit-title]").forEach(el => {
const handler = () => {
const taskId = (el as HTMLElement).dataset.editTitle!;
const val = (el as HTMLInputElement).value;
if (val.trim()) this.updateTask(taskId, { title: val.trim() });
else { this.editingTaskId = null; this.render(); }
};
el.addEventListener("blur", handler);
el.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handler();
if ((e as KeyboardEvent).key === "Escape") { this.editingTaskId = null; this.render(); }
});
});
// Priority cycling
this.shadow.querySelectorAll("[data-cycle-priority]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.cyclePriority((el as HTMLElement).dataset.cyclePriority!);
});
});
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);