Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m36s
Details
CI/CD / deploy (push) Failing after 2m36s
Details
This commit is contained in:
commit
a401faf19f
|
|
@ -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">⚙</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">×</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">×</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}">×</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}">↑</button>` : ''}
|
||||
${i < this.statuses.length - 1 ? `<button class="col-editor__btn" data-move-col-down="${i}">↓</button>` : ''}
|
||||
<button class="col-editor__btn danger" data-remove-col="${i}" ${count > 0 ? 'disabled title="Column has tasks"' : ''}>×</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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue