From e5b2181ee38c5baa237ee9e783dc9ff3729772bc Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 02:14:44 +1000 Subject: [PATCH] feat(ui): sidebar + topbar + rightrail components Sidebar: Spaces tree with lazy-expand to projects on caret click; bottom Navigate section with Sacred Valley / Search / Inbox + placeholders for Agents and Resources greyed out as later. Inbox item carries a pending-count badge that wires to state.js so the topbar bell and the sidebar share one poll. Topbar: brand, + Capture button (modal stub for Plan 3 capture queue), global search input (Enter -> /search?q=), pending Inbox bell with matching badge, Owner toggle (stub for agent-switching post-Plan-2). Rightrail remains the T17 collapsible companion placeholder. Co-Authored-By: Claude Opus 4.7 --- public/components/sidebar.js | 118 ++++++++++++++++++++++++++++++++--- public/components/topbar.js | 45 +++++++++++-- 2 files changed, 148 insertions(+), 15 deletions(-) diff --git a/public/components/sidebar.js b/public/components/sidebar.js index b128f56..090155e 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -1,12 +1,112 @@ -// T17 stub — full implementation lands in T18. -// For now: brand only, so the shell renders without errors. -import { el, mount } from '../dom.js'; +// Spaces tree (lazy-expand to projects) + global links section. +// Drag-reorder is deferred — static order for now. -export function renderSidebar(root) { - mount(root, - el('div', { class: 'sb-section' }, - el('div', { class: 'sb-title' }, 'Void'), - el('div', { class: 'sb-item muted' }, 'Sidebar loads in T18') - ) +import { api } from '../api.js'; +import { el, mount, clear } from '../dom.js'; +import { navigate, current } from '../router.js'; +import { on } from '../state.js'; + +const expanded = new Set(); // space ids expanded in the tree + +function navItem(label, hash, opts = {}) { + const active = current().hash === hash.replace(/^#/, ''); + return el('a', { + class: 'sb-item' + (active ? ' active' : ''), + href: '#' + hash.replace(/^#/, ''), + onclick: (e) => { + if (opts.onclick) { e.preventDefault(); opts.onclick(e); return; } + } + }, + opts.icon ? el('span', { class: 'caret' }, opts.icon) : null, + el('span', { style: { flex: 1 } }, label), + opts.badge !== undefined && opts.badge !== null ? el('span', { class: 'badge' }, String(opts.badge)) : null ); } + +async function loadProjects(space_id) { + try { + return await api.get(`/api/spaces/${space_id}/projects`); + } catch { return []; } +} + +async function renderSpaceTree(container) { + let spaces; + try { spaces = await api.get('/api/spaces'); } + catch { spaces = []; } + clear(container); + if (!spaces.length) { + container.appendChild(el('div', { class: 'sb-item muted' }, 'No spaces yet')); + return; + } + for (const s of spaces) { + const isOpen = expanded.has(s.id); + const childWrap = el('div', { class: 'sb-children' }); + const header = el('a', { + class: 'sb-item', + href: '#/space/' + s.id, + onclick: async (e) => { + // Click on caret toggles; click on label navigates. + if (e.target.dataset.role === 'caret') { + e.preventDefault(); + if (expanded.has(s.id)) { expanded.delete(s.id); clear(childWrap); } + else { + expanded.add(s.id); + const projects = await loadProjects(s.id); + clear(childWrap); + if (!projects.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no projects)')); + for (const p of projects) { + childWrap.appendChild(el('a', { class: 'sb-item', href: '#/project/' + p.id }, p.name)); + } + } + } + } + }, + el('span', { class: 'caret', dataset: { role: 'caret' } }, isOpen ? '▾' : '▸'), + el('span', { style: { flex: 1 } }, s.name) + ); + container.appendChild(header); + if (isOpen) { + loadProjects(s.id).then(projects => { + clear(childWrap); + if (!projects.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no projects)')); + for (const p of projects) { + childWrap.appendChild(el('a', { class: 'sb-item', href: '#/project/' + p.id }, p.name)); + } + }); + } + container.appendChild(childWrap); + } +} + +export function renderSidebar(root) { + const spacesContainer = el('div'); + const inboxItem = navItem('Inbox', '/inbox'); + + mount(root, + el('div', { class: 'sb-section' }, + el('div', { class: 'sb-title' }, 'Spaces'), + spacesContainer + ), + el('hr'), + el('div', { class: 'sb-section' }, + el('div', { class: 'sb-title' }, 'Navigate'), + navItem('Sacred Valley', '/sacred-valley'), + navItem('Search', '/search'), + inboxItem, + el('div', { class: 'sb-item muted', title: 'Ships post-Plan-2' }, 'Agents — later'), + el('div', { class: 'sb-item muted', title: 'Ships post-Plan-2' }, 'Resources — later') + ) + ); + + renderSpaceTree(spacesContainer); + + // Pending-count badge wiring + on('pending-count', (n) => { + const old = inboxItem.querySelector('.badge'); + if (old) old.remove(); + if (n > 0) inboxItem.appendChild(el('span', { class: 'badge' }, String(n))); + }); + + // Refresh tree on hashchange (active highlight) and on space creation. + window.addEventListener('hashchange', () => renderSpaceTree(spacesContainer)); +} diff --git a/public/components/topbar.js b/public/components/topbar.js index f26207d..e772eaf 100644 --- a/public/components/topbar.js +++ b/public/components/topbar.js @@ -1,20 +1,53 @@ -// T17 stub — full implementation lands in T18. -import { el, mount } from '../dom.js'; +// Topbar: brand, global search (Enter → /search?q=), capture button stub, +// pending bell with badge, user/agent toggle stub. + +import { el, mount, clear } from '../dom.js'; import { navigate } from '../router.js'; +import { on } from '../state.js'; + +function captureModal() { + const root = document.getElementById('modal-root'); + mount(root, + el('div', { class: 'modal-backdrop', onclick: (e) => { if (e.target.classList.contains('modal-backdrop')) clear(root); } }, + el('div', { class: 'modal' }, + el('h2', {}, 'Universal Capture'), + el('p', { class: 'muted' }, + 'Drag a URL, paste a YouTube link, drop a PDF. ' + + 'Plan 3 wires the capture queue + workers — this surface is here so the UX shape is honest about what is coming.' + ), + el('div', { class: 'actions' }, + el('button', { class: 'ghost', onclick: () => clear(root) }, 'Close') + ) + ) + ) + ); +} export function renderTopbar(root) { - const search = el('input', { + const searchInput = el('input', { type: 'text', - placeholder: 'Search …', + placeholder: 'Search … (Enter to search)', onkeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim()) { navigate('/search?q=' + encodeURIComponent(e.target.value.trim())); } } }); + + const bell = el('button', { class: 'icon-btn', onclick: () => navigate('/inbox') }, 'Inbox'); + mount(root, el('div', { class: 'brand' }, 'VOID'), - el('div', { class: 'topbar-search' }, search), - el('div', { class: 'topbar-spacer' }) + el('button', { class: 'icon-btn', onclick: captureModal }, '+ Capture'), + el('div', { class: 'topbar-search' }, searchInput), + el('div', { class: 'topbar-spacer' }), + bell, + el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner') ); + + on('pending-count', (n) => { + const old = bell.querySelector('.badge'); + if (old) old.remove(); + if (n > 0) bell.appendChild(el('span', { class: 'badge' }, String(n))); + }); }