Files
Void-Homelab/public/components/project_card.js
root dbf84559de feat(ui): project details panel + compact/responsive cards; rename Sentinel→Yerin (red); migrate research_notes
- Project card expands to show description + status + dates (was only the research stub)
- Cards compacted + responsive (actions wrap on narrow)
- Sentinel renamed Yerin everywhere (#/yerin, red 'Sage of the Endless Sword' theme + red sidebar dot)
- void1 importer now carries research_notes/last_researched_at (was dropped)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:19:16 +10:00

80 lines
4.3 KiB
JavaScript
Raw 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);
// ---- 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;
}