Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m55s Details

This commit is contained in:
Jeff Emmett 2026-04-06 20:27:56 -04:00
commit 17a17103f4
1 changed files with 16 additions and 168 deletions

View File

@ -1,23 +1,18 @@
/** /**
* <folk-tasks-board> kanban board for workspace task management. * <folk-tasks-board> kanban board for per-space task management.
* *
* Views: workspace list board with draggable columns. * Single board per space, loaded directly on page load.
* Supports task creation, status changes, and priority labels. * Supports task creation, status changes, and priority labels.
*/ */
import { boardSchema, type BoardDoc } from "../schemas"; import { boardSchema, type BoardDoc } from "../schemas";
import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTasksBoard extends HTMLElement { class FolkTasksBoard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
private view: "list" | "board" = "list";
private workspaceSlug = ""; private workspaceSlug = "";
private workspaces: any[] = [];
private tasks: any[] = []; private tasks: any[] = [];
private statuses: string[] = ["TODO", "IN_PROGRESS", "REVIEW", "DONE"]; private statuses: string[] = ["TODO", "IN_PROGRESS", "REVIEW", "DONE"];
private loading = false; private loading = false;
@ -32,7 +27,6 @@ class FolkTasksBoard extends HTMLElement {
private dragSourceStatus: string | null = null; private dragSourceStatus: string | null = null;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list");
private _backlogTaskId: string | null = null; private _backlogTaskId: string | null = null;
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Detail panel // Detail panel
@ -45,8 +39,6 @@ class FolkTasksBoard extends HTMLElement {
private _showColumnEditor = false; private _showColumnEditor = false;
// Board labels (from server) // Board labels (from server)
private _boardLabels: string[] = []; private _boardLabels: string[] = [];
// Inline workspace creation
private _showCreateWs = false;
// Inline delete confirmation // Inline delete confirmation
private _confirmDeleteId: string | null = null; private _confirmDeleteId: string | null = null;
// ClickUp integration state // ClickUp integration state
@ -65,7 +57,6 @@ class FolkTasksBoard extends HTMLElement {
private _cuImportResult: { boardId: string; taskCount: number } | null = null; private _cuImportResult: { boardId: string; taskCount: number } | null = null;
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
{ target: '.workspace-card', title: "Workspaces", message: "Select a workspace to open its kanban board.", advanceOnClick: true },
{ target: '#create-task', title: "New Task", message: "Create a new task with title, priority, and description.", advanceOnClick: false }, { target: '#create-task', title: "New Task", message: "Create a new task with title, priority, and description.", advanceOnClick: false },
{ target: '.board', title: "Kanban Board", message: "Drag tasks between columns — TODO, In Progress, Review, Done.", advanceOnClick: false }, { target: '.board', title: "Kanban Board", message: "Drag tasks between columns — TODO, In Progress, Review, Done.", advanceOnClick: false },
{ target: '.badge.clickable', title: "Priority", message: "Click the priority badge on a task to cycle through levels.", advanceOnClick: false }, { target: '.badge.clickable', title: "Priority", message: "Click the priority badge on a task to cycle through levels.", advanceOnClick: false },
@ -84,13 +75,14 @@ class FolkTasksBoard extends HTMLElement {
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.workspaceSlug = this.space;
// Check for ?backlog= deep-link from email checklist // Check for ?backlog= deep-link from email checklist
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
this._backlogTaskId = params.get("backlog"); this._backlogTaskId = params.get("backlog");
if (this.space === "demo") { this.loadDemoData(); } if (this.space === "demo") { this.loadDemoData(); }
else { else {
this.subscribeOffline(); this.subscribeOffline();
this.loadWorkspaces(); this.loadTasks();
this.loadClickUpStatus(); this.loadClickUpStatus();
this.render(); this.render();
} }
@ -137,19 +129,12 @@ class FolkTasksBoard extends HTMLElement {
try { try {
const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema); const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
// Build workspace list from cached boards // Subscribe to changes for the current board
if (docs.size > 0 && this.workspaces.length === 0) { if (docs.size > 0) {
const boards: any[] = []; for (const [docId] of docs) {
for (const [docId, doc] of docs) {
const d = doc as BoardDoc;
if (!d?.board) continue;
boards.push({ slug: d.board.slug, name: d.board.name, icon: null, task_count: Object.keys(d.tasks || {}).length });
this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs())); this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs()));
} }
if (boards.length > 0) { this.refreshFromDocs();
this.workspaces = boards;
this.render();
}
} }
} catch { /* runtime unavailable */ } } catch { /* runtime unavailable */ }
} }
@ -172,8 +157,6 @@ class FolkTasksBoard extends HTMLElement {
private loadDemoData() { private loadDemoData() {
this.isDemo = true; 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.workspaceSlug = "rspace-dev";
this.tasks = [ this.tasks = [
{ id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 0 }, { id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 0 },
@ -204,19 +187,6 @@ class FolkTasksBoard extends HTMLElement {
return h; return h;
} }
private async loadWorkspaces() {
try {
const base = this.getApiBase();
let res = await fetch(`${base}/api/spaces`, { headers: this.authHeaders() });
// If 401 with stale token, retry without auth
if (res.status === 401 && localStorage.getItem("encryptid-token")) {
res = await fetch(`${base}/api/spaces`);
}
if (res.ok) this.workspaces = await res.json();
} catch { this.workspaces = []; }
this.render();
}
private async loadTasks() { private async loadTasks() {
if (!this.workspaceSlug) return; if (!this.workspaceSlug) return;
try { try {
@ -327,7 +297,7 @@ class FolkTasksBoard extends HTMLElement {
this.error = 'Import failed'; this.error = 'Import failed';
this._cuStep = 'list'; this._cuStep = 'list';
} }
this.loadWorkspaces(); this.loadTasks();
this.render(); this.render();
} }
@ -443,33 +413,6 @@ class FolkTasksBoard extends HTMLElement {
return ''; return '';
} }
private async createWorkspace(name: string) {
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._showCreateWs = false;
this.render();
return;
}
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/spaces`, {
method: "POST",
headers: this.authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ name: name.trim() }),
});
if (res.ok) {
this._showCreateWs = false;
this.loadWorkspaces();
} else {
const data = await res.json().catch(() => ({}));
this.error = data.error || `Failed to create workspace (${res.status})`;
this.render();
}
} catch { this.error = "Failed to create workspace"; this.render(); }
}
private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string) { private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string) {
if (!title.trim()) return; if (!title.trim()) return;
if (this.isDemo) { if (this.isDemo) {
@ -686,20 +629,6 @@ class FolkTasksBoard extends HTMLElement {
return { sortOrder: ordinal, rebalanceUpdates }; return { sortOrder: ordinal, rebalanceUpdates };
} }
private openBoard(slug: string) {
this.workspaceSlug = slug;
this.view = "board";
this.loadTasks();
}
private goBack() {
const prev = this._history.back();
if (!prev) return;
this.view = prev.view;
if (prev.view === "list") this.loadWorkspaces();
else this.render();
}
private render() { private render() {
this.shadow.innerHTML = ` this.shadow.innerHTML = `
<style> <style>
@ -714,15 +643,6 @@ class FolkTasksBoard extends HTMLElement {
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; } .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: var(--rs-primary-hover); } .rapp-nav__btn:hover { background: var(--rs-primary-hover); }
.workspace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.workspace-card {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.workspace-card:hover { border-color: var(--rs-border-strong); }
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.ws-meta { font-size: 12px; color: var(--rs-text-muted); }
.board { display: flex; gap: 12px; overflow-x: auto; min-height: 400px; padding-bottom: 12px; } .board { display: flex; gap: 12px; overflow-x: auto; min-height: 400px; padding-bottom: 12px; }
.column { .column {
min-width: 240px; max-width: 280px; flex-shrink: 0; min-width: 240px; max-width: 280px; flex-shrink: 0;
@ -893,15 +813,6 @@ class FolkTasksBoard extends HTMLElement {
.col-editor__add input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 12px; outline: none; } .col-editor__add input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 12px; outline: none; }
.col-editor__add button { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; } .col-editor__add button { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; cursor: pointer; font-size: 12px; font-weight: 600; }
/* Inline workspace create form */
.ws-create-form { background: var(--rs-bg-surface); border: 1px solid var(--rs-primary); border-radius: 10px; padding: 14px; margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }
.ws-create-form input { flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 14px; outline: none; }
.ws-create-form input:focus { border-color: var(--rs-primary-hover); }
.ws-create-form button { padding: 8px 16px; border-radius: 6px; border: none; font-size: 13px; cursor: pointer; font-weight: 600; }
.ws-create-submit { background: var(--rs-primary); color: #fff; }
.ws-create-submit:hover { background: var(--rs-primary-hover); }
.ws-create-cancel { background: transparent; color: var(--rs-text-muted); border: 1px solid var(--rs-border-strong) !important; }
/* Inline delete confirmation */ /* Inline delete confirmation */
.confirm-delete { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #3b1111; border-radius: 6px; margin-top: 6px; } .confirm-delete { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #3b1111; border-radius: 6px; margin-top: 6px; }
.confirm-delete span { font-size: 12px; color: #f87171; } .confirm-delete span { font-size: 12px; color: #f87171; }
@ -916,7 +827,6 @@ class FolkTasksBoard extends HTMLElement {
@media (max-width: 768px) { @media (max-width: 768px) {
.board { flex-direction: column; overflow-x: visible; } .board { flex-direction: column; overflow-x: visible; }
.column { min-width: 100%; max-width: 100%; } .column { min-width: 100%; max-width: 100%; }
.workspace-grid { grid-template-columns: 1fr; }
.detail-panel { width: 100vw; } .detail-panel { width: 100vw; }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@ -937,7 +847,7 @@ class FolkTasksBoard extends HTMLElement {
<span>📋 Backlog task: <strong>${this.esc(this._backlogTaskId)}</strong></span> <span>📋 Backlog task: <strong>${this.esc(this._backlogTaskId)}</strong></span>
<button class="backlog-dismiss" data-dismiss-backlog></button> <button class="backlog-dismiss" data-dismiss-backlog></button>
</div>` : ""} </div>` : ""}
${this.view === "list" ? this.renderList() : this.renderBoard()} ${this.renderBoard()}
`; `;
this.attachListeners(); this.attachListeners();
this._tour.renderOverlay(); this._tour.renderOverlay();
@ -947,36 +857,6 @@ class FolkTasksBoard extends HTMLElement {
this._tour.start(); this._tour.start();
} }
private renderList(): string {
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Workspaces</span>
${!this.isDemo ? `<button class="cu-connect-btn" id="cu-toggle-panel">${this._cuConnected ? 'CU Connected' : 'Connect ClickUp'}</button>` : ''}
<button class="rapp-nav__btn" id="create-ws">+ New Workspace</button>
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this._showCreateWs ? `
<div class="ws-create-form">
<input type="text" id="ws-name-input" placeholder="Workspace name..." autofocus>
<button class="ws-create-submit" id="ws-create-submit">Create</button>
<button class="ws-create-cancel" id="ws-create-cancel">Cancel</button>
</div>
` : ''}
${this.renderClickUpPanel()}
${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 { private renderCreateForm(): string {
if (!this.showCreateForm) return ""; if (!this.showCreateForm) return "";
return ` return `
@ -1006,9 +886,9 @@ class FolkTasksBoard extends HTMLElement {
const hasFilters = !!(this._searchQuery || this._filterPriority || this._filterLabel); const hasFilters = !!(this._searchQuery || this._filterPriority || this._filterLabel);
return ` return `
<div class="rapp-nav"> <div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>' : ''}
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span> <span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
${cuSyncInfo} ${cuSyncInfo}
${!this.isDemo ? `<button class="cu-connect-btn" id="cu-toggle-panel">${this._cuConnected ? 'CU Connected' : 'Connect ClickUp'}</button>` : ''}
<button class="gear-btn" id="toggle-col-editor" title="Manage columns">&#9881;</button> <button class="gear-btn" id="toggle-col-editor" title="Manage columns">&#9881;</button>
<div class="view-toggle"> <div class="view-toggle">
<button class="view-toggle__btn ${this.boardView === 'board' ? 'active' : ''}" data-set-view="board">Board</button> <button class="view-toggle__btn ${this.boardView === 'board' ? 'active' : ''}" data-set-view="board">Board</button>
@ -1025,6 +905,7 @@ class FolkTasksBoard extends HTMLElement {
${hasFilters ? '<button class="filter-bar__clear" id="filter-clear">Clear</button>' : ''} ${hasFilters ? '<button class="filter-bar__clear" id="filter-clear">Clear</button>' : ''}
<span class="filter-bar__count">${filtered.length} of ${this.tasks.length} tasks</span> <span class="filter-bar__count">${filtered.length} of ${this.tasks.length} tasks</span>
</div> </div>
${this.renderClickUpPanel()}
${this._showColumnEditor ? this.renderColumnEditor() : ''} ${this._showColumnEditor ? this.renderColumnEditor() : ''}
${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()} ${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()}
${this.detailTaskId ? this.renderDetailPanel() : ''} ${this.detailTaskId ? this.renderDetailPanel() : ''}
@ -1222,26 +1103,6 @@ class FolkTasksBoard extends HTMLElement {
window.history.replaceState({}, "", url.toString()); window.history.replaceState({}, "", url.toString());
this.render(); this.render();
}); });
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-ws")?.addEventListener("click", () => {
this._showCreateWs = !this._showCreateWs;
this.render();
if (this._showCreateWs) setTimeout(() => this.shadow.getElementById("ws-name-input")?.focus(), 0);
});
this.shadow.getElementById("ws-create-submit")?.addEventListener("click", () => {
const input = this.shadow.getElementById("ws-name-input") as HTMLInputElement;
if (input?.value?.trim()) this.createWorkspace(input.value.trim());
});
this.shadow.getElementById("ws-name-input")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
const input = e.target as HTMLInputElement;
if (input.value?.trim()) this.createWorkspace(input.value.trim());
}
if ((e as KeyboardEvent).key === "Escape") { this._showCreateWs = false; this.render(); }
});
this.shadow.getElementById("ws-create-cancel")?.addEventListener("click", () => {
this._showCreateWs = false; this.render();
});
this.shadow.getElementById("create-task")?.addEventListener("click", () => { this.shadow.getElementById("create-task")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm; this.showCreateForm = !this.showCreateForm;
this.render(); this.render();
@ -1303,16 +1164,6 @@ class FolkTasksBoard extends HTMLElement {
}); });
}); });
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
el.addEventListener("click", () => {
this._history.push("list");
this._history.push("board", { ws: (el as HTMLElement).dataset.ws });
this.openBoard((el as HTMLElement).dataset.ws!);
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => this.goBack());
});
this.shadow.querySelectorAll("[data-move]").forEach(el => { this.shadow.querySelectorAll("[data-move]").forEach(el => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@ -1582,13 +1433,10 @@ class FolkTasksBoard extends HTMLElement {
this._cuEnableSync = (e.target as HTMLInputElement).checked; this._cuEnableSync = (e.target as HTMLInputElement).checked;
}); });
this.shadow.getElementById("cu-open-board")?.addEventListener("click", () => { this.shadow.getElementById("cu-open-board")?.addEventListener("click", () => {
const board = (this.shadow.getElementById("cu-open-board") as HTMLElement)?.dataset.cuBoard;
if (board) {
this._cuShowPanel = false; this._cuShowPanel = false;
this._cuStep = 'token'; this._cuStep = 'token';
this._cuImportResult = null; this._cuImportResult = null;
this.openBoard(board); this.loadTasks();
}
}); });
// Pointer events drag-and-drop on task cards (works with touch, pen, mouse) // Pointer events drag-and-drop on task cards (works with touch, pen, mouse)