feat(rtasks): add list view with checkmarks, drag-drop reordering with drop indicators
Board/List view toggle in nav bar. List view shows tasks grouped by status with checkboxes (check → DONE, uncheck → TODO), priority left-border accent, and strikethrough for completed items. Board view now shows a blue pulsing drop indicator line during drag, supports in-column reordering via sort_order, and cross-column drops land at the cursor position. Cache version bumped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0afde7547c
commit
d62a5e9b15
|
|
@ -24,6 +24,9 @@ class FolkTasksBoard extends HTMLElement {
|
|||
private dragTaskId: string | null = null;
|
||||
private editingTaskId: string | null = null;
|
||||
private showCreateForm = false;
|
||||
private boardView: "board" | "checklist" = "board";
|
||||
private dragOverStatus: string | null = null;
|
||||
private dragOverIndex = -1;
|
||||
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
private _offlineUnsubs: (() => void)[] = [];
|
||||
private _history = new ViewHistory<"list" | "board">("list");
|
||||
|
|
@ -101,7 +104,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
this.tasks = Object.values(doc.tasks).map(t => ({
|
||||
id: t.id, title: t.title, description: t.description,
|
||||
status: t.status, priority: t.priority, labels: t.labels,
|
||||
assignee: t.assigneeId,
|
||||
assignee: t.assigneeId, sort_order: t.sortOrder ?? 0,
|
||||
}));
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -113,17 +116,17 @@ class FolkTasksBoard extends HTMLElement {
|
|||
this.view = "board";
|
||||
this.workspaceSlug = "rspace-dev";
|
||||
this.tasks = [
|
||||
{ id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" },
|
||||
{ id: "d2", title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], assignee: "Bob" },
|
||||
{ id: "d3", title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], assignee: "Alice" },
|
||||
{ id: "d4", title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], assignee: "Alice" },
|
||||
{ id: "d5", title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" },
|
||||
{ id: "d6", title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], assignee: "Bob" },
|
||||
{ id: "d7", title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], assignee: "Alice" },
|
||||
{ id: "d8", title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], assignee: "Alice" },
|
||||
{ id: "d9", title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], assignee: "Bob" },
|
||||
{ id: "d10", title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], assignee: "Alice" },
|
||||
{ id: "d11", title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], assignee: "Bob" },
|
||||
{ id: "d1", title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 0 },
|
||||
{ id: "d2", title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], assignee: "Bob", sort_order: 1000 },
|
||||
{ id: "d3", title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], assignee: "Alice", sort_order: 2000 },
|
||||
{ id: "d4", title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], assignee: "Alice", sort_order: 0 },
|
||||
{ id: "d5", title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], assignee: "Bob", sort_order: 1000 },
|
||||
{ id: "d6", title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], assignee: "Bob", sort_order: 0 },
|
||||
{ id: "d7", title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], assignee: "Alice", sort_order: 1000 },
|
||||
{ id: "d8", title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], assignee: "Alice", sort_order: 0 },
|
||||
{ id: "d9", title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], assignee: "Bob", sort_order: 1000 },
|
||||
{ id: "d10", title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], assignee: "Alice", sort_order: 2000 },
|
||||
{ id: "d11", title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], assignee: "Bob", sort_order: 3000 },
|
||||
];
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -199,7 +202,7 @@ class FolkTasksBoard extends HTMLElement {
|
|||
} catch { this.error = "Failed to create task"; this.render(); }
|
||||
}
|
||||
|
||||
private async updateTask(taskId: string, fields: Record<string, string>) {
|
||||
private async updateTask(taskId: string, fields: Record<string, any>) {
|
||||
if (this.isDemo) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) Object.assign(task, fields);
|
||||
|
|
@ -227,23 +230,41 @@ class FolkTasksBoard extends HTMLElement {
|
|||
this.updateTask(taskId, { priority: next });
|
||||
}
|
||||
|
||||
private async moveTask(taskId: string, newStatus: string) {
|
||||
private async moveTask(taskId: string, newStatus: string, sortOrder?: number) {
|
||||
if (this.isDemo) {
|
||||
const task = this.tasks.find(t => t.id === taskId);
|
||||
if (task) { task.status = newStatus; this.render(); }
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
if (sortOrder !== undefined) task.sort_order = sortOrder;
|
||||
this.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const body: Record<string, any> = { status: newStatus };
|
||||
if (sortOrder !== undefined) body.sort_order = sortOrder;
|
||||
await fetch(`${base}/api/tasks/${taskId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
this.loadTasks();
|
||||
} catch { this.error = "Failed to move task"; this.render(); }
|
||||
}
|
||||
|
||||
private computeSortOrder(targetStatus: string, insertIndex: number): number {
|
||||
const columnTasks = this.tasks
|
||||
.filter(t => t.status === targetStatus && t.id !== this.dragTaskId)
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
if (columnTasks.length === 0) return 0;
|
||||
if (insertIndex <= 0) return (columnTasks[0].sort_order ?? 0) - 1000;
|
||||
if (insertIndex >= columnTasks.length) return (columnTasks[columnTasks.length - 1].sort_order ?? 0) + 1000;
|
||||
const before = columnTasks[insertIndex - 1].sort_order ?? 0;
|
||||
const after = columnTasks[insertIndex].sort_order ?? 0;
|
||||
return (before + after) / 2;
|
||||
}
|
||||
|
||||
private openBoard(slug: string) {
|
||||
this.workspaceSlug = slug;
|
||||
this.view = "board";
|
||||
|
|
@ -334,6 +355,36 @@ class FolkTasksBoard extends HTMLElement {
|
|||
|
||||
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle { display: flex; gap: 2px; background: var(--rs-bg-surface-sunken); border-radius: 6px; padding: 2px; }
|
||||
.view-toggle__btn { padding: 4px 12px; border-radius: 5px; border: none; background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.15s; }
|
||||
.view-toggle__btn.active { background: var(--rs-bg-surface); color: var(--rs-text-primary); box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
|
||||
.view-toggle__btn:hover:not(.active) { color: var(--rs-text-secondary); }
|
||||
|
||||
/* Drop indicator */
|
||||
.drop-indicator { height: 2px; background: var(--rs-primary); border-radius: 1px; margin: 2px 0; animation: pulse-indicator 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
|
||||
|
||||
/* Checklist view */
|
||||
.checklist { max-width: 720px; }
|
||||
.checklist-group { margin-bottom: 16px; }
|
||||
.checklist-header { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--rs-text-muted); margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
|
||||
.checklist-row {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
|
||||
border-left: 3px solid var(--rs-border); border-radius: 4px;
|
||||
background: var(--rs-bg-surface); margin-bottom: 2px; min-height: 38px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.checklist-row:hover { background: var(--rs-bg-surface-sunken); }
|
||||
.checklist-row.done .checklist-title { text-decoration: line-through; opacity: 0.5; }
|
||||
.checklist-row.border-urgent { border-left-color: #f87171; }
|
||||
.checklist-row.border-high { border-left-color: #fb923c; }
|
||||
.checklist-row.border-medium { border-left-color: #facc15; }
|
||||
.checklist-row.border-low { border-left-color: #60a5fa; }
|
||||
.checklist-check { width: 16px; height: 16px; cursor: pointer; accent-color: var(--rs-primary); flex-shrink: 0; }
|
||||
.checklist-title { font-size: 13px; font-weight: 500; flex: 1; cursor: text; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.checklist-badges { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.board { flex-direction: column; overflow-x: visible; }
|
||||
.column { min-width: 100%; max-width: 100%; }
|
||||
|
|
@ -410,11 +461,23 @@ class FolkTasksBoard extends HTMLElement {
|
|||
<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>
|
||||
<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>
|
||||
${this.boardView === "checklist" ? this.renderChecklist() : this.renderKanban()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderKanban(): string {
|
||||
return `
|
||||
<div class="board">
|
||||
${this.statuses.map(status => {
|
||||
const columnTasks = this.tasks.filter(t => t.status === status);
|
||||
const columnTasks = this.tasks
|
||||
.filter(t => t.status === status)
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
return `
|
||||
<div class="column" data-status="${status}">
|
||||
<div class="col-header">
|
||||
|
|
@ -430,6 +493,38 @@ class FolkTasksBoard extends HTMLElement {
|
|||
`;
|
||||
}
|
||||
|
||||
private renderChecklist(): string {
|
||||
return `
|
||||
<div class="checklist">
|
||||
${this.statuses.map(status => {
|
||||
const columnTasks = this.tasks
|
||||
.filter(t => t.status === status)
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
if (columnTasks.length === 0) return '';
|
||||
const isDone = status === "DONE";
|
||||
return `
|
||||
<div class="checklist-group">
|
||||
<div class="checklist-header">${this.esc(status.replace(/_/g, " "))} <span class="col-count">${columnTasks.length}</span></div>
|
||||
${columnTasks.map(t => {
|
||||
const pClass = `border-${(t.priority || 'medium').toLowerCase()}`;
|
||||
return `
|
||||
<div class="checklist-row ${pClass} ${isDone ? 'done' : ''}">
|
||||
<input type="checkbox" class="checklist-check" data-check-task="${t.id}" ${isDone ? 'checked' : ''}>
|
||||
<span class="checklist-title" data-start-edit="${t.id}">${this.esc(t.title)}</span>
|
||||
<div class="checklist-badges">
|
||||
<span class="badge clickable badge-${(t.priority || 'medium').toLowerCase()}" data-cycle-priority="${t.id}">${this.esc((t.priority || '').toLowerCase())}</span>
|
||||
${(t.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTaskCard(task: any, currentStatus: string): string {
|
||||
const otherStatuses = this.statuses.filter(s => s !== currentStatus);
|
||||
const isEditing = this.editingTaskId === task.id;
|
||||
|
|
@ -543,13 +638,29 @@ class FolkTasksBoard extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// View toggle
|
||||
this.shadow.querySelectorAll("[data-set-view]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
this.boardView = (el as HTMLElement).dataset.setView as "board" | "checklist";
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Checklist checkboxes
|
||||
this.shadow.querySelectorAll(".checklist-check").forEach(el => {
|
||||
el.addEventListener("change", () => {
|
||||
const taskId = (el as HTMLElement).dataset.checkTask!;
|
||||
const checked = (el as HTMLInputElement).checked;
|
||||
this.moveTask(taskId, checked ? "DONE" : "TODO");
|
||||
});
|
||||
});
|
||||
|
||||
// Pointer events drag-and-drop on task cards (works with touch, pen, mouse)
|
||||
this.shadow.querySelectorAll(".task-card[draggable]").forEach(card => {
|
||||
const el = card as HTMLElement;
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
const pe = e as PointerEvent;
|
||||
if (pe.button !== 0) return;
|
||||
// Only start drag if not editing
|
||||
if (el.getAttribute("draggable") === "false") return;
|
||||
this.dragTaskId = el.dataset.taskId || null;
|
||||
el.classList.add("dragging");
|
||||
|
|
@ -558,33 +669,67 @@ class FolkTasksBoard extends HTMLElement {
|
|||
});
|
||||
el.addEventListener("pointermove", (e) => {
|
||||
if (!this.dragTaskId) return;
|
||||
(e as PointerEvent).preventDefault();
|
||||
// Highlight target column under pointer
|
||||
const pe = e as PointerEvent;
|
||||
pe.preventDefault();
|
||||
// Clear previous state
|
||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
||||
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null;
|
||||
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null;
|
||||
if (targetCol) targetCol.classList.add("drag-over");
|
||||
});
|
||||
el.addEventListener("pointerup", (e) => {
|
||||
if (!this.dragTaskId) return;
|
||||
const pe = e as PointerEvent;
|
||||
el.classList.remove("dragging");
|
||||
el.style.touchAction = "";
|
||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||
// Find target column
|
||||
const target = this.shadow.elementFromPoint(pe.clientX, pe.clientY) as HTMLElement | null;
|
||||
const targetCol = target?.closest?.(".column[data-status]") as HTMLElement | null;
|
||||
if (targetCol) {
|
||||
const status = targetCol.dataset.status!;
|
||||
this.moveTask(this.dragTaskId!, status);
|
||||
targetCol.classList.add("drag-over");
|
||||
// Calculate insert position
|
||||
const cards = Array.from(targetCol.querySelectorAll('.task-card:not(.dragging)'));
|
||||
let insertIndex = cards.length;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const rect = cards[i].getBoundingClientRect();
|
||||
if (pe.clientY < rect.top + rect.height / 2) { insertIndex = i; break; }
|
||||
}
|
||||
this.dragOverStatus = targetCol.dataset.status || null;
|
||||
this.dragOverIndex = insertIndex;
|
||||
// Show drop indicator line
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'drop-indicator';
|
||||
if (insertIndex < cards.length) {
|
||||
cards[insertIndex].before(indicator);
|
||||
} else {
|
||||
targetCol.appendChild(indicator);
|
||||
}
|
||||
} else {
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
}
|
||||
});
|
||||
el.addEventListener("pointerup", (e) => {
|
||||
if (!this.dragTaskId) return;
|
||||
el.classList.remove("dragging");
|
||||
el.style.touchAction = "";
|
||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||
if (this.dragOverStatus && this.dragOverIndex >= 0) {
|
||||
const task = this.tasks.find(t => t.id === this.dragTaskId);
|
||||
const sortOrder = this.computeSortOrder(this.dragOverStatus, this.dragOverIndex);
|
||||
if (task) {
|
||||
if (task.status === this.dragOverStatus) {
|
||||
this.updateTask(this.dragTaskId!, { sort_order: sortOrder });
|
||||
} else {
|
||||
this.moveTask(this.dragTaskId!, this.dragOverStatus, sortOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.dragTaskId = null;
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
});
|
||||
el.addEventListener("pointercancel", () => {
|
||||
el.classList.remove("dragging");
|
||||
el.style.touchAction = "";
|
||||
this.shadow.querySelectorAll(".column[data-status]").forEach(c => (c as HTMLElement).classList.remove("drag-over"));
|
||||
this.shadow.querySelector('.drop-indicator')?.remove();
|
||||
this.dragTaskId = null;
|
||||
this.dragOverStatus = null;
|
||||
this.dragOverIndex = -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
|
||||
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js"></script>`,
|
||||
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=2"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue