// Void-1-style expandable project card: status (inline-editable), Research (Eithan // stub), Edit, Delete (confirm), and an expandable research panel. import { el } from '../dom.js'; import { api } from '../api.js'; import { renderMarkdown } from '../markdown.js'; import { openProjectModal } from './project_modal.js'; const STATUSES = ['idea', 'active', 'paused', 'done', 'abandoned']; const _open = new Set(); // expanded project ids (persist across re-renders) function ago(ts) { if (!ts) return ''; const s = (Date.now() - new Date(ts)) / 1000; if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; return Math.floor(s / 86400) + 'd ago'; } // o: { reload() — refetch+render, rerender() — render from current data } export function projectCard(p, o) { const open = _open.has(p.id); const card = el('div', { class: 'proj-card' + (open ? ' open' : '') }); const busy = p.research_status === 'requested' || p.research_status === 'researching'; const statusSel = el('select', { class: 'proj-status st-' + p.status, onclick: (e) => e.stopPropagation(), onchange: async (e) => { try { await api.patch('/api/projects/' + p.id, { status: e.target.value }); o.reload(); } catch (err) { alert('failed: ' + err.message); } } }, STATUSES.map(s => el('option', { value: s, selected: s === p.status }, s))); const research = el('button', { class: 'proj-btn' + (busy ? ' busy' : ''), title: busy ? 'Queued for Eithan' : 'Ask Eithan to research this project', onclick: async (e) => { e.stopPropagation(); try { await api.post('/api/projects/' + p.id + '/research'); o.reload(); } catch (err) { alert('failed: ' + err.message); } } }, busy ? '↻ Queued' : '↻ Research'); const header = el('div', { class: 'proj-head', onclick: () => { open ? _open.delete(p.id) : _open.add(p.id); o.rerender(); } }, el('span', { class: 'proj-chev' }, '›'), el('div', { class: 'proj-meta' }, el('div', { class: 'proj-title' }, p.name), p.description ? el('div', { class: 'proj-desc' }, p.description) : null), el('div', { class: 'proj-actions', onclick: (e) => e.stopPropagation() }, statusSel, research, el('button', { class: 'proj-btn', onclick: () => openProjectModal(p.space_id, p, o.reload) }, 'Edit'), el('button', { class: 'proj-btn danger', title: 'Delete project', onclick: async () => { if (!confirm(`Delete project "${p.name}"? This cannot be undone.`)) return; try { await api.del('/api/projects/' + p.id); o.reload(); } catch (err) { alert('failed: ' + err.message); } } }, '✕'))); card.appendChild(header); if (open) { const panel = el('div', { class: 'proj-panel' }); // ---- Details ---- panel.appendChild(el('div', { class: 'proj-section-h' }, 'Details')); const det = el('div', { class: 'proj-details' }); det.appendChild(p.description ? el('div', { class: 'proj-detail-desc' }, p.description) : el('div', { class: 'muted', style: { fontSize: '12px' } }, 'No description yet — Edit to add one.')); const dl = el('dl', { class: 'proj-dl' }); const addDL = (k, v) => { if (v) { dl.appendChild(el('dt', {}, k)); dl.appendChild(el('dd', {}, v)); } }; addDL('Status', p.status); addDL('Created', p.created_at ? new Date(p.created_at).toLocaleDateString() : null); addDL('Updated', p.updated_at ? `${new Date(p.updated_at).toLocaleDateString()} (${ago(p.updated_at)})` : null); if (p.started_at) addDL('Started', new Date(p.started_at).toLocaleDateString()); if (p.completed_at) addDL('Completed', new Date(p.completed_at).toLocaleDateString()); det.appendChild(dl); 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 ---- 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.")); else if (p.research_notes) { const n = el('div', { class: 'md-preview' }); n.innerHTML = renderMarkdown(p.research_notes); panel.appendChild(n); } else panel.appendChild(el('div', { class: 'muted' }, 'No research yet — press ↻ Research to queue it for Eithan.')); card.appendChild(panel); } return card; }