feat(ui): Settings view + per-space project cards (status/research/edit/delete) + theming pass
- Settings (#/settings): API tokens (mint/list/revoke), Agents list, Orthos Mode placeholder - Per-space Projects: Void-1-style expandable cards — inline status, ↻ Research (Eithan stub), Edit/New modal, Delete-with-confirm; migration 019 adds research_status/notes/timestamps; POST /api/projects/:id/research stub; GET /api/agent-tokens list - Global +1 font bump; themed scrollbars; larger/bolder themed topbar Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
61
public/components/project_card.js
Normal file
61
public/components/project_card.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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' });
|
||||
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;
|
||||
}
|
||||
45
public/components/project_modal.js
Normal file
45
public/components/project_modal.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Create / edit a project. Minimal modal: name, description, status.
|
||||
import { el } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const STATUSES = ['idea', 'active', 'paused', 'done', 'abandoned'];
|
||||
const slugify = (s) => (s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 50) || 'project');
|
||||
|
||||
export function openProjectModal(spaceId, project, onSaved) {
|
||||
const editing = !!project;
|
||||
const name = el('input', { class: 'pm-input', value: project?.name || '', placeholder: 'Project name' });
|
||||
const desc = el('textarea', { class: 'pm-input', rows: 3, placeholder: 'Description (optional)' });
|
||||
desc.value = project?.description || '';
|
||||
const status = el('select', { class: 'pm-input' }, STATUSES.map(s => el('option', { value: s, selected: s === (project?.status || 'active') }, s)));
|
||||
const errEl = el('div', { class: 'err', style: { minHeight: '15px' } }, '');
|
||||
|
||||
async function save() {
|
||||
const nm = name.value.trim();
|
||||
if (!nm) { errEl.textContent = 'Name required'; return; }
|
||||
try {
|
||||
if (editing) {
|
||||
await api.patch('/api/projects/' + project.id, { name: nm, description: desc.value.trim() || null, status: status.value });
|
||||
} else {
|
||||
await api.post(`/api/spaces/${spaceId}/projects`, {
|
||||
slug: slugify(nm) + '-' + Date.now().toString(36).slice(-4),
|
||||
name: nm, description: desc.value.trim() || undefined, status: status.value
|
||||
});
|
||||
}
|
||||
modal.remove();
|
||||
onSaved && onSaved();
|
||||
} catch (e) { errEl.textContent = 'Save failed: ' + e.message; }
|
||||
}
|
||||
|
||||
const modal = el('div', { class: 'rev-overlay', onclick: (e) => { if (e.target === modal) modal.remove(); } },
|
||||
el('div', { class: 'pm-modal' },
|
||||
el('div', { class: 'pm-head' }, editing ? 'Edit project' : 'New project'),
|
||||
el('label', { class: 'pm-label' }, 'Name'), name,
|
||||
el('label', { class: 'pm-label' }, 'Description'), desc,
|
||||
el('label', { class: 'pm-label' }, 'Status'), status,
|
||||
errEl,
|
||||
el('div', { class: 'pm-foot' },
|
||||
el('button', { class: 'ghost', onclick: () => modal.remove() }, 'Cancel'),
|
||||
el('button', { class: 'primary', onclick: save }, editing ? 'Save' : 'Create'))));
|
||||
document.body.appendChild(modal);
|
||||
name.focus();
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function renderSidebar(root) {
|
||||
navItem('Search', '/search'),
|
||||
inboxItem,
|
||||
navItem('Jobs', '/jobs'),
|
||||
el('div', { class: 'sb-item muted', title: 'Ships post-Plan-2' }, 'Resources — later')
|
||||
navItem('Settings', '/settings')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user