feat(ui): project card Tasks + Linked references sections; GET /api/projects/:id/links
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as repo from '../../db/repos/projects.js';
|
import * as repo from '../../db/repos/projects.js';
|
||||||
|
import * as links from '../../db/repos/links.js';
|
||||||
|
import * as pagesRepo from '../../db/repos/pages.js';
|
||||||
|
import * as refsRepo from '../../db/repos/refs.js';
|
||||||
import { validate } from '../validate.js';
|
import { validate } from '../validate.js';
|
||||||
import { NotFoundError, ValidationError, asyncWrap } from '../errors.js';
|
import { NotFoundError, ValidationError, asyncWrap } from '../errors.js';
|
||||||
import { requireWrite, divertToPending } from '../cap.js';
|
import { requireWrite, divertToPending } from '../cap.js';
|
||||||
@@ -80,6 +83,22 @@ router.patch('/:id',
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Linked references (entity_links FROM this project → pages/refs), with titles resolved.
|
||||||
|
router.get('/:id/links',
|
||||||
|
validate({ params: idParams }),
|
||||||
|
asyncWrap(async (req, res) => {
|
||||||
|
const rows = await links.listFrom('project', req.params.id);
|
||||||
|
const out = [];
|
||||||
|
for (const l of rows) {
|
||||||
|
let title = null;
|
||||||
|
if (l.to_type === 'page') { const p = await pagesRepo.getById(l.to_id); title = p?.title; }
|
||||||
|
else if (l.to_type === 'ref') { const r = await refsRepo.getById(l.to_id); title = r?.title || r?.source_url; }
|
||||||
|
if (title) out.push({ id: l.id, to_type: l.to_type, to_id: l.to_id, title, relation: l.relation });
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Research stub — owner asks Eithan (later) to research this project.
|
// Research stub — owner asks Eithan (later) to research this project.
|
||||||
router.post('/:id/research',
|
router.post('/:id/research',
|
||||||
validate({ params: idParams }),
|
validate({ params: idParams }),
|
||||||
|
|||||||
@@ -68,6 +68,29 @@ export function projectCard(p, o) {
|
|||||||
det.appendChild(dl);
|
det.appendChild(dl);
|
||||||
panel.appendChild(det);
|
panel.appendChild(det);
|
||||||
|
|
||||||
|
// ---- Tasks (sub-work of this project) ----
|
||||||
|
panel.appendChild(el('div', { class: 'proj-section-h' }, 'Tasks'));
|
||||||
|
const tasksList = el('div', { class: 'proj-sub-list' }, el('div', { class: 'muted', style: { fontSize: '12px' } }, 'Loading…'));
|
||||||
|
panel.appendChild(tasksList);
|
||||||
|
api.get('/api/projects/' + p.id + '/tasks').then(ts => {
|
||||||
|
tasksList.replaceChildren();
|
||||||
|
if (!ts.length) { tasksList.appendChild(el('div', { class: 'muted', style: { fontSize: '12px' } }, 'No tasks.')); return; }
|
||||||
|
for (const t of ts) tasksList.appendChild(el('div', { class: 'proj-sub-row' },
|
||||||
|
el('span', { class: 'status' + (t.status === 'done' ? ' ok' : t.status === 'blocked' ? ' bad' : '') }, t.status || 'todo'),
|
||||||
|
' ', el('span', {}, t.title)));
|
||||||
|
}).catch(() => tasksList.replaceChildren(el('div', { class: 'muted', style: { fontSize: '12px' } }, '—')));
|
||||||
|
|
||||||
|
// ---- Linked references ----
|
||||||
|
panel.appendChild(el('div', { class: 'proj-section-h' }, 'Linked references'));
|
||||||
|
const refsList = el('div', { class: 'proj-sub-list' }, el('div', { class: 'muted', style: { fontSize: '12px' } }, 'Loading…'));
|
||||||
|
panel.appendChild(refsList);
|
||||||
|
api.get('/api/projects/' + p.id + '/links').then(ls => {
|
||||||
|
refsList.replaceChildren();
|
||||||
|
if (!ls.length) { refsList.appendChild(el('div', { class: 'muted', style: { fontSize: '12px' } }, 'None linked.')); return; }
|
||||||
|
for (const l of ls) refsList.appendChild(el('div', { class: 'proj-sub-row' },
|
||||||
|
el('a', { href: (l.to_type === 'page' ? '#/page/' : '#/ref/') + l.to_id }, l.title)));
|
||||||
|
}).catch(() => refsList.replaceChildren(el('div', { class: 'muted', style: { fontSize: '12px' } }, '—')));
|
||||||
|
|
||||||
// ---- Eithan research ----
|
// ---- Eithan research ----
|
||||||
panel.appendChild(el('div', { class: 'proj-section-h' }, 'Eithan research' + (p.last_researched_at ? ` · ${ago(p.last_researched_at)}` : '')));
|
panel.appendChild(el('div', { class: 'proj-section-h' }, 'Eithan research' + (p.last_researched_at ? ` · ${ago(p.last_researched_at)}` : '')));
|
||||||
if (busy) panel.appendChild(el('div', { class: 'muted' }, "Queued for Eithan — he'll fill this in once the agent ships."));
|
if (busy) panel.appendChild(el('div', { class: 'muted' }, "Queued for Eithan — he'll fill this in once the agent ships."));
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
|
|||||||
.proj-dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; margin: 0; font-size: 12px; }
|
.proj-dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; margin: 0; font-size: 12px; }
|
||||||
.proj-dl dt { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 10px; align-self: center; }
|
.proj-dl dt { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 10px; align-self: center; }
|
||||||
.proj-dl dd { margin: 0; color: var(--text); }
|
.proj-dl dd { margin: 0; color: var(--text); }
|
||||||
|
.proj-sub-list { display: flex; flex-direction: column; gap: 3px; margin-bottom: 4px; }
|
||||||
|
.proj-sub-row { font-size: 13px; color: var(--text); padding: 2px 0; }
|
||||||
.proj-panel .research-notes, .proj-panel .md-preview { font-size: 13px; }
|
.proj-panel .research-notes, .proj-panel .md-preview { font-size: 13px; }
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.proj-head { flex-wrap: wrap; }
|
.proj-head { flex-wrap: wrap; }
|
||||||
|
|||||||
Reference in New Issue
Block a user