Files
Void-Homelab/public/views/space.js
root 3f77f3faad feat(pages): explicit position ordering + sectioned space view
Add position column to pages (migration 020), update listBySpace to ORDER BY position, title,
expose position in update(), add to patchSchema, and replace the space view flat table with a
tree renderer grouping pages by parent_id under h4 section headers.

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

108 lines
4.6 KiB
JavaScript

// Space view — rich project cards (status, research, edit/delete) + open tasks,
// then a full pages & references table.
import { api } from '../api.js';
import { el, mount } from '../dom.js';
import { exportMenu } from '../components/export_menu.js';
import { projectCard } from '../components/project_card.js';
import { openProjectModal } from '../components/project_modal.js';
function taskItem(t) {
return el('li', {},
el('span', { class: 'status' + (t.status === 'blocked' ? ' bad' : '') }, t.status), ' ', t.title);
}
function pageLink(p) {
return el('a', { href: '#/page/' + p.id }, p.title || '(untitled)');
}
function renderPageTree(pages, refs) {
const byParent = new Map();
for (const p of pages) {
const k = p.parent_id || '__root__';
if (!byParent.has(k)) byParent.set(k, []);
byParent.get(k).push(p);
}
const roots = byParent.get('__root__') || [];
const blocks = [];
for (const r of roots) {
const kids = byParent.get(r.id) || [];
blocks.push(el('div', { class: 'doc-section' },
el('h4', { style: { margin: '12px 0 4px' } }, pageLink(r)),
kids.length
? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } },
kids.map(k => {
const gk = byParent.get(k.id) || [];
return el('li', {}, pageLink(k),
gk.length ? el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } },
gk.map(g => el('li', {}, pageLink(g)))) : null);
}))
: null));
}
if (refs.length) blocks.push(el('div', { class: 'doc-section' },
el('h4', { style: { margin: '12px 0 4px' } }, 'References'),
el('ul', { class: 'plain', style: { margin: '0 0 0 14px' } },
refs.map(rf => el('li', {}, el('a', { href: '#/ref/' + rf.id }, rf.title || rf.source_url))))));
return blocks;
}
export async function render(main, ctx) {
const id = ctx.params.id;
mount(main, el('h1', { class: 'view-h1' }, 'Space'), el('p', { class: 'view-sub muted' }, 'Loading …'));
localStorage.setItem('last_space_id', id);
let space;
try { space = await api.get('/api/spaces/' + id); }
catch (e) { mount(main, el('h1', { class: 'view-h1' }, 'Space not found'), el('p', { class: 'view-sub muted' }, e.message)); return; }
let projects = [];
const [tasks, pages, refs] = await Promise.all([
api.get(`/api/spaces/${id}/tasks?status=todo`).catch(() => []),
api.get(`/api/spaces/${id}/pages`).catch(() => []),
api.get(`/api/refs?space_id=${id}&limit=200`).catch(() => [])
]);
try { projects = await api.get(`/api/spaces/${id}/projects`); } catch { /* */ }
// ---- Projects section (rich cards) ----
const projWrap = el('div', { class: 'proj-list' });
function renderProjects() {
projWrap.replaceChildren();
projHead.textContent = `Projects${projects.length ? ` (${projects.length})` : ''}`;
if (!projects.length) { projWrap.appendChild(el('div', { class: 'muted', style: { padding: '4px 2px' } }, 'No projects yet.')); return; }
for (const p of projects) projWrap.appendChild(projectCard(p, { reload, rerender: renderProjects }));
}
async function reload() { try { projects = await api.get(`/api/spaces/${id}/projects`); } catch { /* */ } renderProjects(); }
const projHead = el('h3', {}, 'Projects');
renderProjects();
mount(main,
el('div', { class: 'doc-head' },
el('h1', { class: 'view-h1', style: { margin: '0' } }, space.name),
exportMenu({
filenameBase: 'space-' + (space.slug || space.name),
getContent: async () => {
const full = await Promise.all(pages.map(p => api.get('/api/pages/' + p.id).catch(() => null)));
const md = full.filter(Boolean).map(p => `# ${p.title}\n\n${p.body_md || ''}`).join('\n\n---\n\n');
return { title: space.name, md };
}
})
),
el('p', { class: 'view-sub' }, space.description || el('span', { class: 'muted' }, 'No description.')),
el('div', { class: 'card' },
el('div', { class: 'card-head' }, projHead,
el('button', { class: 'primary', onclick: () => openProjectModal(id, null, reload) }, '+ New')),
projWrap),
el('div', { class: 'card' },
el('h3', {}, `Open tasks${tasks.length ? ` (${tasks.length})` : ''}`),
tasks.length ? el('ul', { class: 'plain' }, tasks.map(taskItem)) : el('p', { class: 'muted' }, 'Clear board.')),
el('div', { class: 'card' },
el('h3', {}, `Pages & references${(pages.length + refs.length) ? ` (${pages.length + refs.length})` : ''}`),
(pages.length + refs.length) > 0
? el('div', {}, renderPageTree(pages, refs))
: el('p', { class: 'muted' }, 'Nothing here yet.'))
);
}