diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts index 75ab130..40cc37a 100644 --- a/modules/rtasks/components/folk-tasks-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -35,6 +35,16 @@ class FolkTasksBoard extends HTMLElement { private _history = new ViewHistory<"list" | "board">("list"); private _backlogTaskId: string | null = null; private _stopPresence: (() => void) | null = null; + // Detail panel + private detailTaskId: string | null = null; + // Search & filter + private _searchQuery = ''; + private _filterPriority = ''; + private _filterLabel = ''; + // Column editor + private _showColumnEditor = false; + // Board labels (from server) + private _boardLabels: string[] = []; // ClickUp integration state private _cuConnected = false; private _cuTeamName = ''; @@ -84,12 +94,37 @@ class FolkTasksBoard extends HTMLElement { setTimeout(() => this._tour.start(), 1200); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtasks', context: this.workspaceSlug || 'Workspaces' })); + this._escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (this.detailTaskId) { this.detailTaskId = null; this.render(); } + else if (this._showColumnEditor) { this._showColumnEditor = false; this.render(); } + } + }; + document.addEventListener('keydown', this._escHandler); + } + + private _escHandler: ((e: KeyboardEvent) => void) | null = null; + + private getFilteredTasks(): any[] { + let filtered = this.tasks; + if (this._searchQuery) { + const q = this._searchQuery.toLowerCase(); + filtered = filtered.filter(t => t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)); + } + if (this._filterPriority) { + filtered = filtered.filter(t => t.priority === this._filterPriority); + } + if (this._filterLabel) { + filtered = filtered.filter(t => (t.labels || []).includes(this._filterLabel)); + } + return filtered; } disconnectedCallback() { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; this._stopPresence?.(); + if (this._escHandler) document.removeEventListener('keydown', this._escHandler); } private async subscribeOffline() { @@ -158,10 +193,17 @@ class FolkTasksBoard extends HTMLElement { return match ? match[0] : ""; } + private authHeaders(extra?: Record): Record { + const h: Record = { ...extra }; + const token = localStorage.getItem("encryptid-token"); + if (token) h["Authorization"] = `Bearer ${token}`; + return h; + } + private async loadWorkspaces() { try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/spaces`); + const res = await fetch(`${base}/api/spaces`, { headers: this.authHeaders() }); if (res.ok) this.workspaces = await res.json(); } catch { this.workspaces = []; } this.render(); @@ -171,13 +213,14 @@ class FolkTasksBoard extends HTMLElement { if (!this.workspaceSlug) return; try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`); + const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, { headers: this.authHeaders() }); if (res.ok) this.tasks = await res.json(); - const spaceRes = await fetch(`${base}/api/spaces/${this.workspaceSlug}`); + const spaceRes = await fetch(`${base}/api/spaces/${this.workspaceSlug}`, { headers: this.authHeaders() }); if (spaceRes.ok) { const space = await spaceRes.json(); if (space.statuses?.length) this.statuses = space.statuses; + this._boardLabels = space.labels || []; this._boardClickup = space.clickup || null; } } catch { this.tasks = []; } @@ -188,7 +231,7 @@ class FolkTasksBoard extends HTMLElement { if (this.isDemo) return; try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/clickup/status`); + const res = await fetch(`${base}/api/clickup/status`, { headers: this.authHeaders() }); if (res.ok) { const data = await res.json(); this._cuConnected = data.connected; @@ -203,7 +246,7 @@ class FolkTasksBoard extends HTMLElement { const base = this.getApiBase(); const res = await fetch(`${base}/api/clickup/connect-token`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ token }), }); if (res.ok) { @@ -224,7 +267,7 @@ class FolkTasksBoard extends HTMLElement { private async loadClickUpWorkspaces() { try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/clickup/workspaces`); + const res = await fetch(`${base}/api/clickup/workspaces`, { headers: this.authHeaders() }); if (res.ok) this._cuWorkspaces = await res.json(); } catch { this._cuWorkspaces = []; } } @@ -233,7 +276,7 @@ class FolkTasksBoard extends HTMLElement { this._cuSelectedTeam = teamId; try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/clickup/spaces/${teamId}`); + const res = await fetch(`${base}/api/clickup/spaces/${teamId}`, { headers: this.authHeaders() }); if (res.ok) this._cuSpaces = await res.json(); } catch { this._cuSpaces = []; } this.render(); @@ -243,7 +286,7 @@ class FolkTasksBoard extends HTMLElement { this._cuSelectedSpace = spaceId; try { const base = this.getApiBase(); - const res = await fetch(`${base}/api/clickup/lists/${spaceId}`); + const res = await fetch(`${base}/api/clickup/lists/${spaceId}`, { headers: this.authHeaders() }); if (res.ok) this._cuLists = await res.json(); } catch { this._cuLists = []; } this._cuStep = 'list'; @@ -258,7 +301,7 @@ class FolkTasksBoard extends HTMLElement { const base = this.getApiBase(); const res = await fetch(`${base}/api/clickup/import`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: this.authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ listId: this._cuSelectedList, enableSync: this._cuEnableSync, @@ -282,7 +325,7 @@ class FolkTasksBoard extends HTMLElement { private async disconnectClickUp() { try { const base = this.getApiBase(); - await fetch(`${base}/api/clickup/disconnect`, { method: 'POST' }); + await fetch(`${base}/api/clickup/disconnect`, { method: 'POST', headers: this.authHeaders() }); this._cuConnected = false; this._cuTeamName = ''; this._cuShowPanel = false; @@ -404,17 +447,17 @@ class FolkTasksBoard extends HTMLElement { const base = this.getApiBase(); const res = await fetch(`${base}/api/spaces`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: this.authHeaders({ "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) { + private async submitCreateTask(title: string, priority: string, description: string, dueDate?: 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.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: this.statuses[0] || "TODO", priority, labels: [], description: description.trim() || undefined, due_date: dueDate || null, sort_order: 0 }); this.showCreateForm = false; this.render(); return; @@ -423,8 +466,8 @@ class FolkTasksBoard extends HTMLElement { 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 }), + headers: this.authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ title: title.trim(), priority, description: description.trim() || undefined, due_date: dueDate || undefined }), }); this.showCreateForm = false; this.loadTasks(); @@ -443,7 +486,7 @@ class FolkTasksBoard extends HTMLElement { const base = this.getApiBase(); await fetch(`${base}/api/tasks/${taskId}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: this.authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(fields), }); this.editingTaskId = null; @@ -451,6 +494,44 @@ class FolkTasksBoard extends HTMLElement { } catch { this.error = "Failed to update task"; this.render(); } } + private async deleteTask(taskId: string) { + if (this.isDemo) { + this.tasks = this.tasks.filter(t => t.id !== taskId); + if (this.detailTaskId === taskId) this.detailTaskId = null; + this.render(); + return; + } + try { + const base = this.getApiBase(); + await fetch(`${base}/api/tasks/${taskId}`, { method: "DELETE", headers: this.authHeaders() }); + if (this.detailTaskId === taskId) this.detailTaskId = null; + this.loadTasks(); + } catch { this.error = "Failed to delete task"; this.render(); } + } + + private async updateBoardMeta(fields: Record) { + if (this.isDemo) { + if (fields.statuses) this.statuses = fields.statuses; + if (fields.labels) this._boardLabels = fields.labels; + this.render(); + return; + } + try { + const base = this.getApiBase(); + const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}`, { + method: "PATCH", + headers: this.authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(fields), + }); + if (res.ok) { + const data = await res.json(); + if (data.statuses) this.statuses = data.statuses; + if (data.labels) this._boardLabels = data.labels; + } + } catch { this.error = "Failed to update board"; } + this.render(); + } + private cyclePriority(taskId: string) { const task = this.tasks.find(t => t.id === taskId); if (!task) return; @@ -475,7 +556,7 @@ class FolkTasksBoard extends HTMLElement { if (sortOrder !== undefined) body.sort_order = sortOrder; await fetch(`${base}/api/tasks/${taskId}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: this.authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); this.loadTasks(); @@ -502,14 +583,14 @@ class FolkTasksBoard extends HTMLElement { const base = this.getApiBase(); fetch(`${base}/api/tasks/${taskId}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: this.authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ status: newStatus, sort_order: sortOrder }), }).catch(() => { this.error = "Failed to save task move"; this.render(); }); if (rebalanceUpdates.length > 0) { fetch(`${base}/api/tasks/bulk-sort-order`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: this.authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ updates: rebalanceUpdates }), }).catch(() => {}); // non-critical } @@ -629,12 +710,19 @@ class FolkTasksBoard extends HTMLElement { .task-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; cursor: grab; - transition: opacity 0.2s; + transition: opacity 0.2s, transform 0.15s; + position: relative; } - .task-card.dragging { opacity: 0.4; } + .task-card.dragging { opacity: 0.4; transform: rotate(2deg) scale(1.05); } .task-card:hover { border-color: var(--rs-border-strong); } + .task-card:hover .task-delete-btn { opacity: 1; } + .task-delete-btn { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; border: none; background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); border-radius: 4px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; z-index: 1; } + .task-delete-btn:hover { background: #3b1111; color: #f87171; } .task-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; } - .task-meta { display: flex; gap: 6px; flex-wrap: wrap; } + .task-desc-preview { font-size: 11px; color: var(--rs-text-muted); margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; } + .task-due { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--rs-border); color: var(--rs-text-muted); } + .task-due.overdue { background: #3b1111; color: #f87171; } + .task-meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } .badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--rs-border); color: var(--rs-text-muted); } .badge-urgent { background: #3b1111; color: #f87171; } .badge-high { background: #3b2611; color: #fb923c; } @@ -680,8 +768,8 @@ class FolkTasksBoard extends HTMLElement { @keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } } /* Empty column drop zone */ - .empty-drop-zone { border: 2px dashed #22c55e; border-radius: 8px; padding: 24px 12px; text-align: center; color: #22c55e; font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0; transition: opacity 0.15s; } - .column.drag-over .empty-drop-zone { opacity: 1; } + .empty-drop-zone { border: 2px dashed var(--rs-border); border-radius: 8px; padding: 24px 12px; text-align: center; color: var(--rs-text-muted); font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0.4; transition: all 0.15s; } + .column.drag-over .empty-drop-zone { opacity: 1; border-color: #22c55e; color: #22c55e; background: rgba(34,197,94,0.05); } /* Checklist view */ .checklist { max-width: 720px; } @@ -733,10 +821,60 @@ class FolkTasksBoard extends HTMLElement { .cu-connect-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #7b68ee; background: transparent; color: #7b68ee; cursor: pointer; font-size: 12px; font-weight: 600; } .cu-connect-btn:hover { background: rgba(123, 104, 238, 0.1); } + /* Detail panel slide-out */ + .detail-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); z-index: 100; } + .detail-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100vw; background: var(--rs-bg-surface); border-left: 1px solid var(--rs-border-strong); z-index: 101; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; animation: slideIn 0.2s ease-out; } + @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } } + .detail-panel__close { position: absolute; top: 12px; right: 12px; width: 28px; height: 28px; border: none; background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); border-radius: 6px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; } + .detail-panel__close:hover { color: var(--rs-text-primary); } + .detail-field { display: flex; flex-direction: column; gap: 4px; } + .detail-field label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--rs-text-muted); } + .detail-field input, .detail-field select, .detail-field textarea { + padding: 8px 10px; border-radius: 6px; border: 1px solid var(--rs-border-strong); + background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 13px; outline: none; font-family: inherit; + } + .detail-field input:focus, .detail-field select:focus, .detail-field textarea:focus { border-color: var(--rs-primary-hover); } + .detail-field textarea { resize: vertical; min-height: 80px; } + .detail-field .readonly { font-size: 12px; color: var(--rs-text-muted); padding: 4px 0; } + .detail-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } + .detail-label-chip { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 3px 8px; border-radius: 4px; background: var(--rs-border); color: var(--rs-text-secondary); } + .detail-label-chip button { border: none; background: none; color: var(--rs-text-muted); cursor: pointer; font-size: 12px; padding: 0 2px; } + .detail-label-chip button:hover { color: #f87171; } + .detail-delete { padding: 8px 16px; border-radius: 6px; border: 1px solid #f87171; background: transparent; color: #f87171; cursor: pointer; font-size: 13px; font-weight: 600; margin-top: 8px; } + .detail-delete:hover { background: #3b1111; } + + /* Search & filter bar */ + .filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; } + .filter-bar input { flex: 1; min-width: 160px; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-primary); font-size: 13px; outline: none; } + .filter-bar input:focus { border-color: var(--rs-primary-hover); } + .filter-bar select { padding: 6px 10px; 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; } + .filter-bar__count { font-size: 12px; color: var(--rs-text-muted); white-space: nowrap; } + .filter-bar__clear { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 11px; } + .filter-bar__clear:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + .badge.filter-active { outline: 2px solid var(--rs-primary); outline-offset: 1px; } + + /* Column editor */ + .col-editor { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px; padding: 16px; margin-bottom: 16px; } + .col-editor h3 { font-size: 14px; font-weight: 600; margin: 0 0 12px; } + .col-editor__item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; margin-bottom: 4px; background: var(--rs-bg-surface-sunken); } + .col-editor__item input { flex: 1; padding: 4px 6px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); color: var(--rs-text-primary); font-size: 12px; outline: none; } + .col-editor__btn { padding: 3px 8px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 11px; } + .col-editor__btn:hover { color: var(--rs-text-primary); } + .col-editor__btn.danger { color: #f87171; border-color: #f87171; } + .col-editor__btn.danger:hover { background: #3b1111; } + .col-editor__add { display: flex; gap: 8px; margin-top: 8px; } + .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; } + + /* Gear icon */ + .gear-btn { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--rs-border-subtle); background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; } + .gear-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + @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) { .rapp-nav { gap: 4px; } @@ -814,26 +952,41 @@ class FolkTasksBoard extends HTMLElement { const cuSyncInfo = this._boardClickup?.syncEnabled ? ` CU ${this.esc(this._boardClickup.listName || '')}` : ''; + const filtered = this.getFilteredTasks(); + const hasFilters = !!(this._searchQuery || this._filterPriority || this._filterLabel); return `
${this._history.canGoBack ? '' : ''} ${this.esc(this.workspaceSlug)} ${cuSyncInfo} +
+
+ + + ${hasFilters ? '' : ''} + ${filtered.length} of ${this.tasks.length} tasks +
+ ${this._showColumnEditor ? this.renderColumnEditor() : ''} ${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()} + ${this.detailTaskId ? this.renderDetailPanel() : ''} `; } private renderKanban(): string { + const filtered = this.getFilteredTasks(); return `
${this.statuses.map(status => { - const columnTasks = this.tasks + const columnTasks = filtered .filter(t => t.status === status) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); const isDragSource = this.dragTaskId && this.dragSourceStatus === status; @@ -843,9 +996,9 @@ class FolkTasksBoard extends HTMLElement { ${this.esc(status.replace(/_/g, " "))} ${columnTasks.length}
- ${status === "TODO" ? this.renderCreateForm() : ""} + ${status === this.statuses[0] ? this.renderCreateForm() : ""} ${columnTasks.map(t => this.renderTaskCard(t, status)).join("")} - ${columnTasks.length === 0 && status !== "TODO" ? '
Drop task here to change status
' : ''} + ${columnTasks.length === 0 ? '
Drop here
' : ''} `; }).join("")} @@ -854,10 +1007,11 @@ class FolkTasksBoard extends HTMLElement { } private renderChecklist(): string { + const filtered = this.getFilteredTasks(); return `
${this.statuses.map(status => { - const columnTasks = this.tasks + const columnTasks = filtered .filter(t => t.status === status) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); if (columnTasks.length === 0) return ''; @@ -886,7 +1040,6 @@ class FolkTasksBoard extends HTMLElement { } 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 = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" }; @@ -895,18 +1048,113 @@ class FolkTasksBoard extends HTMLElement { const cuBadge = task.clickup ? `CU ` : ''; + const descPreview = task.description + ? `
${this.esc(task.description)}
` + : ''; + const dueDateBadge = (() => { + const raw = task.due_date || task.dueDate; + if (!raw) return ''; + const d = new Date(raw); + const overdue = d.getTime() < Date.now() && currentStatus !== 'DONE'; + const fmt = `${d.getMonth()+1}/${d.getDate()}`; + return `${fmt}`; + })(); + const labelFilter = this._filterLabel; return `
+ ${isEditing ? `` - : `
${this.esc(task.title)} ${cuBadge}
`} + : `
${this.esc(task.title)} ${cuBadge}
`} + ${descPreview}
${priorityBadge(task.priority || "")} - ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join("")} + ${dueDateBadge} + ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join("")}
- ${task.assignee ? `
${this.esc(task.assignee)}
` : ""} -
- ${otherStatuses.map(s => ``).join("")} +
+ `; + } + + private renderDetailPanel(): string { + const task = this.tasks.find(t => t.id === this.detailTaskId); + if (!task) return ''; + const dueDateVal = (() => { + const raw = task.due_date || task.dueDate; + if (!raw) return ''; + const d = new Date(raw); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + })(); + return ` +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join('')} + +
+
+
+ + +
+
+ + +
+
+ + ${task.created_at ? new Date(task.created_at).toLocaleString() : new Date(task.createdAt || Date.now()).toLocaleString()} +
+
+ + ${task.updated_at ? new Date(task.updated_at).toLocaleString() : new Date(task.updatedAt || Date.now()).toLocaleString()} +
+ +
+ `; + } + + private renderColumnEditor(): string { + return ` +
+

Manage Columns

+ ${this.statuses.map((s, i) => { + const count = this.tasks.filter(t => t.status === s).length; + return ` +
+ + ${i > 0 ? `` : ''} + ${i < this.statuses.length - 1 ? `` : ''} + +
`; + }).join('')} +
+ +
`; @@ -1018,6 +1266,173 @@ class FolkTasksBoard extends HTMLElement { }); }); + // ── Detail panel ── + this.shadow.querySelectorAll("[data-open-detail]").forEach(el => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + this.detailTaskId = (el as HTMLElement).dataset.openDetail!; + this.render(); + }); + }); + this.shadow.getElementById("detail-overlay")?.addEventListener("click", () => { this.detailTaskId = null; this.render(); }); + this.shadow.getElementById("detail-close")?.addEventListener("click", () => { this.detailTaskId = null; this.render(); }); + // Detail field auto-save on blur + const detailSave = () => { + if (!this.detailTaskId) return; + const fields: Record = {}; + const titleEl = this.shadow.getElementById("detail-title") as HTMLInputElement; + const descEl = this.shadow.getElementById("detail-desc") as HTMLTextAreaElement; + const statusEl = this.shadow.getElementById("detail-status") as HTMLSelectElement; + const priorityEl = this.shadow.getElementById("detail-priority") as HTMLSelectElement; + const assigneeEl = this.shadow.getElementById("detail-assignee") as HTMLInputElement; + const dueEl = this.shadow.getElementById("detail-due") as HTMLInputElement; + if (titleEl) fields.title = titleEl.value.trim(); + if (descEl) fields.description = descEl.value; + if (statusEl) fields.status = statusEl.value; + if (priorityEl) fields.priority = priorityEl.value || null; + if (assigneeEl) fields.assignee_id = assigneeEl.value.trim() || null; + if (dueEl) fields.due_date = dueEl.value ? new Date(dueEl.value).toISOString() : null; + this.updateTask(this.detailTaskId!, fields); + this.detailTaskId = this.detailTaskId; // keep panel open + }; + ['detail-title', 'detail-desc', 'detail-assignee', 'detail-due'].forEach(id => { + this.shadow.getElementById(id)?.addEventListener("blur", detailSave); + }); + ['detail-status', 'detail-priority'].forEach(id => { + this.shadow.getElementById(id)?.addEventListener("change", detailSave); + }); + // Detail label add + this.shadow.getElementById("detail-add-label")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + const input = e.target as HTMLInputElement; + const val = input.value.trim(); + if (!val || !this.detailTaskId) return; + const task = this.tasks.find(t => t.id === this.detailTaskId); + if (!task) return; + const labels = [...(task.labels || [])]; + if (!labels.includes(val)) labels.push(val); + this.updateTask(this.detailTaskId, { labels }); + this.detailTaskId = this.detailTaskId; // keep open + } + }); + // Detail label remove + this.shadow.querySelectorAll("[data-remove-label]").forEach(el => { + el.addEventListener("click", () => { + if (!this.detailTaskId) return; + const label = (el as HTMLElement).dataset.removeLabel!; + const task = this.tasks.find(t => t.id === this.detailTaskId); + if (!task) return; + const labels = (task.labels || []).filter((l: string) => l !== label); + this.updateTask(this.detailTaskId, { labels }); + this.detailTaskId = this.detailTaskId; + }); + }); + // Detail delete + this.shadow.getElementById("detail-delete")?.addEventListener("click", () => { + if (this.detailTaskId && confirm("Delete this task?")) this.deleteTask(this.detailTaskId); + }); + + // ── Quick delete on card hover ── + this.shadow.querySelectorAll("[data-quick-delete]").forEach(el => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const taskId = (el as HTMLElement).dataset.quickDelete!; + if (confirm("Delete this task?")) this.deleteTask(taskId); + }); + }); + + // ── Label filter on click ── + this.shadow.querySelectorAll("[data-filter-label]").forEach(el => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const label = (el as HTMLElement).dataset.filterLabel!; + this._filterLabel = this._filterLabel === label ? '' : label; + this.render(); + }); + }); + + // ── Search & filter bar ── + this.shadow.getElementById("filter-search")?.addEventListener("input", (e) => { + this._searchQuery = (e.target as HTMLInputElement).value; + this.render(); + // Restore focus & cursor + setTimeout(() => { + const el = this.shadow.getElementById("filter-search") as HTMLInputElement; + if (el) { el.focus(); el.selectionStart = el.selectionEnd = el.value.length; } + }, 0); + }); + this.shadow.getElementById("filter-priority")?.addEventListener("change", (e) => { + this._filterPriority = (e.target as HTMLSelectElement).value; + this.render(); + }); + this.shadow.getElementById("filter-clear")?.addEventListener("click", () => { + this._searchQuery = ''; + this._filterPriority = ''; + this._filterLabel = ''; + this.render(); + }); + + // ── Column editor ── + this.shadow.getElementById("toggle-col-editor")?.addEventListener("click", () => { + this._showColumnEditor = !this._showColumnEditor; + this.render(); + }); + this.shadow.getElementById("col-add-btn")?.addEventListener("click", () => { + const input = this.shadow.getElementById("col-add-input") as HTMLInputElement; + const val = input?.value?.trim().toUpperCase().replace(/\s+/g, '_'); + if (!val || this.statuses.includes(val)) return; + this.updateBoardMeta({ statuses: [...this.statuses, val] }); + }); + this.shadow.getElementById("col-add-input")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + this.shadow.getElementById("col-add-btn")?.click(); + } + }); + this.shadow.querySelectorAll("[data-rename-col]").forEach(el => { + el.addEventListener("blur", () => { + const idx = parseInt((el as HTMLElement).dataset.renameCol!); + const val = (el as HTMLInputElement).value.trim().toUpperCase().replace(/\s+/g, '_'); + if (!val || val === this.statuses[idx]) return; + // Rename status on all tasks in this column + const oldStatus = this.statuses[idx]; + const newStatuses = [...this.statuses]; + newStatuses[idx] = val; + // Update tasks with the old status + const tasksToUpdate = this.tasks.filter(t => t.status === oldStatus); + for (const t of tasksToUpdate) { + this.updateTask(t.id, { status: val }); + } + this.updateBoardMeta({ statuses: newStatuses }); + }); + }); + this.shadow.querySelectorAll("[data-move-col-up]").forEach(el => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.moveColUp!); + if (idx <= 0) return; + const newStatuses = [...this.statuses]; + [newStatuses[idx - 1], newStatuses[idx]] = [newStatuses[idx], newStatuses[idx - 1]]; + this.updateBoardMeta({ statuses: newStatuses }); + }); + }); + this.shadow.querySelectorAll("[data-move-col-down]").forEach(el => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.moveColDown!); + if (idx >= this.statuses.length - 1) return; + const newStatuses = [...this.statuses]; + [newStatuses[idx], newStatuses[idx + 1]] = [newStatuses[idx + 1], newStatuses[idx]]; + this.updateBoardMeta({ statuses: newStatuses }); + }); + }); + this.shadow.querySelectorAll("[data-remove-col]").forEach(el => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.removeCol!); + const count = this.tasks.filter(t => t.status === this.statuses[idx]).length; + if (count > 0) return; + const newStatuses = this.statuses.filter((_, i) => i !== idx); + this.updateBoardMeta({ statuses: newStatuses }); + }); + }); + // ClickUp panel listeners this.shadow.getElementById("cu-toggle-panel")?.addEventListener("click", () => { this._cuShowPanel = !this._cuShowPanel; diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 72e14f3..f80f6de 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -284,6 +284,40 @@ routes.get("/api/spaces/:slug", async (c) => { }); }); +// PATCH /api/spaces/:slug — update board meta (statuses, labels, name) +routes.patch("/api/spaces/:slug", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const slug = c.req.param("slug"); + const docId = boardDocId(slug, slug); + const doc = _syncServer!.getDoc(docId); + if (!doc) return c.json({ error: "Space not found" }, 404); + + const body = await c.req.json(); + const { name, description, statuses, labels } = body; + + _syncServer!.changeDoc(docId, `Update board meta ${slug}`, (d) => { + if (name !== undefined) d.board.name = name; + if (description !== undefined) d.board.description = description; + if (statuses !== undefined && Array.isArray(statuses)) d.board.statuses = statuses; + if (labels !== undefined && Array.isArray(labels)) d.board.labels = labels; + d.board.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ + id: updated.board.id, + name: updated.board.name, + slug: updated.board.slug, + description: updated.board.description, + statuses: updated.board.statuses, + labels: updated.board.labels, + updated_at: new Date(updated.board.updatedAt).toISOString(), + }); +}); + // ── API: Tasks ── // GET /api/spaces/:slug/tasks — list tasks in workspace @@ -303,6 +337,7 @@ routes.get("/api/spaces/:slug/tasks", async (c) => { assignee_name: null, created_by: t.createdBy, sort_order: t.sortOrder, + due_date: t.dueDate ? new Date(t.dueDate).toISOString() : null, created_at: new Date(t.createdAt).toISOString(), updated_at: new Date(t.updatedAt).toISOString(), ...(t.clickup ? { clickup: { taskId: t.clickup.taskId, url: t.clickup.url, syncStatus: t.clickup.syncStatus } } : {}), @@ -331,7 +366,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { const slug = c.req.param("slug"); const body = await c.req.json(); - const { title, description, status, priority, labels } = body; + const { title, description, status, priority, labels, due_date } = body; if (!title?.trim()) return c.json({ error: "Title required" }, 400); const doc = ensureDoc(slug); @@ -346,6 +381,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { status: taskStatus, priority: priority || 'MEDIUM', labels: labels || [], + dueDate: due_date ? new Date(due_date).getTime() : null, createdBy: claims.sub, }); }); @@ -368,6 +404,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { assignee_id: null, created_by: claims.sub, sort_order: 0, + due_date: due_date ? new Date(due_date).toISOString() : null, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }, 201); @@ -417,12 +454,12 @@ routes.patch("/api/tasks/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); - const { title, description, status, priority, labels, sort_order, assignee_id } = body; + const { title, description, status, priority, labels, sort_order, assignee_id, due_date } = body; // Check that at least one field is being updated if (title === undefined && description === undefined && status === undefined && priority === undefined && labels === undefined && sort_order === undefined && - assignee_id === undefined) { + assignee_id === undefined && due_date === undefined) { return c.json({ error: "No fields to update" }, 400); } @@ -448,6 +485,7 @@ routes.patch("/api/tasks/:id", async (c) => { if (labels !== undefined) task.labels = labels; if (sort_order !== undefined) task.sortOrder = sort_order; if (assignee_id !== undefined) task.assigneeId = assignee_id || null; + if (due_date !== undefined) task.dueDate = due_date ? new Date(due_date).getTime() : null; task.updatedAt = Date.now(); }); @@ -465,6 +503,7 @@ routes.patch("/api/tasks/:id", async (c) => { assignee_id: task.assigneeId, created_by: task.createdBy, sort_order: task.sortOrder, + due_date: task.dueDate ? new Date(task.dueDate).toISOString() : null, created_at: new Date(task.createdAt).toISOString(), updated_at: new Date(task.updatedAt).toISOString(), }); diff --git a/modules/rtasks/schemas.ts b/modules/rtasks/schemas.ts index 3766212..cb23cc9 100644 --- a/modules/rtasks/schemas.ts +++ b/modules/rtasks/schemas.ts @@ -30,6 +30,7 @@ export interface TaskItem { assigneeId: string | null; createdBy: string | null; sortOrder: number; + dueDate: number | null; createdAt: number; updatedAt: number; clickup?: ClickUpTaskMeta; @@ -163,6 +164,7 @@ export function createTaskItem( assigneeId: null, createdBy: null, sortOrder: 0, + dueDate: null, createdAt: now, updatedAt: now, ...opts, diff --git a/website/canvas.html b/website/canvas.html index 9c126fa..4fc3b15 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -21,7 +21,7 @@ /* When loaded inside an iframe, hide shell chrome */ html.rspace-embedded .rstack-header { display: none !important; } html.rspace-embedded .rstack-tab-row { display: none !important; } - html.rspace-embedded #toolbar { top: 16px !important; } + html.rspace-embedded #toolbar { bottom: 16px !important; } html.rspace-embedded #community-info { display: none !important; } @@ -43,8 +43,10 @@ #toolbar { position: fixed; - top: 108px; /* header(56) + tab-row(36) + gap(16) */ - left: 12px; + top: auto; + bottom: 16px; + left: auto; + right: 12px; display: flex; flex-direction: column; align-items: flex-start; @@ -192,11 +194,13 @@ padding: 16px; text-align: center; color: rgba(255,255,255,0.4); font-size: 13px; } - /* Popout panel — renders group tools to the right of toolbar */ + /* Popout panel — renders group tools to the left of toolbar */ #toolbar-panel { position: fixed; - top: 108px; - /* left is set dynamically by openToolbarPanel() */ + top: auto; + bottom: 60px; + left: auto !important; + right: 56px; min-width: 180px; max-height: calc(100vh - 130px); background: var(--rs-toolbar-panel-bg); @@ -1643,19 +1647,13 @@ display: none; } - /* Mobile toolbar: compact column on RIGHT, anchored to bottom */ + /* Mobile toolbar: tighter spacing, scrollable */ #toolbar { - position: fixed; - top: auto; bottom: 8px; - left: auto; right: 6px; - flex-direction: column; - flex-wrap: nowrap; align-items: center; gap: 2px; padding: 4px; - border-radius: 12px; z-index: 1001; max-height: calc(100vh - 140px); overflow-y: auto; @@ -6802,11 +6800,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest group.classList.add("open"); activeToolbarGroup = group; - // Position panel to the right of toolbar (no overlap) — desktop only - if (window.innerWidth > 768) { - const toolbarRect = toolbarEl.getBoundingClientRect(); - toolbarPanel.style.left = (toolbarRect.right + 8) + "px"; - } + // Position panel to the left of toolbar (no overlap) + // CSS handles positioning via right: 56px; left: auto !important toolbarPanel.classList.add("panel-open"); } @@ -6846,12 +6841,10 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar"; }); - // Auto-collapse toolbar on mobile - if (window.innerWidth <= 768) { - toolbarEl.classList.add("collapsed"); - collapseBtn.innerHTML = wrenchSVG; - collapseBtn.title = "Expand toolbar"; - } + // Start toolbar collapsed (wrench icon) on all screen sizes + toolbarEl.classList.add("collapsed"); + collapseBtn.innerHTML = wrenchSVG; + collapseBtn.title = "Expand toolbar"; // Mobile zoom controls (separate from toolbar) document.getElementById("mz-in").addEventListener("click", () => {