diff --git a/public/components/breadcrumb.js b/public/components/breadcrumb.js new file mode 100644 index 0000000..85a55ce --- /dev/null +++ b/public/components/breadcrumb.js @@ -0,0 +1,35 @@ +// Inline stylised breadcrumb: Space › parent page › … › current. +// Walks page.parent_id upward (capped); fills in async, returns the element now. +import { el } from '../dom.js'; +import { api } from '../api.js'; + +export function breadcrumb(page) { + const nav = el('nav', { class: 'crumbs' }); + (async () => { + const parts = []; + try { + const sp = await api.get('/api/spaces/' + page.space_id); + parts.push({ label: sp.name, href: '#/space/' + sp.id }); + } catch { /* */ } + + const chain = []; + let pid = page.parent_id, guard = 0; + while (pid && guard++ < 8) { + try { + const par = await api.get('/api/pages/' + pid); + chain.unshift({ label: par.title, href: '#/page/' + par.id }); + pid = par.parent_id; + } catch { break; } + } + parts.push(...chain, { label: page.title, href: null }); + + nav.replaceChildren(); + parts.forEach((p, i) => { + if (i) nav.appendChild(el('span', { class: 'crumb-sep' }, '›')); + nav.appendChild(p.href + ? el('a', { class: 'crumb', href: p.href }, p.label) + : el('span', { class: 'crumb current' }, p.label)); + }); + })(); + return nav; +} diff --git a/public/components/export_menu.js b/public/components/export_menu.js new file mode 100644 index 0000000..804687c --- /dev/null +++ b/public/components/export_menu.js @@ -0,0 +1,68 @@ +// Export dropdown — Markdown / Plain text / Web page / PDF. Client-side, no deps +// beyond the bundled marked + DOMPurify. getContent() → { title, md }. +import { el } from '../dom.js'; +import { marked } from '../vendor/marked.esm.js'; +import DOMPurify from '../vendor/purify.es.mjs'; + +function download(name, text, mime) { + const url = URL.createObjectURL(new Blob([text], { type: mime })); + const a = el('a', { href: url, download: name }); + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function toPlain(md) { + return md.replace(/```[\s\S]*?```/g, m => m.replace(/```/g, '')) + .replace(/^#{1,6}\s+/gm, '').replace(/\*\*|__|\*|_|`|~~/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^>\s?/gm, ''); +} + +function htmlDoc(title, md) { + const body = DOMPurify.sanitize(marked.parse(md)); + return ` +${title} +${body}`; +} + +export function exportMenu({ getContent, filenameBase }) { + const wrap = el('div', { class: 'exp-menu' }); + + async function run(kind) { + wrap.classList.remove('open'); + const { title, md } = await getContent(); + const base = (filenameBase || title || 'export').replace(/[^\w.-]+/g, '-').replace(/(^-|-$)/g, '') || 'export'; + if (kind === 'md') download(base + '.md', md, 'text/markdown'); + else if (kind === 'txt') download(base + '.txt', toPlain(md), 'text/plain'); + else if (kind === 'html') download(base + '.html', htmlDoc(title, md), 'text/html'); + else if (kind === 'pdf') { + const w = window.open('', '_blank'); + if (!w) return; + w.document.write(htmlDoc(title, md)); w.document.close(); w.focus(); + setTimeout(() => w.print(), 300); + } + } + + const btn = el('button', { + class: 'ghost', + onclick: (e) => { e.stopPropagation(); wrap.classList.toggle('open'); } + }, 'Export ▾'); + + const list = el('div', { class: 'exp-list' }, + el('button', { onclick: () => run('md') }, 'Markdown (.md)'), + el('button', { onclick: () => run('txt') }, 'Plain text (.txt)'), + el('button', { onclick: () => run('html') }, 'Web page (.html)'), + el('button', { onclick: () => run('pdf') }, 'PDF (print)') + ); + + wrap.append(btn, list); + document.addEventListener('click', () => wrap.classList.remove('open')); + return wrap; +} diff --git a/public/style.css b/public/style.css index 612ea3b..7b6d9f8 100644 --- a/public/style.css +++ b/public/style.css @@ -160,6 +160,26 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); } .term-title { font-family: var(--font-display); color: var(--accent); letter-spacing: 0.08em; font-size: 14px; } .term-frame { width: 100%; height: calc(100vh - 100px); border: 1px solid var(--border); border-radius: 6px; background: var(--bg); display: block; } +/* Doc/space header bar: back + breadcrumb on the left, export on the right. */ +.doc-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +.doc-head-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; flex-wrap: wrap; } +.doc-head .back-btn { margin-bottom: 0; } +.doc-head .exp-menu { margin-left: auto; } + +/* Breadcrumb: Space › parent › current */ +.crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; } +.crumb { color: var(--muted); text-decoration: none; } +.crumb:hover { color: var(--accent); } +.crumb.current { color: var(--text); } +.crumb-sep { color: var(--accent-dim); } + +/* Export dropdown */ +.exp-menu { position: relative; } +.exp-list { position: absolute; right: 0; top: calc(100% + 4px); background: var(--panel-2); border: 1px solid var(--border); border-radius: 5px; padding: 4px; min-width: 168px; display: none; z-index: 40; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); } +.exp-menu.open .exp-list { display: block; } +.exp-list button { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 7px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; font-family: var(--font-ui); } +.exp-list button:hover { background: var(--accent-soft); color: var(--accent); } + /* modal */ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal { diff --git a/public/views/page.js b/public/views/page.js index 2e02df0..2f0eb77 100644 --- a/public/views/page.js +++ b/public/views/page.js @@ -3,6 +3,8 @@ import { api } from '../api.js'; import { el, mount } from '../dom.js'; import { markdownEditor } from '../components/markdown_editor.js'; import { backButton } from '../components/backbtn.js'; +import { breadcrumb } from '../components/breadcrumb.js'; +import { exportMenu } from '../components/export_menu.js'; export async function render(main, ctx) { const id = ctx.params.id; @@ -29,7 +31,10 @@ export async function render(main, ctx) { ); mount(main, - backButton(), + el('div', { class: 'doc-head' }, + el('div', { class: 'doc-head-left' }, backButton(), breadcrumb(page)), + exportMenu({ filenameBase: page.slug, getContent: async () => ({ title: page.title, md: page.body_md || '' }) }) + ), el('h1', { class: 'view-h1' }, page.title), el('p', { class: 'view-sub muted' }, '/' + page.slug), editor, diff --git a/public/views/space.js b/public/views/space.js index 3df4d09..e9d8ef7 100644 --- a/public/views/space.js +++ b/public/views/space.js @@ -2,6 +2,7 @@ // pages & references table below (all pages; refs up to the API max). import { api } from '../api.js'; import { el, mount } from '../dom.js'; +import { exportMenu } from '../components/export_menu.js'; function projItem(p) { return el('li', {}, @@ -51,7 +52,17 @@ export async function render(main, ctx) { ]; mount(main, - el('h1', { class: 'view-h1' }, space.name), + 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.')), // Top: Projects + Open tasks, side by side.