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>
This commit is contained in:
@@ -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';
|
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) {
|
export async function render(main) {
|
||||||
mount(main,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, 'The Void'),
|
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('div', { class: 'card' },
|
||||||
el('h3', {}, 'Welcome'),
|
el('h3', {}, 'Recent activity'),
|
||||||
el('p', { class: 'muted' },
|
el('div', { id: 'home-activity' }, el('span', { class: 'muted' }, 'Loading …'))
|
||||||
'Pick a space from the sidebar to start. The API is alive; the views populate as Phase E lands.'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,102 @@
|
|||||||
// T17 stub — full implementation lands in T19.
|
// Project view — header + tasks (with inline status toggle) + pages + refs + add-task form.
|
||||||
import { el, mount } from '../dom.js';
|
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) {
|
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,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, 'Project'),
|
el('h1', { class: 'view-h1' }, proj.name),
|
||||||
el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
|
el('p', { class: 'view-sub' },
|
||||||
el('div', { class: 'card muted' }, 'Project view ships in T19.')
|
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.')
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { el, mount } from '../dom.js';
|
||||||
export async function render(main, ctx) {
|
|
||||||
mount(main,
|
function projCard(p) {
|
||||||
el('h1', { class: 'view-h1' }, 'Space'),
|
return el('li', {},
|
||||||
el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
|
el('a', { href: '#/project/' + p.id }, p.name),
|
||||||
el('div', { class: 'card muted' }, 'Space view ships in T19.')
|
' ',
|
||||||
|
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.')
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user