Embed MagicMirror² (CT 111) via the shared embedView factory, exposed at mirror.hynesy.com through Traefik + CF Access. Traefik mirror-frame middleware swaps MM's X-Frame-Options for a CSP frame-ancestors allowing the Void origins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.8 KiB
JavaScript
165 lines
5.8 KiB
JavaScript
// Spaces tree (lazy-expand to projects) + global links section.
|
|
// Drag-reorder is deferred — static order for now.
|
|
|
|
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.dot ? el('span', { class: 'sb-dot ' + opts.dot }) : null,
|
|
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 loadTopPages(space_id) {
|
|
try {
|
|
const pages = await api.get(`/api/spaces/${space_id}/pages`);
|
|
return pages.filter(p => p.parent_id == null);
|
|
} 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);
|
|
if (s.kind === 'docs') {
|
|
const pages = await loadTopPages(s.id);
|
|
clear(childWrap);
|
|
if (!pages.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no pages)'));
|
|
for (const p of pages) {
|
|
childWrap.appendChild(el('a', { class: 'sb-item', href: '#/page/' + p.id }, p.title || '(untitled)'));
|
|
}
|
|
} else {
|
|
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) {
|
|
if (s.kind === 'docs') {
|
|
loadTopPages(s.id).then(pages => {
|
|
clear(childWrap);
|
|
if (!pages.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no pages)'));
|
|
for (const p of pages) {
|
|
childWrap.appendChild(el('a', { class: 'sb-item', href: '#/page/' + p.id }, p.title || '(untitled)'));
|
|
}
|
|
});
|
|
} else {
|
|
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('div', { class: 'sb-section' },
|
|
el('div', { class: 'sb-title' }, 'Agents'),
|
|
navItem('Yerin', '/yerin', { dot: 'yerin' }),
|
|
navItem('Little Blue', '/little-blue', { dot: 'lb' })
|
|
),
|
|
el('div', { class: 'sb-section' },
|
|
el('div', { class: 'sb-title' }, 'Navigate'),
|
|
navItem('Sacred Valley', '/sacred-valley'),
|
|
navItem('Terminal', '/terminal'),
|
|
navItem('Search', '/search'),
|
|
inboxItem,
|
|
navItem('Jobs', '/jobs'),
|
|
navItem('Settings', '/settings')
|
|
),
|
|
el('div', { class: 'sb-section' },
|
|
el('div', { class: 'sb-title' }, 'Apps'),
|
|
navItem('Timelapse', '/timelapse'),
|
|
navItem('AI Usage', '/ai-usage'),
|
|
navItem('OBD2', '/obd2'),
|
|
navItem('Links', '/links'),
|
|
navItem('MagicMirror', '/mirror')
|
|
)
|
|
);
|
|
|
|
// Sync the active highlight across ALL nav items (global links + space tree)
|
|
// to the current hash. navItem only sets active at creation, so without this
|
|
// the highlight stays stuck on the previously-selected tab until a refresh.
|
|
function syncActive() {
|
|
root.querySelectorAll('a.sb-item').forEach(a =>
|
|
a.classList.toggle('active', a.getAttribute('href') === location.hash));
|
|
}
|
|
|
|
renderSpaceTree(spacesContainer).then(syncActive);
|
|
|
|
// 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)));
|
|
});
|
|
|
|
// On navigation: re-render the tree (lazy state) then re-sync the highlight.
|
|
window.addEventListener('hashchange', async () => {
|
|
await renderSpaceTree(spacesContainer);
|
|
syncActive();
|
|
});
|
|
syncActive();
|
|
}
|