SPB Git

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%
41.4 KB · 980 lines javascript
Raw Blame History
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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