feat(ui): breadcrumb (Space › parent › page) + export menu (md/txt/html/pdf) on pages & spaces
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
35
public/components/breadcrumb.js
Normal file
35
public/components/breadcrumb.js
Normal file
@@ -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;
|
||||
}
|
||||
68
public/components/export_menu.js
Normal file
68
public/components/export_menu.js
Normal file
@@ -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 `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body{max-width:820px;margin:40px auto;padding:0 20px;font:15px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a}
|
||||
h1,h2,h3{font-family:Georgia,serif} h1{font-size:26px} h2{font-size:20px} h3{font-size:16px}
|
||||
code{background:#f4f4f6;padding:1px 5px;border-radius:3px;font-size:13px}
|
||||
pre{background:#f4f4f6;padding:12px;border-radius:5px;overflow:auto}
|
||||
table{border-collapse:collapse;width:100%;margin:12px 0} th,td{border:1px solid #ccc;padding:6px 10px;text-align:left}
|
||||
th{background:#f0f0f2} blockquote{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;color:#555}
|
||||
a{color:#b8431f}
|
||||
</style></head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user