Files
Void-Homelab/public/views/project.js
root ea5a99acff feat(ui): space + project + home views
Home: recent activity feed from /api/audit/actor?limit=20 with relative
timestamps and entity-typed links into detail views.

Space: header + three-column row of Projects / Open tasks (status=todo) /
Recent pages + refs cards. Status badges on projects and tasks use the
shared .status palette.

Project: header (status + start/complete dates), Tasks card with inline
status badges that cycle todo->doing->blocked->done on click (PATCH
/api/tasks/:id), Pages in space card, Add-task inline form bound to
project_id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:17:16 +10:00

103 lines
3.5 KiB
JavaScript

// Project view — header + tasks (with inline status toggle) + pages + refs + add-task form.
import { api } from '../api.js';
import { el, mount, clear } from '../dom.js';
const STATUSES = ['todo', 'doing', 'blocked', 'done'];
const STATUS_CLASS = { todo: 'idle', doing: 'warn', blocked: 'bad', done: 'ok' };
function nextStatus(s) {
const i = STATUSES.indexOf(s);
return STATUSES[(i + 1) % STATUSES.length];
}
function taskRow(t, onChange) {
const badge = el('span', {
class: 'status ' + (STATUS_CLASS[t.status] || 'idle'),
style: { cursor: 'pointer' },
title: 'click to advance status',
onclick: async () => {
const ns = nextStatus(t.status);
try {
const updated = await api.patch('/api/tasks/' + t.id, { status: ns });
onChange(updated);
} catch (e) { alert('update failed: ' + e.message); }
}
}, t.status);
return el('li', {}, badge, ' ', t.title);
}
function pageRow(p) { return el('li', {}, el('a', { href: '#/page/' + p.id }, p.title)); }
export async function render(main, ctx) {
const id = ctx.params.id;
mount(main, el('p', { class: 'view-sub muted' }, 'Loading …'));
let proj;
try { proj = await api.get('/api/projects/' + id); }
catch (e) {
mount(main,
el('h1', { class: 'view-h1' }, 'Project not found'),
el('p', { class: 'view-sub muted' }, e.message)
);
return;
}
const [tasks, pages] = await Promise.all([
api.get(`/api/projects/${id}/tasks`).catch(() => []),
api.get(`/api/spaces/${proj.space_id}/pages`).catch(() => [])
]);
const tasksList = el('ul', { class: 'plain' });
function rerenderTasks(rows) {
clear(tasksList);
if (!rows.length) tasksList.appendChild(el('li', { class: 'muted' }, '(no tasks)'));
for (const t of rows) {
tasksList.appendChild(taskRow(t, (updated) => {
const idx = rows.findIndex(r => r.id === updated.id);
if (idx >= 0) rows[idx] = updated;
rerenderTasks(rows);
}));
}
}
rerenderTasks(tasks);
const newTitle = el('input', { type: 'text', placeholder: 'New task title', style: { flex: 1 } });
async function addTask() {
const title = newTitle.value.trim();
if (!title) return;
try {
const created = await api.post(`/api/spaces/${proj.space_id}/tasks`, { title, project_id: proj.id });
newTitle.value = '';
tasks.unshift(created);
rerenderTasks(tasks);
} catch (e) { alert('add failed: ' + e.message); }
}
newTitle.addEventListener('keydown', (e) => { if (e.key === 'Enter') addTask(); });
mount(main,
el('h1', { class: 'view-h1' }, proj.name),
el('p', { class: 'view-sub' },
el('span', { class: 'status ' + (proj.status === 'done' ? 'ok' : proj.status === 'paused' ? 'warn' : '') }, proj.status),
proj.started_at ? ' · started ' + new Date(proj.started_at).toISOString().slice(0, 10) : '',
proj.completed_at ? ' · completed ' + new Date(proj.completed_at).toISOString().slice(0, 10) : ''
),
proj.description ? el('p', {}, proj.description) : null,
el('div', { class: 'row' },
el('div', { class: 'card' },
el('h3', {}, 'Tasks'),
tasksList,
el('div', { style: { display: 'flex', gap: '8px', marginTop: '10px' } },
newTitle,
el('button', { class: 'primary', onclick: addTask }, 'Add')
)
),
el('div', { class: 'card' },
el('h3', {}, 'Pages in space'),
pages.length
? el('ul', { class: 'plain' }, pages.slice(0, 10).map(pageRow))
: el('p', { class: 'muted' }, 'No pages.')
)
)
);
}