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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,112 @@
|
|||||||
// T17 stub — full implementation lands in T18.
|
// Spaces tree (lazy-expand to projects) + global links section.
|
||||||
// For now: brand only, so the shell renders without errors.
|
// Drag-reorder is deferred — static order for now.
|
||||||
import { el, mount } from '../dom.js';
|
|
||||||
|
|
||||||
export function renderSidebar(root) {
|
import { api } from '../api.js';
|
||||||
mount(root,
|
import { el, mount, clear } from '../dom.js';
|
||||||
el('div', { class: 'sb-section' },
|
import { navigate, current } from '../router.js';
|
||||||
el('div', { class: 'sb-title' }, 'Void'),
|
import { on } from '../state.js';
|
||||||
el('div', { class: 'sb-item muted' }, 'Sidebar loads in T18')
|
|
||||||
)
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
// T17 stub — full implementation lands in T18.
|
// Topbar: brand, global search (Enter → /search?q=), capture button stub,
|
||||||
import { el, mount } from '../dom.js';
|
// pending bell with badge, user/agent toggle stub.
|
||||||
|
|
||||||
|
import { el, mount, clear } from '../dom.js';
|
||||||
import { navigate } from '../router.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) {
|
export function renderTopbar(root) {
|
||||||
const search = el('input', {
|
const searchInput = el('input', {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'Search …',
|
placeholder: 'Search … (Enter to search)',
|
||||||
onkeydown: (e) => {
|
onkeydown: (e) => {
|
||||||
if (e.key === 'Enter' && e.target.value.trim()) {
|
if (e.key === 'Enter' && e.target.value.trim()) {
|
||||||
navigate('/search?q=' + encodeURIComponent(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,
|
mount(root,
|
||||||
el('div', { class: 'brand' }, 'VOID'),
|
el('div', { class: 'brand' }, 'VOID'),
|
||||||
el('div', { class: 'topbar-search' }, search),
|
el('button', { class: 'icon-btn', onclick: captureModal }, '+ Capture'),
|
||||||
el('div', { class: 'topbar-spacer' })
|
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)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user