Files
Void-Homelab/public/components/project_card.js

103 lines
6.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}