/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/assets/js/viewer.js * Purpose : Universal preview renderers + full-screen viewer overlay * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { h, fmtSize, fmtDuration, copyText } from './ui.js'; import { fileIcon, UI } from './icons.js'; /** * Render a preview into `stage` for a descriptor from GET …/preview. * @param {HTMLElement} stage * @param {object} desc preview descriptor (strategy, base, …) * @param {{stream: string, dl: string|null}} urls media URLs for this node */ export async function renderPreview(stage, desc, urls) { stage.innerHTML = ''; const render = RENDERERS[desc.strategy] ?? RENDERERS.fallback; try { await render(stage, desc, urls); } catch (err) { console.error('preview failed', err); RENDERERS.fallback(stage, desc, urls); } } const processing = (label) => h('div.pv-processing', {}, h('div', {}, label), h('div.bar', {}, h('i'))); const panel = (...kids) => { const scroll = h('div.pv-scroll'); const el = h('div.pv-panel', {}, ...kids, scroll); return { el, scroll }; }; // ── Images ─────────────────────────────────────────────────────────── async function renderImage(stage, desc, urls, srcOverride) { const img = h('img', { src: srcOverride ?? urls.stream, alt: desc.name, draggable: false }); const wrap = h('div.pv-img-wrap', {}, img); let scale = 1; let rot = 0; let tx = 0; let ty = 0; let fit = true; const apply = () => { img.style.transform = `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(${scale})`; zoomLabel.textContent = fit ? 'Fit' : `${Math.round(scale * 100)}%`; }; const setZoom = (z, keepFit = false) => { scale = Math.min(Math.max(z, 0.1), 12); fit = keepFit; apply(); }; wrap.addEventListener('wheel', (e) => { e.preventDefault(); setZoom(scale * (e.deltaY < 0 ? 1.15 : 0.87)); }, { passive: false }); let drag = null; wrap.addEventListener('pointerdown', (e) => { drag = { x: e.clientX - tx, y: e.clientY - ty }; wrap.setPointerCapture(e.pointerId); wrap.style.cursor = 'grabbing'; }); wrap.addEventListener('pointermove', (e) => { if (!drag) return; tx = e.clientX - drag.x; ty = e.clientY - drag.y; apply(); }); wrap.addEventListener('pointerup', () => { drag = null; wrap.style.cursor = 'grab'; }); img.addEventListener('dblclick', () => { tx = 0; ty = 0; setZoom(scale === 1 ? 2 : 1, scale !== 1); }); const zoomLabel = h('span.zoom-label', {}, 'Fit'); const bar = h('div.pv-toolbar', {}, h('button.btn', { title: 'Zoom out', html: UI.zoomOut, onclick: () => setZoom(scale * 0.8) }), zoomLabel, h('button.btn', { title: 'Zoom in', html: UI.zoomIn, onclick: () => setZoom(scale * 1.25) }), h('button.btn', { title: '1:1', onclick: () => { tx = 0; ty = 0; setZoom(1); } }, '1:1'), h('button.btn', { title: 'Rotate', html: UI.rotate, onclick: () => { rot = (rot + 90) % 360; apply(); } }), h('button.btn', { title: 'Image info', html: UI.info, onclick: () => showExif(desc) }), ); stage.append(wrap, bar); } async function showExif(desc) { const { modal } = await import('./ui.js'); let data = {}; try { data = await (await fetch(`${desc.base}/exif`, { credentials: 'same-origin' })).json(); } catch {} const dl = h('dl.kv'); const add = (k, v) => { if (v !== null && v !== undefined && v !== '') { dl.append(h('dt', {}, k), h('dd', {}, String(v))); } }; add('Dimensions', data.width ? `${data.width} × ${data.height}` : null); add('Format', data.format); add('Camera', data.camera); add('Lens', data.lens); add('ISO', data.iso); add('Exposure', data.exposure ? `1/${Math.round(1 / data.exposure)}s` : null); add('Aperture', data.fnumber ? `ƒ/${data.fnumber}` : null); add('Focal length', data.focal ? `${data.focal} mm` : null); add('Taken', data.taken ? new Date(data.taken).toLocaleString() : null); const body = h('div', {}, dl.children.length ? dl : h('p.muted', {}, 'No metadata available.')); if (data.gps) { body.append(h('a.btn', { href: `https://www.openstreetmap.org/?mlat=${data.gps.lat}&mlon=${data.gps.lon}#map=15/${data.gps.lat}/${data.gps.lon}`, target: '_blank', rel: 'noopener', }, 'Open map location')); } modal({ title: 'Image details', body, actions: [{ label: 'Close', primary: true, onClick: () => {} }] }); } // ── Video ──────────────────────────────────────────────────────────── async function renderVideo(stage, desc, urls) { const attach = (src) => { const video = h('video.pv-video', { src, controls: true, autoplay: true, playsinline: true, }); // Keyboard: space handled natively when focused; add speed + pip controls. const bar = h('div.pv-toolbar', {}, ...[0.5, 1, 1.5, 2].map((rate) => h('button.btn', { onclick: () => { video.playbackRate = rate; } }, `${rate}×`)), 'pictureInPictureEnabled' in document ? h('button.btn', { title: 'Picture in picture', onclick: () => video.requestPictureInPicture().catch(() => {}) }, 'PiP') : null, h('button.btn', { title: 'Fullscreen', html: UI.expand, onclick: () => video.requestFullscreen?.() }), ); stage.append(video, bar); video.focus(); }; if (desc.webSafe) { attach(urls.stream); return; } stage.append(processing('Optimizing for playback…')); const poll = async () => { try { const res = await fetch(`${desc.base}/video`, { credentials: 'same-origin' }); const { state } = await res.json(); if (state === 'ready') { stage.innerHTML = ''; attach(`${desc.base}/video/file`); return; } if (state === 'unavailable') { stage.innerHTML = ''; RENDERERS.fallback(stage, { ...desc, note: 'This codec can\'t be optimized on the server.' }, urls); return; } } catch { /* keep polling */ } if (stage.isConnected) setTimeout(poll, 2500); }; poll(); } // ── Audio (waveform + ID3) ─────────────────────────────────────────── async function renderAudio(stage, desc, urls) { const art = h('div.art', { html: fileIcon('audio') }); const title = h('div.t', {}, desc.name); const artist = h('div.a', {}, desc.probe?.duration ? fmtDuration(desc.probe.duration) : ''); const wave = h('div', { id: 'waveform' }); const playBtn = h('button.btn.primary', {}, '▶ Play'); const time = h('span.time', {}, '0:00'); const box = h('div.pv-audio', {}, h('div.top', {}, art, h('div.tt', {}, title, artist)), wave, h('div.audio-controls', {}, playBtn, ...[1, 1.5, 2].map((r) => h('button.btn', { onclick: () => ws?.setPlaybackRate(r) }, `${r}×`)), h('button.btn', { id: 'loopBtn', onclick: (e) => { loop = !loop; e.currentTarget.classList.toggle('primary', loop); } }, 'Loop'), time), ); stage.append(box); // Best-effort ID3v2 read for title/artist/cover (first 256 KB). readId3(urls.stream).then((tags) => { if (tags?.title) title.textContent = tags.title; if (tags?.artist) artist.textContent = tags.artist + (artist.textContent ? ` · ${artist.textContent}` : ''); if (tags?.cover) { art.innerHTML = ''; art.append(h('img', { src: tags.cover })); } }).catch(() => {}); let ws = null; let loop = false; try { const { default: WaveSurfer } = await import('/vendor/wavesurfer/wavesurfer.esm.js'); let peaks = null; try { const res = await fetch(`${desc.base}/peaks`, { credentials: 'same-origin' }); peaks = (await res.json()).peaks; if (!peaks?.length) peaks = null; } catch {} ws = WaveSurfer.create({ container: wave, url: urls.stream, peaks: peaks ? [peaks] : undefined, duration: peaks ? desc.probe?.duration : undefined, height: 72, waveColor: 'rgba(139, 147, 163, 0.55)', progressColor: '#4f8cff', cursorColor: '#22d3aa', barWidth: 2, barGap: 1, barRadius: 2, }); ws.on('timeupdate', (t) => { time.textContent = `${fmtDuration(t)} / ${fmtDuration(ws.getDuration())}`; }); ws.on('finish', () => { if (loop) { ws.seekTo(0); ws.play(); } else playBtn.textContent = '▶ Play'; }); playBtn.onclick = () => { ws.playPause(); playBtn.textContent = ws.isPlaying() ? '⏸ Pause' : '▶ Play'; }; } catch { // Wavesurfer unavailable → native audio element. wave.replaceWith(h('audio', { src: urls.stream, controls: true, style: { width: '100%' } })); playBtn.style.display = 'none'; } } /** Minimal ID3v2 parser: TIT2/TPE1 + APIC cover. */ async function readId3(url) { const res = await fetch(url, { headers: { range: 'bytes=0-262143' }, credentials: 'same-origin' }); const buf = new Uint8Array(await res.arrayBuffer()); if (buf[0] !== 0x49 || buf[1] !== 0x44 || buf[2] !== 0x33) return null; // "ID3" const synch = (o) => (buf[o] << 21) | (buf[o + 1] << 14) | (buf[o + 2] << 7) | buf[o + 3]; const tagSize = Math.min(synch(6) + 10, buf.length); const td = new TextDecoder('utf-8'); const tl = new TextDecoder('latin1'); const out = {}; let off = 10; while (off + 10 < tagSize) { const id = tl.decode(buf.slice(off, off + 4)); if (!/^[A-Z0-9]{4}$/.test(id)) break; const size = buf[10] >= 4 ? synch(off + 4) : (buf[off + 4] << 24) | (buf[off + 5] << 16) | (buf[off + 6] << 8) | buf[off + 7]; const body = buf.slice(off + 10, off + 10 + size); if ((id === 'TIT2' || id === 'TPE1') && body.length > 1) { const enc = body[0]; const text = enc === 1 || enc === 2 ? new TextDecoder('utf-16').decode(body.slice(1)) : (enc === 3 ? td : tl).decode(body.slice(1)); out[id === 'TIT2' ? 'title' : 'artist'] = text.replace(/\0+$/, '').replace(/^\uFEFF/, ''); } if (id === 'APIC' && body.length > 10) { let p = 1; while (p < body.length && body[p] !== 0) p += 1; // mime const mimeStr = tl.decode(body.slice(1, p)); p += 2; // skip null + picture type while (p < body.length && body[p] !== 0) p += 1; // description p += 1; if (p < body.length) { out.cover = URL.createObjectURL(new Blob([body.slice(p)], { type: mimeStr || 'image/jpeg' })); } } off += 10 + size; } return out; } // ── PDF (pdf.js) ───────────────────────────────────────────────────── async function renderPdf(stage, desc, urls, srcOverride, note) { const pdfjs = await import('/vendor/pdfjs/pdf.min.mjs'); pdfjs.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.mjs'; const rail = h('div.pdf-rail'); const main = h('div.pdf-main'); const pageInput = h('input', { type: 'number', min: 1, value: 1, style: { width: '58px', textAlign: 'center' } }); const searchInput = h('input', { type: 'search', placeholder: 'Search in document…', style: { width: '190px' } }); const searchInfo = h('span.muted'); let zoom = 1.2; const strip = h('div.pv-toolstrip', {}, note ? h('span.chip', {}, note) : null, pageInput, h('span.muted', { id: 'pageCount' }), h('button.btn.icon.ghost', { html: UI.zoomOut, title: 'Zoom out', onclick: () => rescale(zoom * 0.85) }), h('button.btn.icon.ghost', { html: UI.zoomIn, title: 'Zoom in', onclick: () => rescale(zoom * 1.2) }), searchInput, searchInfo, h('button.btn.icon.ghost', { title: 'Print', onclick: () => window.print() }, '🖨'), ); const viewer = h('div.pv-pdf', {}, rail, h('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 } }, strip, main)); stage.append(viewer); const doc = await pdfjs.getDocument({ url: srcOverride ?? urls.stream, withCredentials: true }).promise; strip.querySelector('#pageCount').textContent = `/ ${doc.numPages}`; pageInput.max = doc.numPages; const pages = []; for (let i = 1; i <= doc.numPages; i += 1) { const holder = h('div.pdf-page', { dataset: { page: i } }); main.append(holder); pages.push({ holder, rendered: false, i }); } const renderPage = async (entry, scale = zoom) => { const page = await doc.getPage(entry.i); const viewport = page.getViewport({ scale: scale * (window.devicePixelRatio > 1 ? 1.5 : 1) }); const canvas = h('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = `${viewport.width / (window.devicePixelRatio > 1 ? 1.5 : 1)}px`; await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; const text = await page.getTextContent(); const textLayer = h('div.textLayer', { style: { width: canvas.style.width, height: `${parseFloat(canvas.style.width) * (viewport.height / viewport.width)}px` } }); // Lightweight text layer for selection + search highlighting. const cssScale = parseFloat(canvas.style.width) / page.getViewport({ scale: 1 }).width; for (const item of text.items) { if (!item.str) continue; const [a, b, , , e, f] = item.transform; const span = h('span', { style: { left: `${e * cssScale}px`, bottom: `${f * cssScale}px`, fontSize: `${Math.hypot(a, b) * cssScale}px`, fontFamily: 'sans-serif', }, }, item.str); span.dataset.txt = item.str.toLowerCase(); textLayer.append(span); } entry.holder.innerHTML = ''; entry.holder.append(canvas, textLayer); entry.rendered = true; }; // Lazy render on scroll. const io = new IntersectionObserver((entries) => { for (const it of entries) { if (!it.isIntersecting) continue; const entry = pages[Number(it.target.dataset.page) - 1]; if (!entry.rendered) renderPage(entry); pageInput.value = entry.i; rail.querySelectorAll('.cur').forEach((n) => n.classList.remove('cur')); rail.querySelector(`[data-rp="${entry.i}"]`)?.classList.add('cur'); } }, { root: main, threshold: 0.15 }); pages.forEach((p) => io.observe(p.holder)); renderPage(pages[0]); // Thumbnail rail. (async () => { for (let i = 1; i <= Math.min(doc.numPages, 60); i += 1) { const page = await doc.getPage(i); const vp = page.getViewport({ scale: 110 / page.getViewport({ scale: 1 }).width }); const canvas = h('canvas'); canvas.width = vp.width; canvas.height = vp.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; const cell = h('div', { dataset: { rp: i }, onclick: () => pages[i - 1].holder.scrollIntoView() }, canvas, h('div.pn', {}, String(i))); rail.append(cell); } })(); const rescale = (z) => { zoom = Math.min(Math.max(z, 0.4), 4); pages.forEach((p) => { p.rendered = false; p.holder.innerHTML = ''; }); const visible = Number(pageInput.value) - 1; renderPage(pages[visible]); }; pageInput.addEventListener('change', () => { const target = pages[Math.min(Math.max(Number(pageInput.value), 1), doc.numPages) - 1]; target.holder.scrollIntoView(); }); searchInput.addEventListener('keydown', async (e) => { if (e.key !== 'Enter') return; const q = searchInput.value.trim().toLowerCase(); main.querySelectorAll('.hl').forEach((n) => n.classList.remove('hl')); if (!q) { searchInfo.textContent = ''; return; } let hits = 0; let first = null; for (const entry of pages) { if (!entry.rendered) { const page = await doc.getPage(entry.i); const text = await page.getTextContent(); if (!text.items.some((it) => it.str?.toLowerCase().includes(q))) continue; await renderPage(entry); } entry.holder.querySelectorAll('.textLayer span').forEach((span) => { if (span.dataset.txt?.includes(q)) { span.classList.add('hl'); hits += 1; first ??= span; } }); } searchInfo.textContent = hits ? `${hits} match${hits > 1 ? 'es' : ''}` : 'No matches'; first?.scrollIntoView({ block: 'center' }); }); } // ── Office → PDF ───────────────────────────────────────────────────── async function renderOffice(stage, desc, urls) { if (desc.office === 'unsupported') { RENDERERS.fallback(stage, { ...desc, note: 'Office preview needs LibreOffice on the server.' }, urls); return; } const isSheet = /^(xlsx|xls|ods|csv)$/.test(desc.ext); if (desc.office === 'ready') { await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original'); if (isSheet) addSheetToggle(stage, desc, urls); return; } if (isSheet) { // Fast path: render the workbook natively right away. await renderSheet(stage, desc, urls); return; } stage.append(processing('Converting document…')); const poll = async () => { try { const res = await fetch(`${desc.base}/pdf`, { method: 'HEAD', credentials: 'same-origin' }); if (res.ok) { stage.innerHTML = ''; await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original'); return; } if (res.status === 422) { stage.innerHTML = ''; RENDERERS.fallback(stage, { ...desc, note: 'Conversion unavailable on this server.' }, urls); return; } } catch {} if (stage.isConnected) setTimeout(poll, 3000); }; setTimeout(poll, 2500); } function addSheetToggle(stage, desc, urls) { const strip = stage.querySelector('.pv-toolstrip'); strip?.append(h('button.btn', { onclick: async () => { stage.innerHTML = ''; await renderSheet(stage, desc, urls); }, }, 'Table view')); } async function renderSheet(stage, desc, urls) { await loadScript('/vendor/xlsx/xlsx.full.min.js'); const buf = await (await fetch(urls.stream.replace('/stream/', '/dl/').includes('/dl/') ? urls.dl ?? urls.stream : urls.stream, { credentials: 'same-origin' })).arrayBuffer(); const wb = globalThis.XLSX.read(buf, { type: 'array' }); const { el, scroll } = panel(); const strip = h('div.pv-toolstrip'); el.prepend(strip); const show = (name) => { scroll.innerHTML = ''; const html = globalThis.XLSX.utils.sheet_to_html(wb.Sheets[name], { header: '', footer: '' }); const wrap = h('div.csv-wrap', { html }); wrap.querySelector('table')?.classList.add('csv-table'); scroll.append(wrap); strip.querySelectorAll('.chip').forEach((c) => c.classList.toggle('active', c.textContent === name)); }; for (const name of wb.SheetNames) { strip.append(h('button.chip', { onclick: () => show(name) }, name)); } stage.append(el); show(wb.SheetNames[0]); } const loadedScripts = new Set(); function loadScript(src) { if (loadedScripts.has(src)) return Promise.resolve(); return new Promise((resolve, reject) => { const s = h('script', { src }); s.onload = () => { loadedScripts.add(src); resolve(); }; s.onerror = reject; document.head.append(s); }); } // ── Code / text ────────────────────────────────────────────────────── async function renderCode(stage, desc, urls) { const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' }); if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; } const { html, lang, clipped, raw } = await res.json(); const { el, scroll } = panel(); const code = h('div.pv-code.linenums', { html }); scroll.append(code); const strip = h('div.pv-toolstrip', {}, h('span.chip', {}, lang), clipped ? h('span.chip', {}, 'truncated preview') : null, h('button.btn', { onclick: (e) => { code.classList.toggle('wrap'); e.currentTarget.classList.toggle('primary'); }, }, 'Wrap'), raw !== null ? h('button.btn', { onclick: () => copyText(raw, 'Source copied') }, 'Copy') : null, ); el.prepend(strip); stage.append(el); } // ── Markdown ───────────────────────────────────────────────────────── async function renderMarkdownFile(stage, desc, urls) { const res = await fetch(`${desc.base}/markdown`, { credentials: 'same-origin' }); if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; } const { html, raw } = await res.json(); const { el, scroll } = panel(); const rendered = h('div.md-body', { html }); const source = h('pre.pv-code', { style: { display: 'none', font: '12.5px/1.6 var(--font-mono)', whiteSpace: 'pre-wrap', margin: 0 } }, raw); scroll.append(rendered, source); el.prepend(h('div.pv-toolstrip', {}, h('button.btn.primary', { onclick: (e) => { const showSrc = source.style.display === 'none'; source.style.display = showSrc ? '' : 'none'; rendered.style.display = showSrc ? 'none' : ''; e.currentTarget.textContent = showSrc ? 'Rendered' : 'Source'; }, }, 'Source'), )); stage.append(el); } // ── CSV / TSV ──────────────────────────────────────────────────────── function parseCsv(text, delim) { const rows = []; let row = []; let cell = ''; let quoted = false; for (let i = 0; i < text.length; i += 1) { const ch = text[i]; if (quoted) { if (ch === '"') { if (text[i + 1] === '"') { cell += '"'; i += 1; } else quoted = false; } else cell += ch; } else if (ch === '"') quoted = true; else if (ch === delim) { row.push(cell); cell = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && text[i + 1] === '\n') i += 1; row.push(cell); cell = ''; if (row.length > 1 || row[0] !== '') rows.push(row); row = []; } else cell += ch; } if (cell !== '' || row.length) { row.push(cell); rows.push(row); } return rows; } async function renderCsv(stage, desc, urls) { const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' }); if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; } const text = await res.text(); const firstLine = text.slice(0, text.indexOf('\n')); const delim = desc.ext === 'tsv' ? '\t' : [',', ';', '\t', '|'].reduce((best, d) => firstLine.split(d).length > firstLine.split(best).length ? d : best, ','); const all = parseCsv(text, delim); const header = all[0] ?? []; let rows = all.slice(1); const original = rows; const { el, scroll } = panel(); const wrap = h('div.csv-wrap'); scroll.style.padding = '0'; scroll.append(wrap); const CHUNK = 300; let shown = 0; let tbody; const table = h('table.csv-table'); const renderHead = () => { const tr = h('tr'); header.forEach((name, ci) => { let dir = 1; tr.append(h('th', { title: 'Click to sort', onclick: () => { rows = [...rows].sort((a, b) => { const x = a[ci] ?? ''; const y = b[ci] ?? ''; const nx = parseFloat(x); const ny = parseFloat(y); const cmp = !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y); return cmp * dir; }); dir *= -1; reset(); }, }, name)); }); table.append(h('thead', {}, tr)); }; const appendChunk = () => { const frag = document.createDocumentFragment(); for (const row of rows.slice(shown, shown + CHUNK)) { const tr = h('tr'); for (let c = 0; c < header.length; c += 1) tr.append(h('td', {}, row[c] ?? '')); frag.append(tr); } shown = Math.min(shown + CHUNK, rows.length); tbody.append(frag); counter.textContent = `${rows.length.toLocaleString()} rows · delimiter "${delim === '\t' ? '\\t' : delim}"`; }; const reset = () => { table.innerHTML = ''; renderHead(); tbody = h('tbody'); table.append(tbody); shown = 0; appendChunk(); }; wrap.append(table); wrap.addEventListener('scroll', () => { if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - 600 && shown < rows.length) appendChunk(); }); const counter = h('span.muted'); const search = h('input', { type: 'search', placeholder: 'Filter cells…', style: { width: '190px' }, oninput: () => { const q = search.value.toLowerCase(); rows = q ? original.filter((r) => r.some((c) => c?.toLowerCase().includes(q))) : original; reset(); }, }); el.prepend(h('div.pv-toolstrip', {}, search, counter)); stage.append(el); reset(); } // ── Structured (json/yaml/toml/xml) ────────────────────────────────── function jsonTree(value, key) { const label = key !== undefined ? h('span.json-key', {}, `${JSON.stringify(key)}: `) : ''; if (value === null) return h('div', {}, label, h('span.json-null', {}, 'null')); if (typeof value === 'string') return h('div', {}, label, h('span.json-str', {}, JSON.stringify(value))); if (typeof value === 'number') return h('div', {}, label, h('span.json-num', {}, String(value))); if (typeof value === 'boolean') return h('div', {}, label, h('span.json-bool', {}, String(value))); const isArr = Array.isArray(value); const entries = isArr ? value.map((v, i) => [i, v]) : Object.entries(value); const det = h('details', { open: entries.length <= 24 }, h('summary', {}, label, h('span.muted', {}, isArr ? `Array(${entries.length})` : `Object {${entries.length}}`))); for (const [k, v] of entries.slice(0, 2000)) det.append(jsonTree(v, isArr ? undefined : k)); if (entries.length > 2000) det.append(h('div.muted', {}, `… ${entries.length - 2000} more`)); return det; } async function renderStructured(stage, desc, urls) { if (desc.ext === 'json') { try { const text = await (await fetch(`${desc.base}/raw`, { credentials: 'same-origin' })).text(); const value = JSON.parse(text); const { el, scroll } = panel(); const pretty = h('div.pv-code.linenums', { style: { display: 'none' } }); const tree = h('div.json-tree', {}, jsonTree(value)); scroll.append(tree, pretty); let prettyLoaded = false; el.prepend(h('div.pv-toolstrip', {}, h('button.btn.primary', { onclick: async (e) => { const showPretty = pretty.style.display === 'none'; if (showPretty && !prettyLoaded) { const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' }); pretty.innerHTML = (await res.json()).html; prettyLoaded = true; } pretty.style.display = showPretty ? '' : 'none'; tree.style.display = showPretty ? 'none' : ''; e.currentTarget.textContent = showPretty ? 'Tree' : 'Source'; }, }, 'Source'), h('button.btn', { onclick: () => copyText(text, 'JSON copied') }, 'Copy'), )); stage.append(el); return; } catch { /* fall through to highlighted source */ } } await renderCode(stage, desc, urls); } // ── Notebook (ipynb) ───────────────────────────────────────────────── function miniMd(text) { const escd = text.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); return escd .replace(/^###### (.*)$/gm, '
$1')
.replace(/\n\n/g, '