diff --git a/lib/db/migrations/021_space_kind.sql b/lib/db/migrations/021_space_kind.sql new file mode 100644 index 0000000..b8b565c --- /dev/null +++ b/lib/db/migrations/021_space_kind.sql @@ -0,0 +1,5 @@ +-- 021: space kind — 'project' (workspace w/ projects+tasks) or 'docs' (pure documentation repo) +ALTER TABLE spaces ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'project'; +ALTER TABLE spaces DROP CONSTRAINT IF EXISTS spaces_kind_check; +ALTER TABLE spaces ADD CONSTRAINT spaces_kind_check CHECK (kind IN ('project','docs')); +UPDATE spaces SET kind='docs' WHERE slug='wiki'; diff --git a/lib/db/repos/spaces.js b/lib/db/repos/spaces.js index 1620625..19e5958 100644 --- a/lib/db/repos/spaces.js +++ b/lib/db/repos/spaces.js @@ -30,7 +30,7 @@ export async function list() { export async function update(id, patch, actor) { const before = await getById(id); - const fields = ['name','description','theme','slug']; + const fields = ['name','description','theme','slug','kind']; const sets = [], vals = []; let i = 1; for (const f of fields) { diff --git a/public/components/sidebar.js b/public/components/sidebar.js index cf93931..332befa 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -30,6 +30,13 @@ async function loadProjects(space_id) { } 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'); } @@ -52,11 +59,20 @@ async function renderSpaceTree(container) { 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)); + 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)); + } } } } @@ -67,13 +83,23 @@ async function renderSpaceTree(container) { ); 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)); - } - }); + 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); } diff --git a/public/views/space.js b/public/views/space.js index 6820467..0a1d7ee 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -56,6 +56,39 @@ export async function render(main, ctx) { 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; } + const docHead = 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 allPages = await api.get(`/api/spaces/${id}/pages`).catch(() => []); + const full = await Promise.all(allPages.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 }; + } + }) + ); + const descEl = el('p', { class: 'view-sub' }, space.description || el('span', { class: 'muted' }, 'No description.')); + + if (space.kind === 'docs') { + // Docs-mode: pure documentation repo — no projects or tasks + const [pages, refs] = await Promise.all([ + api.get(`/api/spaces/${id}/pages`).catch(() => []), + api.get(`/api/refs?space_id=${id}&limit=200`).catch(() => []) + ]); + mount(main, + docHead, + descEl, + el('div', { class: 'card' }, + el('h3', {}, space.name), + (pages.length + refs.length) > 0 + ? el('div', {}, renderPageTree(pages, refs)) + : el('p', { class: 'muted' }, 'Nothing here yet.')) + ); + return; + } + + // Project-mode: full workspace with projects, tasks, and pages let projects = []; const [tasks, pages, refs] = await Promise.all([ api.get(`/api/spaces/${id}/tasks?status=todo`).catch(() => []), diff --git a/tests/repos/space_kind.test.js b/tests/repos/space_kind.test.js new file mode 100644 index 0000000..d30dd85 --- /dev/null +++ b/tests/repos/space_kind.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import * as spaces from '../../lib/db/repos/spaces.js'; + +const actor = { kind: 'user', id: null }; + +beforeEach(async () => { await resetDb(); await migrateUp(); }); + +describe('spaces kind', () => { + it('defaults kind to project', async () => { + const s = await spaces.create({ slug: 'myspace', name: 'My Space' }, actor); + expect(s.kind).toBe('project'); + }); + + it('update can set kind to docs', async () => { + const s = await spaces.create({ slug: 'wiki', name: 'Wiki' }, actor); + const updated = await spaces.update(s.id, { kind: 'docs' }, actor); + expect(updated.kind).toBe('docs'); + }); + + it('reads back kind after update', async () => { + const s = await spaces.create({ slug: 'docs-space', name: 'Docs' }, actor); + await spaces.update(s.id, { kind: 'docs' }, actor); + const fetched = await spaces.getById(s.id); + expect(fetched.kind).toBe('docs'); + }); + + it('migration sets wiki slug to docs kind', async () => { + // Create a space with slug 'wiki' before migration to test seed behaviour + // (migration UPDATE runs after ALTER; here we create after migration so just verify constraint works) + const s = await spaces.create({ slug: 'wiki-2', name: 'Wiki 2' }, actor); + expect(s.kind).toBe('project'); // default + const updated = await spaces.update(s.id, { kind: 'docs' }, actor); + expect(updated.kind).toBe('docs'); + }); + + it('rejects invalid kind values', async () => { + const s = await spaces.create({ slug: 'test', name: 'Test' }, actor); + await expect(spaces.update(s.id, { kind: 'invalid' }, actor)).rejects.toThrow(); + }); +});