spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/web/assets/js/viewer.js8 * Purpose : Universal preview renderers + full-screen viewer overlay9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { h, fmtSize, fmtDuration, copyText } from './ui.js';14import { fileIcon, UI } from './icons.js';1516/**17 * Render a preview into `stage` for a descriptor from GET …/preview.18 * @param {HTMLElement} stage19 * @param {object} desc preview descriptor (strategy, base, …)20 * @param {{stream: string, dl: string|null}} urls media URLs for this node21 */22export async function renderPreview(stage, desc, urls) {23 stage.innerHTML = '';24 const render = RENDERERS[desc.strategy] ?? RENDERERS.fallback;25 try {26 await render(stage, desc, urls);27 } catch (err) {28 console.error('preview failed', err);29 RENDERERS.fallback(stage, desc, urls);30 }31}3233const processing = (label) =>34 h('div.pv-processing', {},35 h('div', {}, label),36 h('div.bar', {}, h('i')));3738const panel = (...kids) => {39 const scroll = h('div.pv-scroll');40 const el = h('div.pv-panel', {}, ...kids, scroll);41 return { el, scroll };42};4344// ── Images ───────────────────────────────────────────────────────────45async function renderImage(stage, desc, urls, srcOverride) {46 const img = h('img', { src: srcOverride ?? urls.stream, alt: desc.name, draggable: false });47 const wrap = h('div.pv-img-wrap', {}, img);48 let scale = 1; let rot = 0; let tx = 0; let ty = 0; let fit = true;49 const apply = () => {50 img.style.transform = `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(${scale})`;51 zoomLabel.textContent = fit ? 'Fit' : `${Math.round(scale * 100)}%`;52 };53 const setZoom = (z, keepFit = false) => { scale = Math.min(Math.max(z, 0.1), 12); fit = keepFit; apply(); };5455 wrap.addEventListener('wheel', (e) => {56 e.preventDefault();57 setZoom(scale * (e.deltaY < 0 ? 1.15 : 0.87));58 }, { passive: false });5960 let drag = null;61 wrap.addEventListener('pointerdown', (e) => {62 drag = { x: e.clientX - tx, y: e.clientY - ty };63 wrap.setPointerCapture(e.pointerId);64 wrap.style.cursor = 'grabbing';65 });66 wrap.addEventListener('pointermove', (e) => {67 if (!drag) return;68 tx = e.clientX - drag.x; ty = e.clientY - drag.y; apply();69 });70 wrap.addEventListener('pointerup', () => { drag = null; wrap.style.cursor = 'grab'; });71 img.addEventListener('dblclick', () => { tx = 0; ty = 0; setZoom(scale === 1 ? 2 : 1, scale !== 1); });7273 const zoomLabel = h('span.zoom-label', {}, 'Fit');74 const bar = h('div.pv-toolbar', {},75 h('button.btn', { title: 'Zoom out', html: UI.zoomOut, onclick: () => setZoom(scale * 0.8) }),76 zoomLabel,77 h('button.btn', { title: 'Zoom in', html: UI.zoomIn, onclick: () => setZoom(scale * 1.25) }),78 h('button.btn', { title: '1:1', onclick: () => { tx = 0; ty = 0; setZoom(1); } }, '1:1'),79 h('button.btn', { title: 'Rotate', html: UI.rotate, onclick: () => { rot = (rot + 90) % 360; apply(); } }),80 h('button.btn', { title: 'Image info', html: UI.info, onclick: () => showExif(desc) }),81 );82 stage.append(wrap, bar);83}8485async function showExif(desc) {86 const { modal } = await import('./ui.js');87 let data = {};88 try { data = await (await fetch(`${desc.base}/exif`, { credentials: 'same-origin' })).json(); } catch {}89 const dl = h('dl.kv');90 const add = (k, v) => { if (v !== null && v !== undefined && v !== '') { dl.append(h('dt', {}, k), h('dd', {}, String(v))); } };91 add('Dimensions', data.width ? `${data.width} × ${data.height}` : null);92 add('Format', data.format);93 add('Camera', data.camera);94 add('Lens', data.lens);95 add('ISO', data.iso);96 add('Exposure', data.exposure ? `1/${Math.round(1 / data.exposure)}s` : null);97 add('Aperture', data.fnumber ? `ƒ/${data.fnumber}` : null);98 add('Focal length', data.focal ? `${data.focal} mm` : null);99 add('Taken', data.taken ? new Date(data.taken).toLocaleString() : null);100 const body = h('div', {}, dl.children.length ? dl : h('p.muted', {}, 'No metadata available.'));101 if (data.gps) {102 body.append(h('a.btn', {103 href: `https://www.openstreetmap.org/?mlat=${data.gps.lat}&mlon=${data.gps.lon}#map=15/${data.gps.lat}/${data.gps.lon}`,104 target: '_blank', rel: 'noopener',105 }, 'Open map location'));106 }107 modal({ title: 'Image details', body, actions: [{ label: 'Close', primary: true, onClick: () => {} }] });108}109110// ── Video ────────────────────────────────────────────────────────────111async function renderVideo(stage, desc, urls) {112 const attach = (src) => {113 const video = h('video.pv-video', {114 src, controls: true, autoplay: true, playsinline: true,115 });116 // Keyboard: space handled natively when focused; add speed + pip controls.117 const bar = h('div.pv-toolbar', {},118 ...[0.5, 1, 1.5, 2].map((rate) =>119 h('button.btn', { onclick: () => { video.playbackRate = rate; } }, `${rate}×`)),120 'pictureInPictureEnabled' in document121 ? h('button.btn', { title: 'Picture in picture', onclick: () => video.requestPictureInPicture().catch(() => {}) }, 'PiP')122 : null,123 h('button.btn', { title: 'Fullscreen', html: UI.expand, onclick: () => video.requestFullscreen?.() }),124 );125 stage.append(video, bar);126 video.focus();127 };128129 if (desc.webSafe) { attach(urls.stream); return; }130131 stage.append(processing('Optimizing for playback…'));132 const poll = async () => {133 try {134 const res = await fetch(`${desc.base}/video`, { credentials: 'same-origin' });135 const { state } = await res.json();136 if (state === 'ready') { stage.innerHTML = ''; attach(`${desc.base}/video/file`); return; }137 if (state === 'unavailable') {138 stage.innerHTML = '';139 RENDERERS.fallback(stage, { ...desc, note: 'This codec can\'t be optimized on the server.' }, urls);140 return;141 }142 } catch { /* keep polling */ }143 if (stage.isConnected) setTimeout(poll, 2500);144 };145 poll();146}147148// ── Audio (waveform + ID3) ───────────────────────────────────────────149async function renderAudio(stage, desc, urls) {150 const art = h('div.art', { html: fileIcon('audio') });151 const title = h('div.t', {}, desc.name);152 const artist = h('div.a', {}, desc.probe?.duration ? fmtDuration(desc.probe.duration) : '');153 const wave = h('div', { id: 'waveform' });154 const playBtn = h('button.btn.primary', {}, '▶ Play');155 const time = h('span.time', {}, '0:00');156 const box = h('div.pv-audio', {},157 h('div.top', {}, art, h('div.tt', {}, title, artist)),158 wave,159 h('div.audio-controls', {},160 playBtn,161 ...[1, 1.5, 2].map((r) => h('button.btn', { onclick: () => ws?.setPlaybackRate(r) }, `${r}×`)),162 h('button.btn', { id: 'loopBtn', onclick: (e) => { loop = !loop; e.currentTarget.classList.toggle('primary', loop); } }, 'Loop'),163 time),164 );165 stage.append(box);166167 // Best-effort ID3v2 read for title/artist/cover (first 256 KB).168 readId3(urls.stream).then((tags) => {169 if (tags?.title) title.textContent = tags.title;170 if (tags?.artist) artist.textContent = tags.artist + (artist.textContent ? ` · ${artist.textContent}` : '');171 if (tags?.cover) { art.innerHTML = ''; art.append(h('img', { src: tags.cover })); }172 }).catch(() => {});173174 let ws = null; let loop = false;175 try {176 const { default: WaveSurfer } = await import('/vendor/wavesurfer/wavesurfer.esm.js');177 let peaks = null;178 try {179 const res = await fetch(`${desc.base}/peaks`, { credentials: 'same-origin' });180 peaks = (await res.json()).peaks;181 if (!peaks?.length) peaks = null;182 } catch {}183 ws = WaveSurfer.create({184 container: wave,185 url: urls.stream,186 peaks: peaks ? [peaks] : undefined,187 duration: peaks ? desc.probe?.duration : undefined,188 height: 72,189 waveColor: 'rgba(139, 147, 163, 0.55)',190 progressColor: '#4f8cff',191 cursorColor: '#22d3aa',192 barWidth: 2, barGap: 1, barRadius: 2,193 });194 ws.on('timeupdate', (t) => { time.textContent = `${fmtDuration(t)} / ${fmtDuration(ws.getDuration())}`; });195 ws.on('finish', () => { if (loop) { ws.seekTo(0); ws.play(); } else playBtn.textContent = '▶ Play'; });196 playBtn.onclick = () => {197 ws.playPause();198 playBtn.textContent = ws.isPlaying() ? '⏸ Pause' : '▶ Play';199 };200 } catch {201 // Wavesurfer unavailable → native audio element.202 wave.replaceWith(h('audio', { src: urls.stream, controls: true, style: { width: '100%' } }));203 playBtn.style.display = 'none';204 }205}206207/** Minimal ID3v2 parser: TIT2/TPE1 + APIC cover. */208async function readId3(url) {209 const res = await fetch(url, { headers: { range: 'bytes=0-262143' }, credentials: 'same-origin' });210 const buf = new Uint8Array(await res.arrayBuffer());211 if (buf[0] !== 0x49 || buf[1] !== 0x44 || buf[2] !== 0x33) return null; // "ID3"212 const synch = (o) => (buf[o] << 21) | (buf[o + 1] << 14) | (buf[o + 2] << 7) | buf[o + 3];213 const tagSize = Math.min(synch(6) + 10, buf.length);214 const td = new TextDecoder('utf-8'); const tl = new TextDecoder('latin1');215 const out = {};216 let off = 10;217 while (off + 10 < tagSize) {218 const id = tl.decode(buf.slice(off, off + 4));219 if (!/^[A-Z0-9]{4}$/.test(id)) break;220 const size = buf[10] >= 4 ? synch(off + 4)221 : (buf[off + 4] << 24) | (buf[off + 5] << 16) | (buf[off + 6] << 8) | buf[off + 7];222 const body = buf.slice(off + 10, off + 10 + size);223 if ((id === 'TIT2' || id === 'TPE1') && body.length > 1) {224 const enc = body[0];225 const text = enc === 1 || enc === 2226 ? new TextDecoder('utf-16').decode(body.slice(1))227 : (enc === 3 ? td : tl).decode(body.slice(1));228 out[id === 'TIT2' ? 'title' : 'artist'] = text.replace(/\0+$/, '').replace(/^\uFEFF/, '');229 }230 if (id === 'APIC' && body.length > 10) {231 let p = 1;232 while (p < body.length && body[p] !== 0) p += 1; // mime233 const mimeStr = tl.decode(body.slice(1, p));234 p += 2; // skip null + picture type235 while (p < body.length && body[p] !== 0) p += 1; // description236 p += 1;237 if (p < body.length) {238 out.cover = URL.createObjectURL(new Blob([body.slice(p)], { type: mimeStr || 'image/jpeg' }));239 }240 }241 off += 10 + size;242 }243 return out;244}245246// ── PDF (pdf.js) ─────────────────────────────────────────────────────247async function renderPdf(stage, desc, urls, srcOverride, note) {248 const pdfjs = await import('/vendor/pdfjs/pdf.min.mjs');249 pdfjs.GlobalWorkerOptions.workerSrc = '/vendor/pdfjs/pdf.worker.min.mjs';250251 const rail = h('div.pdf-rail');252 const main = h('div.pdf-main');253 const pageInput = h('input', { type: 'number', min: 1, value: 1, style: { width: '58px', textAlign: 'center' } });254 const searchInput = h('input', { type: 'search', placeholder: 'Search in document…', style: { width: '190px' } });255 const searchInfo = h('span.muted');256 let zoom = 1.2;257 const strip = h('div.pv-toolstrip', {},258 note ? h('span.chip', {}, note) : null,259 pageInput, h('span.muted', { id: 'pageCount' }),260 h('button.btn.icon.ghost', { html: UI.zoomOut, title: 'Zoom out', onclick: () => rescale(zoom * 0.85) }),261 h('button.btn.icon.ghost', { html: UI.zoomIn, title: 'Zoom in', onclick: () => rescale(zoom * 1.2) }),262 searchInput, searchInfo,263 h('button.btn.icon.ghost', { title: 'Print', onclick: () => window.print() }, '🖨'),264 );265 const viewer = h('div.pv-pdf', {}, rail, h('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 } }, strip, main));266 stage.append(viewer);267268 const doc = await pdfjs.getDocument({ url: srcOverride ?? urls.stream, withCredentials: true }).promise;269 strip.querySelector('#pageCount').textContent = `/ ${doc.numPages}`;270 pageInput.max = doc.numPages;271272 const pages = [];273 for (let i = 1; i <= doc.numPages; i += 1) {274 const holder = h('div.pdf-page', { dataset: { page: i } });275 main.append(holder);276 pages.push({ holder, rendered: false, i });277 }278279 const renderPage = async (entry, scale = zoom) => {280 const page = await doc.getPage(entry.i);281 const viewport = page.getViewport({ scale: scale * (window.devicePixelRatio > 1 ? 1.5 : 1) });282 const canvas = h('canvas');283 canvas.width = viewport.width; canvas.height = viewport.height;284 canvas.style.width = `${viewport.width / (window.devicePixelRatio > 1 ? 1.5 : 1)}px`;285 await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;286 const text = await page.getTextContent();287 const textLayer = h('div.textLayer', { style: { width: canvas.style.width, height: `${parseFloat(canvas.style.width) * (viewport.height / viewport.width)}px` } });288 // Lightweight text layer for selection + search highlighting.289 const cssScale = parseFloat(canvas.style.width) / page.getViewport({ scale: 1 }).width;290 for (const item of text.items) {291 if (!item.str) continue;292 const [a, b, , , e, f] = item.transform;293 const span = h('span', {294 style: {295 left: `${e * cssScale}px`,296 bottom: `${f * cssScale}px`,297 fontSize: `${Math.hypot(a, b) * cssScale}px`,298 fontFamily: 'sans-serif',299 },300 }, item.str);301 span.dataset.txt = item.str.toLowerCase();302 textLayer.append(span);303 }304 entry.holder.innerHTML = '';305 entry.holder.append(canvas, textLayer);306 entry.rendered = true;307 };308309 // Lazy render on scroll.310 const io = new IntersectionObserver((entries) => {311 for (const it of entries) {312 if (!it.isIntersecting) continue;313 const entry = pages[Number(it.target.dataset.page) - 1];314 if (!entry.rendered) renderPage(entry);315 pageInput.value = entry.i;316 rail.querySelectorAll('.cur').forEach((n) => n.classList.remove('cur'));317 rail.querySelector(`[data-rp="${entry.i}"]`)?.classList.add('cur');318 }319 }, { root: main, threshold: 0.15 });320 pages.forEach((p) => io.observe(p.holder));321 renderPage(pages[0]);322323 // Thumbnail rail.324 (async () => {325 for (let i = 1; i <= Math.min(doc.numPages, 60); i += 1) {326 const page = await doc.getPage(i);327 const vp = page.getViewport({ scale: 110 / page.getViewport({ scale: 1 }).width });328 const canvas = h('canvas');329 canvas.width = vp.width; canvas.height = vp.height;330 await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;331 const cell = h('div', { dataset: { rp: i }, onclick: () => pages[i - 1].holder.scrollIntoView() },332 canvas, h('div.pn', {}, String(i)));333 rail.append(cell);334 }335 })();336337 const rescale = (z) => {338 zoom = Math.min(Math.max(z, 0.4), 4);339 pages.forEach((p) => { p.rendered = false; p.holder.innerHTML = ''; });340 const visible = Number(pageInput.value) - 1;341 renderPage(pages[visible]);342 };343344 pageInput.addEventListener('change', () => {345 const target = pages[Math.min(Math.max(Number(pageInput.value), 1), doc.numPages) - 1];346 target.holder.scrollIntoView();347 });348349 searchInput.addEventListener('keydown', async (e) => {350 if (e.key !== 'Enter') return;351 const q = searchInput.value.trim().toLowerCase();352 main.querySelectorAll('.hl').forEach((n) => n.classList.remove('hl'));353 if (!q) { searchInfo.textContent = ''; return; }354 let hits = 0; let first = null;355 for (const entry of pages) {356 if (!entry.rendered) {357 const page = await doc.getPage(entry.i);358 const text = await page.getTextContent();359 if (!text.items.some((it) => it.str?.toLowerCase().includes(q))) continue;360 await renderPage(entry);361 }362 entry.holder.querySelectorAll('.textLayer span').forEach((span) => {363 if (span.dataset.txt?.includes(q)) {364 span.classList.add('hl');365 hits += 1;366 first ??= span;367 }368 });369 }370 searchInfo.textContent = hits ? `${hits} match${hits > 1 ? 'es' : ''}` : 'No matches';371 first?.scrollIntoView({ block: 'center' });372 });373}374375// ── Office → PDF ─────────────────────────────────────────────────────376async function renderOffice(stage, desc, urls) {377 if (desc.office === 'unsupported') {378 RENDERERS.fallback(stage, { ...desc, note: 'Office preview needs LibreOffice on the server.' }, urls);379 return;380 }381 const isSheet = /^(xlsx|xls|ods|csv)$/.test(desc.ext);382 if (desc.office === 'ready') {383 await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original');384 if (isSheet) addSheetToggle(stage, desc, urls);385 return;386 }387 if (isSheet) {388 // Fast path: render the workbook natively right away.389 await renderSheet(stage, desc, urls);390 return;391 }392 stage.append(processing('Converting document…'));393 const poll = async () => {394 try {395 const res = await fetch(`${desc.base}/pdf`, { method: 'HEAD', credentials: 'same-origin' });396 if (res.ok) {397 stage.innerHTML = '';398 await renderPdf(stage, desc, urls, `${desc.base}/pdf`, 'Converted preview — download for original');399 return;400 }401 if (res.status === 422) {402 stage.innerHTML = '';403 RENDERERS.fallback(stage, { ...desc, note: 'Conversion unavailable on this server.' }, urls);404 return;405 }406 } catch {}407 if (stage.isConnected) setTimeout(poll, 3000);408 };409 setTimeout(poll, 2500);410}411412function addSheetToggle(stage, desc, urls) {413 const strip = stage.querySelector('.pv-toolstrip');414 strip?.append(h('button.btn', {415 onclick: async () => { stage.innerHTML = ''; await renderSheet(stage, desc, urls); },416 }, 'Table view'));417}418419async function renderSheet(stage, desc, urls) {420 await loadScript('/vendor/xlsx/xlsx.full.min.js');421 const buf = await (await fetch(urls.stream.replace('/stream/', '/dl/').includes('/dl/') ? urls.dl ?? urls.stream : urls.stream, { credentials: 'same-origin' })).arrayBuffer();422 const wb = globalThis.XLSX.read(buf, { type: 'array' });423 const { el, scroll } = panel();424 const strip = h('div.pv-toolstrip');425 el.prepend(strip);426 const show = (name) => {427 scroll.innerHTML = '';428 const html = globalThis.XLSX.utils.sheet_to_html(wb.Sheets[name], { header: '', footer: '' });429 const wrap = h('div.csv-wrap', { html });430 wrap.querySelector('table')?.classList.add('csv-table');431 scroll.append(wrap);432 strip.querySelectorAll('.chip').forEach((c) => c.classList.toggle('active', c.textContent === name));433 };434 for (const name of wb.SheetNames) {435 strip.append(h('button.chip', { onclick: () => show(name) }, name));436 }437 stage.append(el);438 show(wb.SheetNames[0]);439}440441const loadedScripts = new Set();442function loadScript(src) {443 if (loadedScripts.has(src)) return Promise.resolve();444 return new Promise((resolve, reject) => {445 const s = h('script', { src });446 s.onload = () => { loadedScripts.add(src); resolve(); };447 s.onerror = reject;448 document.head.append(s);449 });450}451452// ── Code / text ──────────────────────────────────────────────────────453async function renderCode(stage, desc, urls) {454 const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' });455 if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }456 const { html, lang, clipped, raw } = await res.json();457 const { el, scroll } = panel();458 const code = h('div.pv-code.linenums', { html });459 scroll.append(code);460 const strip = h('div.pv-toolstrip', {},461 h('span.chip', {}, lang),462 clipped ? h('span.chip', {}, 'truncated preview') : null,463 h('button.btn', {464 onclick: (e) => {465 code.classList.toggle('wrap');466 e.currentTarget.classList.toggle('primary');467 },468 }, 'Wrap'),469 raw !== null ? h('button.btn', { onclick: () => copyText(raw, 'Source copied') }, 'Copy') : null,470 );471 el.prepend(strip);472 stage.append(el);473}474475// ── Markdown ─────────────────────────────────────────────────────────476async function renderMarkdownFile(stage, desc, urls) {477 const res = await fetch(`${desc.base}/markdown`, { credentials: 'same-origin' });478 if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }479 const { html, raw } = await res.json();480 const { el, scroll } = panel();481 const rendered = h('div.md-body', { html });482 const source = h('pre.pv-code', { style: { display: 'none', font: '12.5px/1.6 var(--font-mono)', whiteSpace: 'pre-wrap', margin: 0 } }, raw);483 scroll.append(rendered, source);484 el.prepend(h('div.pv-toolstrip', {},485 h('button.btn.primary', {486 onclick: (e) => {487 const showSrc = source.style.display === 'none';488 source.style.display = showSrc ? '' : 'none';489 rendered.style.display = showSrc ? 'none' : '';490 e.currentTarget.textContent = showSrc ? 'Rendered' : 'Source';491 },492 }, 'Source'),493 ));494 stage.append(el);495}496497// ── CSV / TSV ────────────────────────────────────────────────────────498function parseCsv(text, delim) {499 const rows = [];500 let row = []; let cell = ''; let quoted = false;501 for (let i = 0; i < text.length; i += 1) {502 const ch = text[i];503 if (quoted) {504 if (ch === '"') {505 if (text[i + 1] === '"') { cell += '"'; i += 1; } else quoted = false;506 } else cell += ch;507 } else if (ch === '"') quoted = true;508 else if (ch === delim) { row.push(cell); cell = ''; }509 else if (ch === '\n' || ch === '\r') {510 if (ch === '\r' && text[i + 1] === '\n') i += 1;511 row.push(cell); cell = '';512 if (row.length > 1 || row[0] !== '') rows.push(row);513 row = [];514 } else cell += ch;515 }516 if (cell !== '' || row.length) { row.push(cell); rows.push(row); }517 return rows;518}519520async function renderCsv(stage, desc, urls) {521 const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });522 if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }523 const text = await res.text();524 const firstLine = text.slice(0, text.indexOf('\n'));525 const delim = desc.ext === 'tsv' ? '\t'526 : [',', ';', '\t', '|'].reduce((best, d) =>527 firstLine.split(d).length > firstLine.split(best).length ? d : best, ',');528 const all = parseCsv(text, delim);529 const header = all[0] ?? [];530 let rows = all.slice(1);531 const original = rows;532533 const { el, scroll } = panel();534 const wrap = h('div.csv-wrap');535 scroll.style.padding = '0';536 scroll.append(wrap);537538 const CHUNK = 300;539 let shown = 0;540 let tbody;541 const table = h('table.csv-table');542543 const renderHead = () => {544 const tr = h('tr');545 header.forEach((name, ci) => {546 let dir = 1;547 tr.append(h('th', {548 title: 'Click to sort',549 onclick: () => {550 rows = [...rows].sort((a, b) => {551 const x = a[ci] ?? ''; const y = b[ci] ?? '';552 const nx = parseFloat(x); const ny = parseFloat(y);553 const cmp = !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);554 return cmp * dir;555 });556 dir *= -1;557 reset();558 },559 }, name));560 });561 table.append(h('thead', {}, tr));562 };563564 const appendChunk = () => {565 const frag = document.createDocumentFragment();566 for (const row of rows.slice(shown, shown + CHUNK)) {567 const tr = h('tr');568 for (let c = 0; c < header.length; c += 1) tr.append(h('td', {}, row[c] ?? ''));569 frag.append(tr);570 }571 shown = Math.min(shown + CHUNK, rows.length);572 tbody.append(frag);573 counter.textContent = `${rows.length.toLocaleString()} rows · delimiter "${delim === '\t' ? '\\t' : delim}"`;574 };575576 const reset = () => {577 table.innerHTML = '';578 renderHead();579 tbody = h('tbody');580 table.append(tbody);581 shown = 0;582 appendChunk();583 };584585 wrap.append(table);586 wrap.addEventListener('scroll', () => {587 if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - 600 && shown < rows.length) appendChunk();588 });589590 const counter = h('span.muted');591 const search = h('input', {592 type: 'search', placeholder: 'Filter cells…', style: { width: '190px' },593 oninput: () => {594 const q = search.value.toLowerCase();595 rows = q ? original.filter((r) => r.some((c) => c?.toLowerCase().includes(q))) : original;596 reset();597 },598 });599 el.prepend(h('div.pv-toolstrip', {}, search, counter));600 stage.append(el);601 reset();602}603604// ── Structured (json/yaml/toml/xml) ──────────────────────────────────605function jsonTree(value, key) {606 const label = key !== undefined ? h('span.json-key', {}, `${JSON.stringify(key)}: `) : '';607 if (value === null) return h('div', {}, label, h('span.json-null', {}, 'null'));608 if (typeof value === 'string') return h('div', {}, label, h('span.json-str', {}, JSON.stringify(value)));609 if (typeof value === 'number') return h('div', {}, label, h('span.json-num', {}, String(value)));610 if (typeof value === 'boolean') return h('div', {}, label, h('span.json-bool', {}, String(value)));611 const isArr = Array.isArray(value);612 const entries = isArr ? value.map((v, i) => [i, v]) : Object.entries(value);613 const det = h('details', { open: entries.length <= 24 },614 h('summary', {}, label, h('span.muted', {}, isArr ? `Array(${entries.length})` : `Object {${entries.length}}`)));615 for (const [k, v] of entries.slice(0, 2000)) det.append(jsonTree(v, isArr ? undefined : k));616 if (entries.length > 2000) det.append(h('div.muted', {}, `… ${entries.length - 2000} more`));617 return det;618}619620async function renderStructured(stage, desc, urls) {621 if (desc.ext === 'json') {622 try {623 const text = await (await fetch(`${desc.base}/raw`, { credentials: 'same-origin' })).text();624 const value = JSON.parse(text);625 const { el, scroll } = panel();626 const pretty = h('div.pv-code.linenums', { style: { display: 'none' } });627 const tree = h('div.json-tree', {}, jsonTree(value));628 scroll.append(tree, pretty);629 let prettyLoaded = false;630 el.prepend(h('div.pv-toolstrip', {},631 h('button.btn.primary', {632 onclick: async (e) => {633 const showPretty = pretty.style.display === 'none';634 if (showPretty && !prettyLoaded) {635 const res = await fetch(`${desc.base}/text`, { credentials: 'same-origin' });636 pretty.innerHTML = (await res.json()).html;637 prettyLoaded = true;638 }639 pretty.style.display = showPretty ? '' : 'none';640 tree.style.display = showPretty ? 'none' : '';641 e.currentTarget.textContent = showPretty ? 'Tree' : 'Source';642 },643 }, 'Source'),644 h('button.btn', { onclick: () => copyText(text, 'JSON copied') }, 'Copy'),645 ));646 stage.append(el);647 return;648 } catch { /* fall through to highlighted source */ }649 }650 await renderCode(stage, desc, urls);651}652653// ── Notebook (ipynb) ─────────────────────────────────────────────────654function miniMd(text) {655 const escd = text.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));656 return escd657 .replace(/^###### (.*)$/gm, '<h6>$1</h6>').replace(/^##### (.*)$/gm, '<h5>$1</h5>')658 .replace(/^#### (.*)$/gm, '<h4>$1</h4>').replace(/^### (.*)$/gm, '<h3>$1</h3>')659 .replace(/^## (.*)$/gm, '<h2>$1</h2>').replace(/^# (.*)$/gm, '<h1>$1</h1>')660 .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')661 .replace(/`([^`]+)`/g, '<code>$1</code>')662 .replace(/\n\n/g, '<br><br>');663}664665async function renderNotebook(stage, desc, urls) {666 const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });667 if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }668 let nb;669 try { nb = JSON.parse(await res.text()); } catch { RENDERERS.fallback(stage, desc, urls); return; }670 const { el, scroll } = panel();671 const lang = nb.metadata?.kernelspec?.language ?? 'python';672 el.prepend(h('div.pv-toolstrip', {},673 h('span.chip', {}, `${nb.cells?.length ?? 0} cells`),674 h('span.chip', {}, nb.metadata?.kernelspec?.display_name ?? lang)));675 for (const cell of nb.cells ?? []) {676 const src = Array.isArray(cell.source) ? cell.source.join('') : cell.source ?? '';677 if (cell.cell_type === 'markdown') {678 scroll.append(h('div.md-body', { html: miniMd(src), style: { marginBottom: '14px' } }));679 } else if (cell.cell_type === 'code') {680 scroll.append(h('pre.code-fence', {681 style: {682 background: 'var(--surface-2)', border: '1px solid var(--border)',683 borderRadius: '7px', padding: '11px 14px', overflowX: 'auto',684 font: '12.5px/1.6 var(--font-mono)', margin: '0 0 8px',685 },686 }, src));687 for (const out of cell.outputs ?? []) {688 const imgB64 = out.data?.['image/png'];689 if (imgB64) {690 scroll.append(h('img', {691 src: `data:image/png;base64,${Array.isArray(imgB64) ? imgB64.join('') : imgB64}`,692 style: { maxWidth: '100%', marginBottom: '14px', borderRadius: '7px' },693 }));694 } else {695 const txt = out.text ?? out.data?.['text/plain'];696 if (txt) {697 scroll.append(h('pre', {698 style: { font: '12px/1.5 var(--font-mono)', color: 'var(--muted)', whiteSpace: 'pre-wrap', margin: '0 0 14px', paddingLeft: '12px', borderLeft: '2px solid var(--border)' },699 }, Array.isArray(txt) ? txt.join('') : String(txt)));700 }701 }702 }703 }704 }705 stage.append(el);706}707708// ── Archives ─────────────────────────────────────────────────────────709async function renderArchive(stage, desc, urls) {710 const res = await fetch(`${desc.base}/archive`, { credentials: 'same-origin' });711 if (!res.ok) {712 RENDERERS.fallback(stage, { ...desc, note: 'Listing this archive needs the 7z tool on the server.' }, urls);713 return;714 }715 const { entries, truncated } = await res.json();716 const { el, scroll } = panel();717 el.prepend(h('div.pv-toolstrip', {},718 h('span.chip', {}, `${entries.length}${truncated ? '+' : ''} entries`)));719 const list = h('div.arc-list');720 scroll.append(list);721722 const previewable = (p) => /\.(png|jpe?g|gif|webp|txt|md|json|js|mjs|ts|py|csv|html|css|xml|yml|yaml|log|sh)$/i.test(p);723 for (const entry of entries) {724 if (entry.dir) continue;725 const row = h('div.arc-row', {726 onclick: () => {727 if (!previewable(entry.path)) {728 location.href = `${desc.base}/archive/member?path=${encodeURIComponent(entry.path)}&download=1`;729 return;730 }731 showMember(desc, entry);732 },733 },734 h('span', { html: fileIcon(/\.(png|jpe?g|gif|webp)$/i.test(entry.path) ? 'image' : 'text'), style: { display: 'contents' } }),735 h('span', {}, entry.path),736 h('span.sz', {}, fmtSize(entry.size)));737 list.append(row);738 }739 stage.append(el);740}741742async function showMember(desc, entry) {743 const { modal } = await import('./ui.js');744 const url = `${desc.base}/archive/member?path=${encodeURIComponent(entry.path)}`;745 const body = h('div', { style: { maxHeight: '60vh', overflow: 'auto' } });746 if (/\.(png|jpe?g|gif|webp)$/i.test(entry.path)) {747 body.append(h('img', { src: url, style: { maxWidth: '100%' } }));748 } else {749 const text = await (await fetch(url, { credentials: 'same-origin' })).text();750 body.append(h('pre', { style: { font: '12px/1.55 var(--font-mono)', whiteSpace: 'pre-wrap' } }, text.slice(0, 400_000)));751 }752 modal({753 title: entry.path.split('/').pop(),754 body,755 wide: true,756 actions: [757 { label: 'Download', onClick: () => { location.href = `${url}&download=1`; return false; } },758 { label: 'Close', primary: true, onClick: () => {} },759 ],760 });761}762763// ── Fonts ────────────────────────────────────────────────────────────764async function renderFont(stage, desc, urls) {765 const family = `pv-font-${desc.id}`;766 const face = new FontFace(family, `url(${urls.stream})`);767 await face.load();768 document.fonts.add(face);769 const { el, scroll } = panel();770 const sizeInput = h('input', { type: 'range', min: 10, max: 96, value: 34, style: { width: '180px' } });771 el.prepend(h('div.pv-toolstrip', {}, h('span.chip', {}, desc.ext.toUpperCase()), 'Size', sizeInput));772 const spec = h('div.font-specimen', { style: { fontFamily: `'${family}'` } },773 h('h2', {}, desc.name.replace(/\.[^.]+$/, '')),774 h('div.alpha', {}, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 &@%$#!?'),775 ...[776 'The quick brown fox jumps over the lazy dog.',777 'Portez ce vieux whisky au juge blond qui fume. 0123456789',778 'Sphinx of black quartz, judge my vow — «SPB Drive».',779 ].map((s) => h('p.pangram', { style: { fontSize: '21px' } }, s)));780 sizeInput.addEventListener('input', () => {781 spec.querySelectorAll('.pangram').forEach((p) => { p.style.fontSize = `${sizeInput.value}px`; });782 });783 scroll.append(spec);784 stage.append(el);785}786787// ── Email (eml) ──────────────────────────────────────────────────────788function decodeQP(text) {789 return text.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hx) => String.fromCharCode(parseInt(hx, 16)));790}791792async function renderEmail(stage, desc, urls) {793 const res = await fetch(`${desc.base}/raw`, { credentials: 'same-origin' });794 if (!res.ok) { RENDERERS.fallback(stage, desc, urls); return; }795 const raw = await res.text();796 const headEnd = raw.search(/\r?\n\r?\n/);797 const headText = raw.slice(0, headEnd).replace(/\r?\n[ \t]+/g, ' ');798 const headers = {};799 for (const line of headText.split(/\r?\n/)) {800 const m = line.match(/^([\w-]+):\s*(.*)$/);801 if (m) headers[m[1].toLowerCase()] = m[2];802 }803 let body = raw.slice(headEnd).trim();804 const attachments = [];805 const ctype = headers['content-type'] ?? '';806 const boundary = ctype.match(/boundary="?([^";]+)"?/)?.[1];807 if (boundary) {808 const parts = body.split(`--${boundary}`).slice(1, -1);809 let best = '';810 for (const part of parts) {811 const pEnd = part.search(/\r?\n\r?\n/);812 const pHead = part.slice(0, pEnd).toLowerCase();813 const pBody = part.slice(pEnd).trim();814 const fname = pHead.match(/filename="?([^";\r\n]+)"?/)?.[1];815 if (fname) { attachments.push(fname); continue; }816 if (pHead.includes('text/plain') && !best) {817 best = pHead.includes('quoted-printable') ? decodeQP(pBody)818 : pHead.includes('base64') ? atob(pBody.replace(/\s/g, '')) : pBody;819 }820 }821 body = best || '(no plain-text body)';822 } else if ((headers['content-transfer-encoding'] ?? '').includes('quoted-printable')) {823 body = decodeQP(body);824 }825 const decodeHdr = (s) => (s ?? '').replace(/=\?utf-8\?([qb])\?([^?]+)\?=/gi, (_, enc, val) =>826 enc.toLowerCase() === 'b' ? decodeURIComponent(escape(atob(val))) : decodeQP(val.replace(/_/g, ' ')));827 const { el, scroll } = panel();828 const kv = h('dl.kv');829 for (const key of ['from', 'to', 'cc', 'date', 'subject']) {830 if (headers[key]) kv.append(h('dt', {}, key[0].toUpperCase() + key.slice(1)), h('dd', {}, decodeHdr(headers[key])));831 }832 scroll.append(kv);833 if (attachments.length) {834 scroll.append(h('div.pv-toolstrip', { style: { border: 'none', padding: '0 0 10px' } },835 ...attachments.map((a) => h('span.chip', {}, `📎 ${a}`))));836 }837 scroll.append(h('pre', { style: { font: '13px/1.6 var(--font-sans)', whiteSpace: 'pre-wrap', borderTop: '1px solid var(--border)', paddingTop: '14px' } }, body));838 stage.append(el);839}840841// ── SVG / HEIC ───────────────────────────────────────────────────────842async function renderSvg(stage, desc, urls) {843 await renderImage(stage, desc, urls); // /stream serves it sandboxed inline844}845846async function renderHeic(stage, desc, urls) {847 if (desc.heicSupported) {848 await renderImage(stage, desc, urls, `${desc.base}/heic`);849 return;850 }851 RENDERERS.fallback(stage, { ...desc, note: 'HEIC decoding not available on this server.' }, urls);852}853854// ── Fallback card ────────────────────────────────────────────────────855function renderFallback(stage, desc, urls) {856 stage.append(h('div.fallback-card', {},857 h('div', { html: fileIcon(desc.icon ?? 'file') }),858 h('h3', {}, desc.name),859 h('div.fm', {}, `${fmtSize(desc.size)} · ${desc.mime ?? 'unknown type'}`),860 desc.note ? h('div.fm', { style: { color: 'var(--warning)' } }, desc.note) : null,861 desc.sha ? h('div.sha', {}, `sha256 ${desc.sha}`) : null,862 urls.dl ? h('a.btn.primary', { href: urls.dl, download: '' },863 h('span', { html: UI.download, style: { display: 'contents' } }), 'Download') : null,864 ));865}866867const RENDERERS = {868 image: renderImage,869 svg: renderSvg,870 heic: renderHeic,871 video: renderVideo,872 audio: renderAudio,873 pdf: (s, d, u) => renderPdf(s, d, u),874 office: renderOffice,875 code: renderCode,876 markdown: renderMarkdownFile,877 csv: renderCsv,878 structured: renderStructured,879 notebook: renderNotebook,880 archive: renderArchive,881 font: renderFont,882 email: renderEmail,883 epub: renderFallback,884 model3d: renderFallback,885 fallback: renderFallback,886};887888// ── Full-screen viewer overlay (app + folder shares) ─────────────────889export class Viewer {890 /**891 * @param {object[]} items sibling file list892 * @param {number} index starting position893 * @param {{descUrl(n), streamUrl(n), dlUrl(n), actions?: Array}} opts894 */895 constructor(items, index, opts) {896 this.items = items.filter((n) => n.type === 'file');897 this.index = Math.max(0, this.items.findIndex((n) => n.id === items[index]?.id));898 this.opts = opts;899 this.build();900 this.show();901 }902903 build() {904 this.overlay = h('div.preview-overlay', { role: 'dialog', 'aria-label': 'Preview' });905 this.title = h('span.title');906 this.sizeEl = h('span.size');907 const actions = h('div.p-actions');908 this.actionsHost = actions;909 this.stage = h('div.preview-stage');910 this.count = h('div.preview-count');911 this.body = h('div.preview-body', {},912 h('button.preview-nav.prev', { html: UI.chevronL, 'aria-label': 'Previous', onclick: () => this.nav(-1) }),913 this.stage,914 h('button.preview-nav.next', { html: UI.chevron, 'aria-label': 'Next', onclick: () => this.nav(1) }),915 this.count);916 this.overlay.append(h('div.preview-head', {}, this.title, this.sizeEl, actions), this.body);917 this.onKey = (e) => {918 if (e.target.matches('input, textarea')) return;919 if (e.key === 'Escape') this.close();920 if (e.key === 'ArrowLeft' && !e.target.matches('video')) this.nav(-1);921 if (e.key === 'ArrowRight' && !e.target.matches('video')) this.nav(1);922 };923 document.addEventListener('keydown', this.onKey);924 document.body.append(this.overlay);925 }926927 /** Rebuild header actions for the current node (honors action.visible). */928 renderActions(node) {929 this.actionsHost.innerHTML = '';930 for (const action of this.opts.actions ?? []) {931 if (action.visible && !action.visible(node)) continue;932 this.actionsHost.append(h('button.btn.icon', {933 title: action.title, html: action.icon,934 onclick: () => action.onClick(this.items[this.index], this),935 }));936 }937 this.actionsHost.append(h('button.btn.icon', { title: 'Close (Esc)', html: UI.close, onclick: () => this.close() }));938 }939940 async show() {941 const node = this.items[this.index];942 if (!node) { this.close(); return; }943 this.renderActions(node);944 this.title.textContent = node.name;945 this.sizeEl.textContent = fmtSize(node.size);946 this.count.textContent = this.items.length > 1 ? `${this.index + 1} / ${this.items.length}` : '';947 this.stage.innerHTML = '';948 this.stage.append(processing('Loading…'));949 try {950 const res = await fetch(this.opts.descUrl(node), { credentials: 'same-origin' });951 if (!res.ok) throw new Error(`HTTP ${res.status}`);952 const desc = await res.json();953 desc.icon = node.icon;954 if (this.items[this.index] !== node) return; // user already navigated955 this.stage.innerHTML = '';956 await renderPreview(this.stage, desc, {957 stream: this.opts.streamUrl(node),958 dl: this.opts.dlUrl ? this.opts.dlUrl(node) : null,959 });960 } catch {961 this.stage.innerHTML = '';962 renderFallback(this.stage, { name: node.name, size: node.size, mime: node.mime, icon: node.icon }, { dl: this.opts.dlUrl?.(node) ?? null });963 }964 }965966 nav(delta) {967 if (this.items.length < 2) return;968 this.index = (this.index + delta + this.items.length) % this.items.length;969 this.show();970 }971972 refreshCurrent() { this.show(); }973974 close() {975 document.removeEventListener('keydown', this.onKey);976 this.overlay.remove();977 this.opts.onClose?.();978 }979}980