diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts index 1998658..9e894a5 100644 --- a/modules/rtasks/components/folk-tasks-board.ts +++ b/modules/rtasks/components/folk-tasks-board.ts @@ -41,6 +41,10 @@ class FolkTasksBoard extends HTMLElement { private _boardLabels: string[] = []; // Inline delete confirmation private _confirmDeleteId: string | null = null; + // Space members for assignee dropdown + private _spaceMembers: { did: string; displayName?: string; role: string; isOwner: boolean }[] = []; + // Drag guard — prevents column click from firing after drag + private _justDragged = false; // ClickUp integration state private _cuConnected = false; private _cuTeamName = ''; @@ -84,6 +88,7 @@ class FolkTasksBoard extends HTMLElement { this.subscribeOffline(); this.loadTasks(); this.loadClickUpStatus(); + this.loadSpaceMembers(); this.render(); } if (!localStorage.getItem("rtasks_tour_done")) { @@ -206,6 +211,18 @@ class FolkTasksBoard extends HTMLElement { this.render(); } + private async loadSpaceMembers() { + if (this.isDemo) return; + try { + // Members API is at /api/spaces/:slug/members (global, not module-scoped) + const res = await fetch(`/api/spaces/${this.space}/members`, { headers: this.authHeaders() }); + if (res.ok) { + const data = await res.json(); + this._spaceMembers = data.members || []; + } + } catch { /* not authenticated or no members — fallback to text input */ } + } + private async loadClickUpStatus() { if (this.isDemo) return; try { @@ -413,11 +430,11 @@ class FolkTasksBoard extends HTMLElement { return ''; } - private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string, status?: string) { + private async submitCreateTask(title: string, priority: string, description: string, opts?: { dueDate?: string; status?: string; assignee?: string }) { if (!title.trim()) return; - const taskStatus = status || this.statuses[0] || "TODO"; + const taskStatus = opts?.status || this.statuses[0] || "TODO"; if (this.isDemo) { - this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: taskStatus, priority, labels: [], description: description.trim() || undefined, due_date: dueDate || null, sort_order: 0 }); + this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: taskStatus, priority, labels: [], description: description.trim() || undefined, due_date: opts?.dueDate || null, assignee_id: opts?.assignee || null, sort_order: 0 }); this.showCreateForm = false; this.render(); return; @@ -427,7 +444,7 @@ class FolkTasksBoard extends HTMLElement { const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, { method: "POST", headers: this.authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: dueDate || undefined }), + body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: opts?.dueDate || undefined, assignee_id: opts?.assignee || undefined }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -683,6 +700,7 @@ class FolkTasksBoard extends HTMLElement { .badge-high { background: #3b2611; color: #fb923c; } .badge-medium { background: #3b3511; color: #facc15; } .badge-low { background: #112a3b; color: #60a5fa; } + .badge-assignee { background: #1a2332; color: #93c5fd; font-style: italic; } .move-btns { display: flex; gap: 4px; margin-top: 6px; } .move-btn { font-size: 11px; padding: 6px 10px; min-height: 36px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; } @@ -878,6 +896,11 @@ class FolkTasksBoard extends HTMLElement { + ${this._spaceMembers.length > 0 ? ` + ` : ''}
@@ -1002,6 +1025,12 @@ class FolkTasksBoard extends HTMLElement { })(); const labelFilter = this._filterLabel; const confirmingDelete = this._confirmDeleteId === task.id; + const assigneeName = (() => { + const aid = task.assignee_id || task.assignee; + if (!aid) return ''; + const member = this._spaceMembers.find(m => m.did === aid); + return member?.displayName || aid.slice(0, 10); + })(); return `
@@ -1012,6 +1041,7 @@ class FolkTasksBoard extends HTMLElement {
${priorityBadge(task.priority || "")} ${dueDateBadge} + ${assigneeName ? `${this.esc(assigneeName)}` : ''} ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join("")}
${confirmingDelete ? `
Delete?
` : ''} @@ -1062,7 +1092,17 @@ class FolkTasksBoard extends HTMLElement {
- + ${this._spaceMembers.length > 0 ? ` + + ` : ``}
@@ -1125,6 +1165,8 @@ class FolkTasksBoard extends HTMLElement { // Column click to create task in that column this.shadow.querySelectorAll("[data-col-click]").forEach(el => { el.addEventListener("click", (e) => { + // Don't trigger if a drag just ended + if (this._justDragged) return; // Don't trigger if clicking on a card, button, or input const target = e.target as HTMLElement; if (target.closest('.task-card, button, input, select, textarea, .create-form')) return; @@ -1137,23 +1179,24 @@ class FolkTasksBoard extends HTMLElement { }); // Create form handlers + const getCreateFormValues = () => ({ + title: (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "", + priority: (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM", + desc: (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "", + status: (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "", + assignee: (this.shadow.getElementById("cf-assignee") as HTMLSelectElement)?.value || "", + }); this.shadow.getElementById("cf-submit")?.addEventListener("click", () => { - const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || ""; - const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM"; - const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || ""; - const status = (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || ""; - this.submitCreateTask(title, priority, desc, undefined, status); + const v = getCreateFormValues(); + this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee }); }); this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => { this.showCreateForm = false; this.render(); }); this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") { - const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || ""; - const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM"; - const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || ""; - const status = (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || ""; - this.submitCreateTask(title, priority, desc, undefined, status); + const v = getCreateFormValues(); + this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee }); } }); @@ -1259,9 +1302,13 @@ class FolkTasksBoard extends HTMLElement { this.shadow.getElementById("detail-priority")?.addEventListener("change", (e) => { detailFieldSave('priority', (e.target as HTMLSelectElement).value || null); }); - this.shadow.getElementById("detail-assignee")?.addEventListener("blur", (e) => { - detailFieldSave('assignee_id', (e.target as HTMLInputElement).value.trim() || null); - }); + const assigneeEl = this.shadow.getElementById("detail-assignee"); + if (assigneeEl) { + const event = assigneeEl.tagName === 'SELECT' ? 'change' : 'blur'; + assigneeEl.addEventListener(event, (e) => { + detailFieldSave('assignee_id', (e.target as HTMLInputElement | HTMLSelectElement).value.trim() || null); + }); + } this.shadow.getElementById("detail-due")?.addEventListener("change", (e) => { const v = (e.target as HTMLInputElement).value; detailFieldSave('due_date', v ? new Date(v).toISOString() : null); @@ -1535,6 +1582,9 @@ class FolkTasksBoard extends HTMLElement { }); el.addEventListener("pointerup", (e) => { if (!this.dragTaskId) return; + // Prevent column click handler from firing after drag + this._justDragged = true; + setTimeout(() => { this._justDragged = false; }, 100); el.classList.remove("dragging"); el.style.touchAction = ""; this.shadow.querySelectorAll(".column[data-status]").forEach(c => { @@ -1567,6 +1617,8 @@ class FolkTasksBoard extends HTMLElement { this.dragOverIndex = -1; }); el.addEventListener("pointercancel", () => { + this._justDragged = true; + setTimeout(() => { this._justDragged = false; }, 100); el.classList.remove("dragging"); el.style.touchAction = ""; this.shadow.querySelectorAll(".column[data-status]").forEach(c => { diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index cfa4560..fde270d 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -370,7 +370,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, due_date } = body; + const { title, description, status, priority, labels, due_date, assignee_id } = body; if (!title?.trim()) return c.json({ error: "Title required" }, 400); const doc = ensureDoc(slug); @@ -380,7 +380,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { const docId = boardDocId(slug, slug); _syncServer!.changeDoc(docId, `Create task ${taskId}`, (d) => { - d.tasks[taskId] = createTaskItem(taskId, slug, title.trim(), { + const task = createTaskItem(taskId, slug, title.trim(), { description: description || '', status: taskStatus, priority: priority || 'MEDIUM', @@ -388,6 +388,8 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { dueDate: due_date ? new Date(due_date).getTime() : null, createdBy, }); + if (assignee_id) task.assigneeId = assignee_id; + d.tasks[taskId] = task; }); // Notify space members about the new task @@ -405,7 +407,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => { status: taskStatus, priority: priority || "MEDIUM", labels: labels || [], - assignee_id: null, + assignee_id: assignee_id || null, created_by: createdBy, sort_order: 0, due_date: due_date ? new Date(due_date).toISOString() : null,