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.
*/
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 { ViewHistory } from "../../../shared/view-history.js";
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
class FolkTasksBoard 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;
@ -32,7 +27,6 @@ class FolkTasksBoard extends HTMLElement {
private dragSourceStatus: string | null = null;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"list" | "board">("list");
private _backlogTaskId: string | null = null;
private _stopPresence: (() => void) | null = null;
// Detail panel
@ -45,8 +39,6 @@ class FolkTasksBoard extends HTMLElement {
private _showColumnEditor = false;
// Board labels (from server)
private _boardLabels: string[] = [];
// Inline workspace creation
private _showCreateWs = false;
// Inline delete confirmation
private _confirmDeleteId: string | null = null;
// ClickUp integration state
@ -65,7 +57,6 @@ class FolkTasksBoard extends HTMLElement {
private _cuImportResult: { boardId: string; taskCount: number } | null = null;
private _tour!: TourEngine;
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: '.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 },
@ -84,13 +75,14 @@ class FolkTasksBoard extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.workspaceSlug = this.space;
// Check for ?backlog= deep-link from email checklist
const params = new URLSearchParams(window.location.search);
this._backlogTaskId = params.get("backlog");
if (this.space === "demo") { this.loadDemoData(); }
else {
this.subscribeOffline();
this.loadWorkspaces();
this.loadTasks();
this.loadClickUpStatus();
this.render();
}
@ -137,19 +129,12 @@ class FolkTasksBoard extends HTMLElement {
try {
const docs = await runtime.subscribeModule('tasks', 'boards', boardSchema);
// Build workspace list from cached boards
if (docs.size > 0 && this.workspaces.length === 0) {
const boards: any[] = [];
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 });
// Subscribe to changes for the current board
if (docs.size > 0) {
for (const [docId] of docs) {
this._offlineUnsubs.push(runtime.onChange(docId, () => this.refreshFromDocs()));
}
if (boards.length > 0) {
this.workspaces = boards;
this.render();
}
this.refreshFromDocs();
}
} catch { /* runtime unavailable */ }
}
@ -172,8 +157,6 @@ class FolkTasksBoard extends HTMLElement {
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", sort_order: 0 },
@ -204,19 +187,6 @@ class FolkTasksBoard extends HTMLElement {
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() {
if (!this.workspaceSlug) return;
try {
@ -327,7 +297,7 @@ class FolkTasksBoard extends HTMLElement {
this.error = 'Import failed';
this._cuStep = 'list';
}
this.loadWorkspaces();
this.loadTasks();
this.render();
}
@ -443,33 +413,6 @@ class FolkTasksBoard extends HTMLElement {
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) {
if (!title.trim()) return;
if (this.isDemo) {
@ -686,20 +629,6 @@ class FolkTasksBoard extends HTMLElement {
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() {
this.shadow.innerHTML = `
<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: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; }
.column {
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 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 */
.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; }
@ -916,7 +827,6 @@ class FolkTasksBoard extends HTMLElement {
@media (max-width: 768px) {
.board { flex-direction: column; overflow-x: visible; }
.column { min-width: 100%; max-width: 100%; }
.workspace-grid { grid-template-columns: 1fr; }
.detail-panel { width: 100vw; }
}
@media (max-width: 480px) {
@ -937,7 +847,7 @@ class FolkTasksBoard extends HTMLElement {
<span>📋 Backlog task: <strong>${this.esc(this._backlogTaskId)}</strong></span>
<button class="backlog-dismiss" data-dismiss-backlog></button>
</div>` : ""}
${this.view === "list" ? this.renderList() : this.renderBoard()}
${this.renderBoard()}
`;
this.attachListeners();
this._tour.renderOverlay();
@ -947,36 +857,6 @@ class FolkTasksBoard extends HTMLElement {
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 {
if (!this.showCreateForm) return "";
return `
@ -1006,9 +886,9 @@ class FolkTasksBoard extends HTMLElement {
const hasFilters = !!(this._searchQuery || this._filterPriority || this._filterLabel);
return `
<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>
${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>
<div class="view-toggle">
<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>' : ''}
<span class="filter-bar__count">${filtered.length} of ${this.tasks.length} tasks</span>
</div>
${this.renderClickUpPanel()}
${this._showColumnEditor ? this.renderColumnEditor() : ''}
${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()}
${this.detailTaskId ? this.renderDetailPanel() : ''}
@ -1222,26 +1103,6 @@ class FolkTasksBoard extends HTMLElement {
window.history.replaceState({}, "", url.toString());
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.showCreateForm = !this.showCreateForm;
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 => {
el.addEventListener("click", (e) => {
e.stopPropagation();
@ -1582,13 +1433,10 @@ class FolkTasksBoard extends HTMLElement {
this._cuEnableSync = (e.target as HTMLInputElement).checked;
});
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._cuStep = 'token';
this._cuImportResult = null;
this.openBoard(board);
}
this._cuShowPanel = false;
this._cuStep = 'token';
this._cuImportResult = null;
this.loadTasks();
});
// Pointer events drag-and-drop on task cards (works with touch, pen, mouse)