Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m36s Details

This commit is contained in:
Jeff Emmett 2026-04-06 14:42:30 -04:00
commit a401faf19f
4 changed files with 510 additions and 61 deletions

View File

@ -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<string, string>): Record<string, string> {
const h: Record<string, string> = { ...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<string, any>) {
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
? `<span class="cu-board-status"><span class="cu-sync-dot synced"></span> CU ${this.esc(this._boardClickup.listName || '')}</span>`
: '';
const filtered = this.getFilteredTasks();
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}
<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>
<button class="view-toggle__btn ${this.boardView === 'checklist' ? 'active' : ''}" data-set-view="checklist">List</button>
</div>
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
</div>
<div class="filter-bar">
<input type="text" id="filter-search" placeholder="Search tasks..." value="${this.esc(this._searchQuery)}">
<select id="filter-priority">
<option value="">All priorities</option>
${this.priorities.map(p => `<option value="${p}" ${this._filterPriority === p ? 'selected' : ''}>${p.toLowerCase()}</option>`).join('')}
</select>
${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._showColumnEditor ? this.renderColumnEditor() : ''}
${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()}
${this.detailTaskId ? this.renderDetailPanel() : ''}
`;
}
private renderKanban(): string {
const filtered = this.getFilteredTasks();
return `
<div class="board">
${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 {
<span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span>
</div>
${status === "TODO" ? this.renderCreateForm() : ""}
${status === this.statuses[0] ? this.renderCreateForm() : ""}
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
${columnTasks.length === 0 && status !== "TODO" ? '<div class="empty-drop-zone">Drop task here to change status</div>' : ''}
${columnTasks.length === 0 ? '<div class="empty-drop-zone">Drop here</div>' : ''}
</div>
`;
}).join("")}
@ -854,10 +1007,11 @@ class FolkTasksBoard extends HTMLElement {
}
private renderChecklist(): string {
const filtered = this.getFilteredTasks();
return `
<div class="checklist">
${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<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" };
@ -895,18 +1048,113 @@ class FolkTasksBoard extends HTMLElement {
const cuBadge = task.clickup
? `<a class="cu-badge" href="${this.esc(task.clickup.url)}" target="_blank" rel="noopener" title="Open in ClickUp">CU <span class="cu-sync-dot ${task.clickup.syncStatus || 'synced'}"></span></a>`
: '';
const descPreview = task.description
? `<div class="task-desc-preview">${this.esc(task.description)}</div>`
: '';
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 `<span class="task-due${overdue ? ' overdue' : ''}">${fmt}</span>`;
})();
const labelFilter = this._filterLabel;
return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
<button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">&times;</button>
${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)} ${cuBadge}</div>`}
: `<div class="task-title" data-open-detail="${task.id}" style="cursor:pointer">${this.esc(task.title)} ${cuBadge}</div>`}
${descPreview}
<div class="task-meta">
${priorityBadge(task.priority || "")}
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
${dueDateBadge}
${(task.labels || []).map((l: string) => `<span class="badge clickable${labelFilter === l ? ' filter-active' : ''}" data-filter-label="${l}">${this.esc(l)}</span>`).join("")}
</div>
${task.assignee ? `<div style="font-size:11px;color:var(--rs-text-muted);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>
`;
}
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 `
<div class="detail-overlay" id="detail-overlay"></div>
<div class="detail-panel">
<button class="detail-panel__close" id="detail-close">&times;</button>
<div class="detail-field">
<label>Title</label>
<input type="text" id="detail-title" value="${this.esc(task.title)}">
</div>
<div class="detail-field">
<label>Description</label>
<textarea id="detail-desc" rows="4">${this.esc(task.description || '')}</textarea>
</div>
<div class="detail-field">
<label>Status</label>
<select id="detail-status">
${this.statuses.map(s => `<option value="${s}" ${task.status === s ? 'selected' : ''}>${this.esc(s.replace(/_/g, ' '))}</option>`).join('')}
</select>
</div>
<div class="detail-field">
<label>Priority</label>
<select id="detail-priority">
<option value="">None</option>
${this.priorities.map(p => `<option value="${p}" ${task.priority === p ? 'selected' : ''}>${p.toLowerCase()}</option>`).join('')}
</select>
</div>
<div class="detail-field">
<label>Labels</label>
<div class="detail-labels">
${(task.labels || []).map((l: string) => `<span class="detail-label-chip">${this.esc(l)}<button data-remove-label="${l}">&times;</button></span>`).join('')}
<input type="text" id="detail-add-label" placeholder="Add label..." style="flex:1;min-width:80px;padding:4px 6px;font-size:12px;">
</div>
</div>
<div class="detail-field">
<label>Assignee</label>
<input type="text" id="detail-assignee" value="${this.esc(task.assignee_id || task.assignee || '')}" placeholder="Assignee...">
</div>
<div class="detail-field">
<label>Due Date</label>
<input type="date" id="detail-due" value="${dueDateVal}">
</div>
<div class="detail-field">
<label>Created</label>
<span class="readonly">${task.created_at ? new Date(task.created_at).toLocaleString() : new Date(task.createdAt || Date.now()).toLocaleString()}</span>
</div>
<div class="detail-field">
<label>Updated</label>
<span class="readonly">${task.updated_at ? new Date(task.updated_at).toLocaleString() : new Date(task.updatedAt || Date.now()).toLocaleString()}</span>
</div>
<button class="detail-delete" id="detail-delete">Delete Task</button>
</div>
`;
}
private renderColumnEditor(): string {
return `
<div class="col-editor">
<h3>Manage Columns</h3>
${this.statuses.map((s, i) => {
const count = this.tasks.filter(t => t.status === s).length;
return `
<div class="col-editor__item">
<input type="text" value="${this.esc(s)}" data-rename-col="${i}">
${i > 0 ? `<button class="col-editor__btn" data-move-col-up="${i}">&uarr;</button>` : ''}
${i < this.statuses.length - 1 ? `<button class="col-editor__btn" data-move-col-down="${i}">&darr;</button>` : ''}
<button class="col-editor__btn danger" data-remove-col="${i}" ${count > 0 ? 'disabled title="Column has tasks"' : ''}>&times;</button>
</div>`;
}).join('')}
<div class="col-editor__add">
<input type="text" id="col-add-input" placeholder="New status name...">
<button id="col-add-btn">Add</button>
</div>
</div>
`;
@ -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<string, any> = {};
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;

View File

@ -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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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(),
});

View File

@ -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,

View File

@ -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; }
</style>
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
@ -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", () => {