Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m36s Details

This commit is contained in:
Jeff Emmett 2026-04-07 22:32:27 -04:00
commit 1ac52e301f
2 changed files with 75 additions and 21 deletions

View File

@ -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 {
<option value="HIGH">High</option>
<option value="URGENT">Urgent</option>
</select>
${this._spaceMembers.length > 0 ? `
<select id="cf-assignee">
<option value="">Unassigned</option>
${this._spaceMembers.map(m => `<option value="${this.esc(m.did)}">${this.esc(m.displayName || m.did.slice(0, 12))}${m.isOwner ? ' (owner)' : ''}</option>`).join('')}
</select>` : ''}
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
<div class="create-form-actions">
<button class="cf-submit" id="cf-submit">Add Task</button>
@ -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 `
<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>
@ -1012,6 +1041,7 @@ class FolkTasksBoard extends HTMLElement {
<div class="task-meta">
${priorityBadge(task.priority || "")}
${dueDateBadge}
${assigneeName ? `<span class="badge badge-assignee">${this.esc(assigneeName)}</span>` : ''}
${(task.labels || []).map((l: string) => `<span class="badge clickable${labelFilter === l ? ' filter-active' : ''}" data-filter-label="${l}">${this.esc(l)}</span>`).join("")}
</div>
${confirmingDelete ? `<div class="confirm-delete"><span>Delete?</span><button class="confirm-yes" data-confirm-yes="${task.id}">Yes</button><button class="confirm-no" data-confirm-no="${task.id}">No</button></div>` : ''}
@ -1062,7 +1092,17 @@ class FolkTasksBoard extends HTMLElement {
</div>
<div class="detail-field">
<label>Assignee</label>
<input type="text" id="detail-assignee" value="${this.esc(task.assignee_id || task.assignee || '')}" placeholder="Assignee...">
${this._spaceMembers.length > 0 ? `
<select id="detail-assignee">
<option value="">Unassigned</option>
${this._spaceMembers.map(m => {
const name = m.displayName || m.did.slice(0, 12);
const val = m.did;
const selected = (task.assignee_id || task.assignee || '') === val;
return `<option value="${this.esc(val)}" ${selected ? 'selected' : ''}>${this.esc(name)}${m.isOwner ? ' (owner)' : ''}</option>`;
}).join('')}
</select>
` : `<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>
@ -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 => {

View File

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