diff --git a/public/views/home.js b/public/views/home.js index 390648d..67f5129 100644 --- a/public/views/home.js +++ b/public/views/home.js @@ -1,15 +1,73 @@ -// T17 stub — recent activity lands in T19. +// Home — recent activity feed pulled from the audit log. +import { api } from '../api.js'; import { el, mount } from '../dom.js'; +const ROUTE_BY_TYPE = { + space: id => '#/space/' + id, + project: id => '#/project/' + id, + task: () => '#/', // tasks list inside their project — no detail view yet + page: id => '#/page/' + id, + ref: id => '#/ref/' + id, + resource: id => '#/resource/' + id, + source_doc: () => '#/', + conversation: () => '#/' +}; + +const ACTION_VERB = { + create: 'created', update: 'updated', delete: 'deleted', + suggest: 'suggested', approve: 'approved', reject: 'rejected' +}; + +function actorLabel(row) { + if (row.actor_kind === 'user') return 'You'; + if (row.actor_kind === 'agent') return 'Agent ' + (row.actor_id || '').slice(0, 8); + return row.actor_kind; +} + +function fmtWhen(ts) { + const d = new Date(ts); + const diff = (Date.now() - d.getTime()) / 1000; + if (diff < 60) return Math.round(diff) + 's ago'; + if (diff < 3600) return Math.round(diff / 60) + 'm ago'; + if (diff < 86400) return Math.round(diff / 3600) + 'h ago'; + return d.toISOString().slice(0, 10); +} + +function activityRow(row) { + const route = ROUTE_BY_TYPE[row.entity_type]; + const link = route && row.entity_id ? route(row.entity_id) : null; + return el('li', {}, + el('span', { class: 'muted' }, fmtWhen(row.occurred_at) + ' — '), + actorLabel(row), + ' ', + ACTION_VERB[row.action] || row.action, + ' ', + link + ? el('a', { href: link }, row.entity_type) + : el('span', {}, row.entity_type) + ); +} + export async function render(main) { mount(main, el('h1', { class: 'view-h1' }, 'The Void'), - el('p', { class: 'view-sub' }, 'Step into the abyss. Everything else loads as you navigate.'), + el('p', { class: 'view-sub' }, 'Recent activity across all spaces.'), el('div', { class: 'card' }, - el('h3', {}, 'Welcome'), - el('p', { class: 'muted' }, - 'Pick a space from the sidebar to start. The API is alive; the views populate as Phase E lands.' - ) + el('h3', {}, 'Recent activity'), + el('div', { id: 'home-activity' }, el('span', { class: 'muted' }, 'Loading …')) ) ); + try { + const rows = await api.get('/api/audit/actor?limit=20'); + const wrap = document.getElementById('home-activity'); + if (!wrap) return; + if (!rows.length) { + mount(wrap, el('span', { class: 'muted' }, 'Nothing yet — create a space to begin.')); + return; + } + mount(wrap, el('ul', { class: 'plain' }, rows.map(activityRow))); + } catch (e) { + const wrap = document.getElementById('home-activity'); + if (wrap) mount(wrap, el('span', { class: 'muted' }, 'Could not load activity: ' + e.message)); + } } diff --git a/public/views/project.js b/public/views/project.js index be13833..bec41e4 100644 --- a/public/views/project.js +++ b/public/views/project.js @@ -1,9 +1,102 @@ -// T17 stub — full implementation lands in T19. -import { el, mount } from '../dom.js'; +// 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' }, 'Project'), - el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')), - el('div', { class: 'card muted' }, 'Project view ships in T19.') + 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.') + ) + ) ); } diff --git a/public/views/space.js b/public/views/space.js index 0c6c390..cd0313c 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -1,9 +1,80 @@ -// T17 stub — full implementation lands in T19. +// Space view — header + three columns (projects, recent open tasks, recent pages/refs). +import { api } from '../api.js'; import { el, mount } from '../dom.js'; -export async function render(main, ctx) { - mount(main, - el('h1', { class: 'view-h1' }, 'Space'), - el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')), - el('div', { class: 'card muted' }, 'Space view ships in T19.') + +function projCard(p) { + return el('li', {}, + el('a', { href: '#/project/' + p.id }, p.name), + ' ', + el('span', { class: 'status' + (p.status === 'done' ? ' ok' : p.status === 'paused' ? ' warn' : '') }, p.status) + ); +} + +function taskCard(t) { + return el('li', {}, + el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), + ' ', t.title + ); +} + +function pageCard(p) { + return el('li', {}, el('a', { href: '#/page/' + p.id }, p.title)); +} + +function refCard(r) { + return el('li', {}, el('a', { href: '#/ref/' + r.id }, r.title || r.source_url || '(untitled)'), + ' ', el('span', { class: 'status idle' }, r.kind)); +} + +export async function render(main, ctx) { + const id = ctx.params.id; + mount(main, + el('h1', { class: 'view-h1' }, 'Space'), + el('p', { class: 'view-sub muted' }, 'Loading …') + ); + + let space; + try { space = await api.get('/api/spaces/' + id); } + catch (e) { + mount(main, + el('h1', { class: 'view-h1' }, 'Space not found'), + el('p', { class: 'view-sub muted' }, e.message) + ); + return; + } + + const [projects, tasks, pages, refs] = await Promise.all([ + api.get(`/api/spaces/${id}/projects`).catch(() => []), + api.get(`/api/spaces/${id}/tasks?status=todo`).catch(() => []), + api.get(`/api/spaces/${id}/pages`).catch(() => []), + api.get(`/api/refs?space_id=${id}&limit=8`).catch(() => []) + ]); + + mount(main, + el('h1', { class: 'view-h1' }, space.name), + el('p', { class: 'view-sub' }, space.description || el('span', { class: 'muted' }, 'No description.')), + el('div', { class: 'row' }, + el('div', { class: 'card' }, + el('h3', {}, 'Projects'), + projects.length + ? el('ul', { class: 'plain' }, projects.map(projCard)) + : el('p', { class: 'muted' }, 'None yet.') + ), + el('div', { class: 'card' }, + el('h3', {}, 'Open tasks'), + tasks.length + ? el('ul', { class: 'plain' }, tasks.slice(0, 10).map(taskCard)) + : el('p', { class: 'muted' }, 'Clear board.') + ), + el('div', { class: 'card' }, + el('h3', {}, 'Recent pages & refs'), + pages.length || refs.length + ? el('ul', { class: 'plain' }, [ + ...pages.slice(0, 6).map(pageCard), + ...refs.slice(0, 6).map(refCard) + ]) + : el('p', { class: 'muted' }, 'Nothing here yet.') + ) + ) ); }