cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / static / app.js

app.js (48911B)


      1 /*!
      2  * amit/cdn UI behaviour — loaded once per full page load (head, deferred) on every page.
      3  *
      4  * Design rules that keep this safe with Turbo and the instant-folder renderer:
      5  *   - Everything is DELEGATED from `document`. Turbo swaps <body> on every visit, and cached snapshots lose
      6  *     element-level listeners, so nothing is bound to individual elements.
      7  *   - Page set-up lives in init(), which is idempotent and runs on DOMContentLoaded and every turbo:load.
      8  *   - Page markup is built with textContent / escaped strings, never by concatenating raw names into HTML.
      9  *
     10  * Sections: helpers · theme · header/menus · copy · listing (filter, sort, zip, truncation) · upload ·
     11  *           history · search · file preview · lifecycle
     12  */
     13 (function () {
     14   'use strict';
     15   if (window.__cdnApp) return;
     16   window.__cdnApp = true;
     17 
     18   // ---------------------------------------------------------------------------------------------
     19   // helpers
     20   // ---------------------------------------------------------------------------------------------
     21   const $ = (sel, root) => (root || document).querySelector(sel);
     22   const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
     23   const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
     24   const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ESC[c]);
     25   const store = {
     26     get(key, fallback) { try { const v = localStorage.getItem(key); return v == null ? fallback : v; } catch (e) { return fallback; } },
     27     set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* private mode */ } },
     28     del(key) { try { localStorage.removeItem(key); } catch (e) { /* ignore */ } },
     29     json(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } },
     30   };
     31 
     32   function formatBytes(n) {
     33     if (!n || n < 0) return '0 B';
     34     if (n < 1024) return n + ' B';
     35     if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
     36     if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
     37     return (n / 1073741824).toFixed(2) + ' GB';
     38   }
     39 
     40   const ICON = {
     41     check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>',
     42     alert: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
     43     file: '<svg class="file-icon-list" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>',
     44     folder: '<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
     45     copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
     46     download: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>',
     47   };
     48 
     49   function toast(message, opts) {
     50     const o = opts || {};
     51     const region = $('#toasts');
     52     if (!region) return;
     53     const el = document.createElement('div');
     54     el.className = 'toast' + (o.type ? ' toast--' + o.type : '');
     55     el.innerHTML = (o.type === 'error' ? ICON.alert : ICON.check) + '<span></span>';
     56     el.lastChild.textContent = message;
     57     region.appendChild(el);
     58     while (region.children.length > 3) region.removeChild(region.firstChild);
     59     setTimeout(() => {
     60       el.classList.add('is-leaving');
     61       setTimeout(() => el.remove(), 200);
     62     }, o.ms || 2200);
     63   }
     64 
     65   async function copyText(text) {
     66     try {
     67       if (navigator.clipboard && window.isSecureContext) {
     68         await navigator.clipboard.writeText(text);
     69         return true;
     70       }
     71     } catch (e) { /* fall through to the legacy path */ }
     72     try {
     73       const ta = document.createElement('textarea');
     74       ta.value = text;
     75       ta.setAttribute('readonly', '');
     76       ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
     77       document.body.appendChild(ta);
     78       ta.select();
     79       const ok = document.execCommand('copy');
     80       ta.remove();
     81       return ok;
     82     } catch (e) { return false; }
     83   }
     84 
     85   function flashButton(btn, label) {
     86     if (!btn) return;
     87     const span = btn.querySelector('span');
     88     const prev = span ? span.textContent : null;
     89     btn.classList.add('is-done');
     90     if (span && label) span.textContent = label;
     91     clearTimeout(btn._flash);
     92     btn._flash = setTimeout(() => {
     93       btn.classList.remove('is-done');
     94       if (span && prev != null) span.textContent = prev;
     95     }, 1400);
     96   }
     97 
     98   function openDialog(id) {
     99     const d = document.getElementById(id);
    100     if (!d) return null;
    101     if (typeof d.showModal === 'function') { if (!d.open) d.showModal(); } else d.setAttribute('open', '');
    102     return d;
    103   }
    104   function closeDialog(d) {
    105     if (!d) return;
    106     if (typeof d.close === 'function') d.close(); else d.removeAttribute('open');
    107   }
    108 
    109   window.CDN = { toast, copyText, formatBytes, esc };
    110 
    111   // ---------------------------------------------------------------------------------------------
    112   // theme (the initial value is applied by an inline script in <head>; this owns changes)
    113   // ---------------------------------------------------------------------------------------------
    114   function syncThemeColor() {
    115     const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
    116     if (!bg) return;
    117     $$('meta[name="theme-color"]').forEach((m) => m.setAttribute('content', bg));
    118   }
    119   function setTheme(t, persist) {
    120     document.documentElement.setAttribute('data-theme', t);
    121     if (persist) store.set('cdn_theme', t);
    122     syncThemeColor();
    123   }
    124   try {
    125     const mq = matchMedia('(prefers-color-scheme: dark)');
    126     const onSystem = (e) => { if (!store.get('cdn_theme')) setTheme(e.matches ? 'dark' : 'light', false); };
    127     if (mq.addEventListener) mq.addEventListener('change', onSystem); else if (mq.addListener) mq.addListener(onSystem);
    128   } catch (e) { /* matchMedia unavailable */ }
    129 
    130   // ---------------------------------------------------------------------------------------------
    131   // header, menus, copy, dialogs: one delegated click handler
    132   // ---------------------------------------------------------------------------------------------
    133   function closeMenus(except) {
    134     $$('.more.is-open').forEach((m) => {
    135       if (m === except) return;
    136       m.classList.remove('is-open');
    137       const b = $('.more__toggle', m); if (b) b.setAttribute('aria-expanded', 'false');
    138     });
    139     $$('.breadcrumb-menu.open').forEach((m) => {
    140       if (except && except.contains(m)) return;
    141       m.classList.remove('open');
    142       const b = m.parentElement && $('.breadcrumb-ellipsis', m.parentElement); if (b) b.setAttribute('aria-expanded', 'false');
    143     });
    144   }
    145 
    146   document.addEventListener('click', async (e) => {
    147     const t = e.target;
    148     if (!(t instanceof Element)) return;
    149 
    150     const act = t.closest('[data-action]');
    151     const action = act && act.getAttribute('data-action');
    152 
    153     if (action === 'theme-toggle') {
    154       setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark', true);
    155       return;
    156     }
    157     if (action === 'nav-toggle') {
    158       const nav = $('#primary-nav');
    159       const open = !nav.classList.contains('is-open');
    160       nav.classList.toggle('is-open', open);
    161       act.setAttribute('aria-expanded', String(open));
    162       return;
    163     }
    164     if (action === 'more-toggle') {
    165       const more = act.closest('.more');
    166       const open = !more.classList.contains('is-open');
    167       closeMenus(open ? more : null);
    168       more.classList.toggle('is-open', open);
    169       act.setAttribute('aria-expanded', String(open));
    170       return;
    171     }
    172     if (action === 'crumb-menu') {
    173       const wrap = act.closest('.breadcrumb-collapsed');
    174       const menu = wrap && $('.breadcrumb-menu', wrap);
    175       if (!menu) return;
    176       const open = !menu.classList.contains('open');
    177       closeMenus(open ? wrap : null);
    178       menu.classList.toggle('open', open);
    179       act.setAttribute('aria-expanded', String(open));
    180       return;
    181     }
    182     if (action === 'copy-path') {
    183       const holder = act.closest('.breadcrumb-container');
    184       const base = (holder && holder.getAttribute('data-cdn-base')) || location.origin;
    185       const ok = await copyText(base.replace(/\/$/, '') + location.pathname);
    186       toast(ok ? 'Link copied' : 'Could not copy', ok ? {} : { type: 'error' });
    187       return;
    188     }
    189     if (action === 'dialog-close') {
    190       closeDialog(act.closest('dialog'));
    191       // an <a data-action="dialog-close"> still follows its href (the zip download)
    192       return;
    193     }
    194     if (action === 'dismiss-notice') {
    195       store.set('cdn_notice_off_v1', '1');
    196       document.documentElement.setAttribute('data-notice', 'off');
    197       return;
    198     }
    199     if (action === 'clear-history') {
    200       store.del(act.getAttribute('data-key'));
    201       renderHistory();
    202       toast('History cleared');
    203       return;
    204     }
    205 
    206     // copy buttons: data-copy="text", data-copy-url="page"
    207     const copyBtn = t.closest('[data-copy], [data-copy-url]');
    208     if (copyBtn) {
    209       const text = copyBtn.hasAttribute('data-copy') ? copyBtn.getAttribute('data-copy') : location.href;
    210       const ok = await copyText(text);
    211       if (ok) { flashButton(copyBtn, 'Copied'); toast('Copied to clipboard'); } else toast('Could not copy', { type: 'error' });
    212       return;
    213     }
    214 
    215     // click on a dialog's backdrop closes it
    216     if (t.tagName === 'DIALOG') { closeDialog(t); return; }
    217 
    218     // click elsewhere closes open menus
    219     if (!t.closest('.more') && !t.closest('.breadcrumb-collapsed')) closeMenus(null);
    220 
    221     // listing / batch
    222     if (t.closest('#filter-clear')) {
    223       const input = $('#filter-input');
    224       if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
    225       return;
    226     }
    227     if (t.closest('#search-clear')) { clearSearch(); return; }
    228     if (t.closest('#url-clear')) { clearShortener(); return; }
    229     if (t.closest('#shorten-btn')) { shorten(); return; }
    230     const zipBtn = t.closest('#download-zip-btn');
    231     if (zipBtn) { e.preventDefault(); onZip(zipBtn); return; }
    232 
    233     // upload
    234     const folderBtn = t.closest('#folder-picker [data-folder]');
    235     if (folderBtn) {
    236       $$('#folder-picker [data-folder]').forEach((b) => {
    237         const on = b === folderBtn;
    238         b.classList.toggle('is-active', on);
    239         b.setAttribute('aria-pressed', String(on));
    240       });
    241       return;
    242     }
    243     if (t.closest('#url-upload-btn')) { uploadFromUrl(); return; }
    244 
    245     // history tabs
    246     const tab = t.closest('[data-history-tab]');
    247     if (tab) { switchTab(Number(tab.getAttribute('data-history-tab')), false); return; }
    248   });
    249 
    250   document.addEventListener('focusin', (e) => {
    251     const t = e.target;
    252     if (t instanceof HTMLInputElement && t.hasAttribute('data-select-on-focus')) t.select();
    253   });
    254 
    255   document.addEventListener('keydown', (e) => {
    256     if (e.key === 'Escape') {
    257       closeMenus(null);
    258       const nav = $('#primary-nav'), tgl = $('.nav-toggle');
    259       if (nav && nav.classList.contains('is-open')) { nav.classList.remove('is-open'); if (tgl) { tgl.setAttribute('aria-expanded', 'false'); tgl.focus(); } }
    260       return;
    261     }
    262     // history tabs: arrow keys move between tabs
    263     const tabEl = e.target instanceof Element && e.target.closest('[role="tab"]');
    264     if (tabEl && (e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'Home' || e.key === 'End')) {
    265       const tabs = $$('[role="tab"]', tabEl.parentElement);
    266       let i = tabs.indexOf(tabEl);
    267       if (e.key === 'ArrowRight') i = (i + 1) % tabs.length;
    268       else if (e.key === 'ArrowLeft') i = (i - 1 + tabs.length) % tabs.length;
    269       else if (e.key === 'Home') i = 0; else i = tabs.length - 1;
    270       e.preventDefault();
    271       switchTab(i, true);
    272       return;
    273     }
    274     // "/" focuses the page's main search/filter/url box
    275     if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
    276       const tag = (document.activeElement && document.activeElement.tagName) || '';
    277       if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
    278       const target = $('#filter-input') || $('#search-input') || $('#url-input');
    279       if (target) { e.preventDefault(); target.focus(); }
    280     }
    281   });
    282 
    283   // ---------------------------------------------------------------------------------------------
    284   // listing: filter, sort, zip, long-name truncation
    285   // ---------------------------------------------------------------------------------------------
    286   document.addEventListener('input', (e) => {
    287     const t = e.target;
    288     if (!(t instanceof HTMLInputElement)) return;
    289     if (t.id === 'filter-input') {
    290       const term = t.value.toLowerCase();
    291       const group = $('#filter-group');
    292       if (group) { if (t.value.length) group.setAttribute('data-has-text', 'true'); else group.removeAttribute('data-has-text'); }
    293       $$('#file-list .list-item[data-name]').forEach((item) => {
    294         const name = item.getAttribute('data-name').toLowerCase();
    295         if (name === '..') return;
    296         item.hidden = !name.includes(term);
    297       });
    298       scheduleTruncate();
    299     } else if (t.id === 'search-input') {
    300       onSearchInput(t);
    301     } else if (t.id === 'url-input') {
    302       const g = $('#shorten-group');
    303       if (g) { if (t.value.length) g.setAttribute('data-has-text', 'true'); else g.removeAttribute('data-has-text'); }
    304     }
    305   });
    306 
    307   document.addEventListener('change', (e) => {
    308     const t = e.target;
    309     if (t instanceof HTMLSelectElement && t.id === 'sort-select') {
    310       store.set('cdn_sort_v1', t.value);
    311       applySort();
    312     } else if (t instanceof HTMLInputElement && t.id === 'file-input') {
    313       if (t.files && t.files.length) handleFiles(t.files);
    314     }
    315   });
    316 
    317   const SIZE_UNITS = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
    318   function rowBytes(row) {
    319     const el = $('.size-label', row);
    320     const m = el && /([\d.]+)\s*(B|KB|MB|GB|TB)/i.exec(el.textContent);
    321     return m ? parseFloat(m[1]) * SIZE_UNITS[m[2].toUpperCase()] : -1;
    322   }
    323   function applySort() {
    324     const list = $('#file-list');
    325     const select = $('#sort-select');
    326     if (!list || !select) return;
    327     const rows = Array.from(list.children).filter((r) => r.classList.contains('list-item'));
    328     if (!rows.length) return;
    329     // Folders have no size, so a folder with only sub-folders has nothing to size-sort. Disable those options
    330     // there, show "Default order" instead of a stale saved choice (without overwriting it), and hide the control
    331     // entirely when there is nothing to reorder.
    332     const hasSizes = rows.some((r) => rowBytes(r) >= 0);
    333     $$('option', select).forEach((o) => { if (o.value.indexOf('size') === 0) o.disabled = !hasSizes; });
    334     let mode = store.get('cdn_sort_v1', 'default');
    335     if (!hasSizes && mode.indexOf('size') === 0) mode = 'default';
    336     select.value = $$('option', select).some((o) => o.value === mode && !o.disabled) ? mode : 'default';
    337     const wrap = select.closest('.select-wrap');
    338     if (wrap) wrap.hidden = rows.filter((r) => r.getAttribute('data-name') !== '..').length < 2;
    339     rows.forEach((r, i) => { if (!r.dataset.i) r.dataset.i = String(i); });
    340     const isUp = (r) => r.getAttribute('data-name') === '..';
    341     const isDir = (r) => !!$('.folder-link', r) && !$('.size-label', r);
    342     const byName = (a, b) => a.getAttribute('data-name').localeCompare(b.getAttribute('data-name'), undefined, { numeric: true, sensitivity: 'base' });
    343     const cmp = {
    344       'default': (a, b) => Number(a.dataset.i) - Number(b.dataset.i),
    345       'name': byName,
    346       'name-desc': (a, b) => byName(b, a),
    347       'size-desc': (a, b) => rowBytes(b) - rowBytes(a) || byName(a, b),
    348       'size-asc': (a, b) => rowBytes(a) - rowBytes(b) || byName(a, b),
    349     }[select.value] || byName;
    350     rows.sort((a, b) => (isUp(b) - isUp(a)) || (isDir(b) - isDir(a)) || cmp(a, b));
    351     const frag = document.createDocumentFragment();
    352     rows.forEach((r) => frag.appendChild(r));
    353     list.insertBefore(frag, list.firstChild);
    354   }
    355 
    356   async function onZip(btn) {
    357     if (btn.disabled) return;
    358     const batchId = btn.getAttribute('data-batch-id');
    359     const path = btn.getAttribute('data-path') || '';
    360     const statsUrl = batchId ? '/api/pack-info-batch/' + batchId : '/api/pack-info/' + path;
    361     const downloadUrl = batchId ? '/api/pack-batch/' + batchId : '/api/pack/' + path;
    362     const spinner = $('#zip-spinner');
    363     const label = $('#zip-btn-text');
    364     const labelHtml = label ? label.innerHTML : '';
    365     btn.disabled = true;
    366     if (spinner) spinner.style.display = 'block';
    367     if (label) label.textContent = 'Calculating…';
    368     try {
    369       const res = await fetch(statsUrl);
    370       const data = await res.json().catch(() => ({}));
    371       if (!res.ok) throw new Error(data.detail || 'Failed to get file stats');
    372       const what = batchId ? 'this batch' : (path ? path.split('/').pop() : 'root');
    373       $('#zip-dialog-text').innerHTML =
    374         'Download <strong>' + esc(what) + '</strong> as a single .zip? It packages <strong>' + esc(data.file_count) +
    375         ' file(s)</strong> totalling <strong>' + esc(data.size_str) + '</strong>.';
    376       $('#zip-dialog-confirm').href = downloadUrl;
    377       openDialog('zip-dialog');
    378     } catch (err) {
    379       showErrorDialog(err.message);
    380     } finally {
    381       btn.disabled = false;
    382       if (spinner) spinner.style.display = 'none';
    383       if (label) label.innerHTML = labelHtml;
    384     }
    385   }
    386   function showErrorDialog(message) {
    387     const text = $('#error-dialog-text');
    388     if (text && openDialog('error-dialog')) text.textContent = message; else toast(message, { type: 'error' });
    389   }
    390 
    391   // Phones: keep the extension visible by truncating the middle of long names (CSS can only cut the end).
    392   const MOBILE_BP = 480;
    393   let measureCanvas = null;
    394   function textWidth(text, font) {
    395     if (!measureCanvas) measureCanvas = document.createElement('canvas');
    396     const ctx = measureCanvas.getContext('2d');
    397     ctx.font = font;
    398     return ctx.measureText(text).width;
    399   }
    400   function middleTruncate(full, maxWidth, font) {
    401     if (textWidth(full, font) <= maxWidth) return full;
    402     const dot = full.lastIndexOf('.');
    403     const hasExt = dot > 0 && dot < full.length - 1;
    404     const ext = hasExt ? full.slice(dot) : '';
    405     const base = hasExt ? full.slice(0, dot) : full;
    406     for (let head = base.length; head > 1; head--) {
    407       const cand = base.slice(0, head) + '...' + ext;
    408       if (textWidth(cand, font) <= maxWidth) return cand;
    409     }
    410     for (let end = full.length; end > 1; end--) {
    411       const cand = full.slice(0, end) + '...';
    412       if (textWidth(cand, font) <= maxWidth) return cand;
    413     }
    414     return full.slice(0, 1) + '...';
    415   }
    416   function applyTruncation() {
    417     const names = $$('.item-name');
    418     if (!names.length) return;
    419     const mobile = window.innerWidth <= MOBILE_BP;
    420     names.forEach((box) => {
    421       const a = box.querySelector('a[data-full-name]');
    422       const full = a && a.getAttribute('data-full-name');
    423       if (!a || !full) return;
    424       if (!mobile) { if (a.textContent !== full) a.textContent = full; return; }
    425       const width = box.clientWidth;
    426       if (!width) return;
    427       const icon = box.querySelector('svg');
    428       const gap = parseFloat(getComputedStyle(box).columnGap) || 0;
    429       const avail = width - (icon ? icon.getBoundingClientRect().width : 0) - gap - 4;
    430       if (avail <= 0) return;
    431       const cs = getComputedStyle(a);
    432       const out = middleTruncate(full, avail, cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily);
    433       if (a.textContent !== out) a.textContent = out;
    434       if (!a.title) a.title = full;
    435     });
    436   }
    437   let truncRaf = 0;
    438   function scheduleTruncate() {
    439     cancelAnimationFrame(truncRaf);
    440     truncRaf = requestAnimationFrame(() => requestAnimationFrame(applyTruncation));
    441   }
    442   window.scheduleTruncate = scheduleTruncate;
    443 
    444   function applyPlaceholders() {
    445     const mobile = window.innerWidth <= MOBILE_BP;
    446     const f = $('#filter-input'); if (f) f.placeholder = mobile ? 'Search this folder...' : 'Search this folder... (press / to focus)';
    447     const s = $('#search-input'); if (s) s.placeholder = mobile ? 'Find files...' : 'Find a file anywhere in the CDN...';
    448   }
    449   let resizeTimer = 0;
    450   window.addEventListener('resize', () => {
    451     clearTimeout(resizeTimer);
    452     resizeTimer = setTimeout(() => { scheduleTruncate(); applyPlaceholders(); }, 120);
    453   });
    454 
    455   // ---------------------------------------------------------------------------------------------
    456   // upload
    457   // ---------------------------------------------------------------------------------------------
    458   const MAX_FILE_BYTES = 200 * 1024 * 1024;
    459 
    460   function recordUpload(item) {
    461     try {
    462       const entries = store.json('cdn_uploads_v1');
    463       entries.unshift({ filename: item.filename, hf_path: item.hf_path, size: item.size, cdn_url: item.cdn_url, raw_url: item.raw_url, ts: Date.now() });
    464       store.set('cdn_uploads_v1', JSON.stringify(entries.slice(0, 50)));
    465     } catch (e) { /* ignore */ }
    466     // a new file exists now: refresh the cached tree so instant folder navigation shows it
    467     if (window.__cdnFast) window.__cdnFast.refresh(true);
    468   }
    469   const selectedFolder = () => { const a = $('#folder-picker .is-active'); return a ? a.getAttribute('data-folder') : 'uploads'; };
    470   function uploadError(msg) { const box = $('#error-box'); if (!box) return; box.textContent = msg; box.hidden = false; }
    471   function clearUploadError() { const box = $('#error-box'); if (box) { box.hidden = true; box.textContent = ''; } }
    472 
    473   function queueItem(name) {
    474     const li = document.createElement('li');
    475     li.className = 'queue-item';
    476     li.innerHTML = '<span class="queue-item__name"></span><span class="queue-item__status"></span><div class="queue-item__bar"><i></i></div>';
    477     li.firstChild.textContent = name;
    478     li.setAttribute('title', name);
    479     $('#results').prepend(li);
    480     return li;
    481   }
    482   function setStatus(li, html) { $('.queue-item__status', li).innerHTML = html; }
    483   function markDone(li, item) {
    484     li.className = 'queue-item is-done';
    485     li.innerHTML = '<span class="queue-item__name"></span><span class="queue-item__actions"><a href="/' + esc(item.hf_path) + '">View</a>' +
    486       '<a href="#" data-copy="' + esc(item.cdn_url || (location.origin + '/' + item.hf_path)) + '">Copy link</a></span>';
    487     li.firstChild.textContent = item.filename;
    488     const copy = $('[data-copy]', li);
    489     copy.addEventListener('click', (ev) => ev.preventDefault());
    490   }
    491   function markFailed(li, msg) {
    492     li.className = 'queue-item is-error';
    493     $('.queue-item__bar', li) && $('.queue-item__bar', li).remove();
    494     setStatus(li, ICON.alert + '<span>' + esc(msg || 'Failed') + '</span>');
    495   }
    496 
    497   function handleFiles(fileList) {
    498     clearUploadError();
    499     const all = Array.from(fileList);
    500     const files = all.filter((f) => f.size <= MAX_FILE_BYTES);
    501     const rejected = all.filter((f) => f.size > MAX_FILE_BYTES).map((f) => f.name);
    502     if (rejected.length) uploadError('Skipped ' + rejected.length + ' file(s) over ' + formatBytes(MAX_FILE_BYTES) + ': ' + rejected.join(', '));
    503     if (!files.length) return;
    504 
    505     const rows = new Map();
    506     files.forEach((f) => {
    507       const li = queueItem(f.name);
    508       setStatus(li, formatBytes(f.size) + ' · waiting…');
    509       rows.set(f.name, li);
    510     });
    511 
    512     const form = new FormData();
    513     files.forEach((f) => form.append('files', f));
    514     form.append('folder', selectedFolder());
    515 
    516     const xhr = new XMLHttpRequest();
    517     xhr.open('POST', '/api/put', true);
    518     xhr.upload.onprogress = (ev) => {
    519       if (!ev.lengthComputable) return;
    520       const pct = Math.round((ev.loaded / ev.total) * 100);
    521       rows.forEach((li) => {
    522         if (pct >= 100) { li.classList.add('is-saving'); setStatus(li, '<span class="spinner"></span><span>saving…</span>'); } else {
    523           setStatus(li, pct + '%');
    524           const bar = $('.queue-item__bar i', li); if (bar) bar.style.width = pct + '%';
    525         }
    526       });
    527     };
    528     xhr.onload = () => {
    529       const fileInput = $('#file-input'); if (fileInput) fileInput.value = '';
    530       if (xhr.status >= 200 && xhr.status < 300) {
    531         let data;
    532         try { data = JSON.parse(xhr.responseText); } catch (e) { uploadError('Invalid response from server.'); return; }
    533         const rowList = Array.from(rows.values());
    534         (data.files || []).forEach((item, idx) => {
    535           recordUpload(item);
    536           // match by name; if the server renamed the file, fall back to upload order so no row hangs on "waiting"
    537           const li = rows.get(item.filename) || rowList[idx];
    538           if (li) markDone(li, item);
    539         });
    540         if (data.batch_url) {
    541           const li = document.createElement('li');
    542           li.className = 'queue-item queue-item--group is-done';
    543           li.innerHTML = '<span class="queue-item__status">All ' + esc((data.files || []).length) + ' files uploaded. Group link:</span>' +
    544             '<a href="' + esc(data.batch_url) + '" target="_blank" rel="noopener">' + esc(data.batch_url) + '</a>';
    545           $('#results').prepend(li);
    546         }
    547         toast(files.length === 1 ? 'Upload complete' : files.length + ' files uploaded');
    548       } else {
    549         let msg = 'Upload failed.';
    550         try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) { /* keep default */ }
    551         uploadError(msg);
    552         rows.forEach((li) => markFailed(li, 'Failed'));
    553       }
    554     };
    555     xhr.onerror = () => { uploadError('Connection error.'); rows.forEach((li) => markFailed(li, 'Failed')); };
    556     xhr.send(form);
    557   }
    558 
    559   async function uploadFromUrl() {
    560     const input = $('#url-input');
    561     const btn = $('#url-upload-btn');
    562     if (!input || !btn) return;
    563     const url = input.value.trim();
    564     clearUploadError();
    565     if (!url) return;
    566     let label;
    567     try { label = decodeURIComponent(new URL(url).pathname.split('/').pop()) || url; } catch (e) { uploadError("That doesn't look like a valid URL."); return; }
    568     const li = queueItem(label);
    569     setStatus(li, '<span class="spinner"></span><span>fetching &amp; saving…</span>');
    570     li.classList.add('is-saving');
    571     btn.disabled = true;
    572     try {
    573       const resp = await fetch('/api/put-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, folder: selectedFolder() }) });
    574       const data = await resp.json().catch(() => ({}));
    575       if (!resp.ok) throw new Error(data.detail || 'Upload from URL failed.');
    576       const item = data.files[0];
    577       recordUpload(item);
    578       markDone(li, item);
    579       input.value = '';
    580       toast('Upload complete');
    581     } catch (err) {
    582       markFailed(li, 'Failed');
    583       uploadError(err.message);
    584     } finally {
    585       btn.disabled = false;
    586     }
    587   }
    588 
    589   // drag & drop (delegated: the page can be swapped by Turbo at any time)
    590   document.addEventListener('dragover', (e) => { const z = e.target instanceof Element && e.target.closest('#dropzone'); if (z) { e.preventDefault(); z.classList.add('dragover'); } });
    591   document.addEventListener('dragleave', (e) => { const z = e.target instanceof Element && e.target.closest('#dropzone'); if (z && !z.contains(e.relatedTarget)) z.classList.remove('dragover'); });
    592   document.addEventListener('drop', (e) => {
    593     const z = e.target instanceof Element && e.target.closest('#dropzone');
    594     if (!z) return;
    595     e.preventDefault();
    596     z.classList.remove('dragover');
    597     if (e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
    598   });
    599   document.addEventListener('paste', (e) => {
    600     if (document.body.dataset.page !== 'upload') return;
    601     const t = e.target;
    602     if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
    603     const files = e.clipboardData && e.clipboardData.files;
    604     if (files && files.length) { e.preventDefault(); handleFiles(files); }
    605   });
    606   document.addEventListener('keydown', (e) => {
    607     if (e.key === 'Enter' && e.target instanceof HTMLInputElement && e.target.id === 'url-input') {
    608       const page = document.body.dataset.page;
    609       if (page === 'upload') { e.preventDefault(); uploadFromUrl(); } else if (page === 'shorten') { e.preventDefault(); shorten(); }
    610     }
    611   });
    612 
    613   // ---------------------------------------------------------------------------------------------
    614   // url shortener
    615   // ---------------------------------------------------------------------------------------------
    616   const PRIVATE_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2}|\[?::1\]?|.*\.localhost|.*\.local|.*\.lan)$/i;
    617   function isPrivateHost(raw) { try { return PRIVATE_HOST_RE.test(new URL(raw).hostname); } catch (e) { return false; } }
    618   function shortenError(msg) { const b = $('#error-box'); if (!b) return; b.textContent = msg; b.style.display = msg ? 'block' : 'none'; }
    619   function clearShortener() {
    620     const input = $('#url-input'); const group = $('#shorten-group'); const result = $('#result');
    621     if (input) { input.value = ''; input.focus(); }
    622     if (group) group.removeAttribute('data-has-text');
    623     shortenError('');
    624     if (result) result.hidden = true;
    625   }
    626   async function shorten() {
    627     const input = $('#url-input'); const btn = $('#shorten-btn'); const result = $('#result');
    628     if (!input || !btn || btn.disabled) return;
    629     const url = input.value.trim();
    630     shortenError('');
    631     if (result) result.hidden = true;
    632     if (!url) { shortenError('Please enter a URL.'); return; }
    633     if (!/^https:\/\//i.test(url)) { shortenError('Only https:// URLs are supported.'); return; }
    634     if (isPrivateHost(url)) { shortenError('Localhost and private network URLs cannot be shortened.'); return; }
    635     btn.disabled = true;
    636     btn.classList.add('is-loading');
    637     try {
    638       const res = await fetch('/api/shorten', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) });
    639       const data = await res.json().catch(() => ({}));
    640       if (!res.ok) throw new Error(data.detail || 'Failed to shorten URL.');
    641       const link = $('#masked-link');
    642       link.href = '/mask/' + data.id;
    643       link.textContent = data.masked_url;
    644       $('#copy-btn').setAttribute('data-copy', data.masked_url);
    645       result.hidden = false;
    646     } catch (err) {
    647       shortenError(err.message || 'An unexpected error occurred.');
    648     } finally {
    649       btn.disabled = false;
    650       btn.classList.remove('is-loading');
    651     }
    652   }
    653 
    654   // ---------------------------------------------------------------------------------------------
    655   // history
    656   // ---------------------------------------------------------------------------------------------
    657   function relTime(ts) {
    658     if (!ts) return '';
    659     const mins = Math.round((Date.now() - ts) / 60000);
    660     if (mins < 1) return 'just now';
    661     if (mins < 60) return mins + 'm ago';
    662     if (mins < 1440) return Math.round(mins / 60) + 'h ago';
    663     return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
    664   }
    665   function historyRow(opts) {
    666     const dl = opts.download
    667       ? '<a href="/api/get/' + esc(opts.download) + '" class="download-icon" title="Download" aria-label="Download ' + esc(opts.name) + '" data-turbo="false">' + ICON.download + '</a>' : '';
    668     return '<div class="list-item" data-name="' + esc(opts.name) + '"><div class="item-name">' + (opts.dir ? ICON.folder : ICON.file) +
    669       '<a href="' + esc(opts.href) + '" class="' + (opts.dir ? 'folder-link' : 'file-link') + '" data-full-name="' + esc(opts.name) + '">' + esc(opts.name) + '</a></div>' +
    670       '<div class="item-meta">' + (opts.size ? '<span class="size-label">' + esc(opts.size) + '</span>' : '') +
    671       '<span class="when">' + esc(relTime(opts.ts)) + '</span>' + dl + '</div></div>';
    672   }
    673   function renderHistory() {
    674     const up = $('#history-uploads-list');
    675     const vis = $('#history-visited-list');
    676     if (up) {
    677       const items = store.json('cdn_uploads_v1');
    678       up.innerHTML = items.length
    679         ? items.map((i) => historyRow({ name: i.filename || String(i.hf_path || '').split('/').pop(), href: '/' + i.hf_path, size: i.size ? formatBytes(i.size) : '', ts: i.ts, download: i.hf_path })).join('')
    680         : '<div class="empty-state"><p>No uploads logged yet on this browser.</p></div>';
    681     }
    682     if (vis) {
    683       const items = store.json('cdn_visited_v1');
    684       vis.innerHTML = items.length
    685         ? items.map((i) => {
    686             const dir = i.type === 'folder';
    687             const name = dir ? String(i.path).split('/').pop() + '/' : (i.filename || String(i.path).split('/').pop());
    688             return historyRow({ name, href: '/' + i.path, ts: i.ts, dir, download: dir ? '' : i.path });
    689           }).join('')
    690         : '<div class="empty-state"><p>No visited links logged yet.</p></div>';
    691     }
    692     scheduleTruncate();
    693   }
    694   function switchTab(index, focus) {
    695     const tabs = $$('[data-history-tab]');
    696     if (!tabs.length) return;
    697     tabs.forEach((t, i) => {
    698       const on = i === index;
    699       t.classList.toggle('is-active', on);
    700       t.setAttribute('aria-selected', String(on));
    701       t.tabIndex = on ? 0 : -1;
    702       const panel = document.getElementById(t.getAttribute('aria-controls'));
    703       if (panel) panel.hidden = !on;
    704       if (on && focus) t.focus();
    705     });
    706   }
    707 
    708   // ---------------------------------------------------------------------------------------------
    709   // search
    710   // ---------------------------------------------------------------------------------------------
    711   let searchTimer = 0, searchCtl = null, searchSeq = 0;
    712   function searchStatus(html) {
    713     const el = $('#search-status');
    714     if (!el) return;
    715     el.hidden = !html;
    716     el.innerHTML = html || '';
    717   }
    718   function clearSearch() {
    719     const input = $('#search-input'); const group = $('#search-group'); const results = $('#search-results');
    720     if (input) { input.value = ''; input.focus(); }
    721     if (group) group.removeAttribute('data-has-text');
    722     if (results) results.innerHTML = '';
    723     searchStatus('');
    724   }
    725   function renderResults(items) {
    726     const box = $('#search-results');
    727     if (!box) return;
    728     if (!items.length) { box.innerHTML = ''; searchStatus('No matching files found. Try a different term.'); return; }
    729     searchStatus('<strong>' + items.length + '</strong> result' + (items.length !== 1 ? 's' : ''));
    730     box.innerHTML = items.map((it) => {
    731       const name = it.is_dir ? it.name + '/' : it.name;
    732       const dl = it.is_dir ? '' : '<a href="/api/get/' + esc(it.path) + '" class="download-icon" title="Download" aria-label="Download ' + esc(it.name) + '" data-turbo="false">' + ICON.download + '</a>';
    733       return '<div class="list-item list-item--stack" data-name="' + esc(it.name) + '"><div class="row-top"><div class="item-name">' + (it.is_dir ? ICON.folder : ICON.file) +
    734         '<a href="/' + esc(it.path) + '" class="' + (it.is_dir ? 'folder-link' : 'file-link') + '" data-full-name="' + esc(name) + '">' + esc(name) + '</a></div>' +
    735         '<div class="item-meta">' + (it.is_dir ? '' : '<span class="size-label">' + esc(it.size_str) + '</span>' + dl) + '</div></div>' +
    736         '<div class="result-path">' + esc(it.path) + '</div></div>';
    737     }).join('');
    738     scheduleTruncate();
    739   }
    740   function onSearchInput(input) {
    741     const term = input.value.trim();
    742     const group = $('#search-group');
    743     clearTimeout(searchTimer);
    744     if (group) { if (input.value.length) group.setAttribute('data-has-text', 'true'); else group.removeAttribute('data-has-text'); }
    745     if (!term) { const r = $('#search-results'); if (r) r.innerHTML = ''; searchStatus(''); return; }
    746     searchStatus('<span class="spinner"></span> Searching…');
    747     searchTimer = setTimeout(() => {
    748       const seq = ++searchSeq;
    749       if (searchCtl) searchCtl.abort();
    750       searchCtl = new AbortController();
    751       fetch('/api/find?q=' + encodeURIComponent(term), { signal: searchCtl.signal })
    752         .then((r) => r.json())
    753         .then((d) => { if (seq === searchSeq) renderResults(d.results || []); })
    754         .catch((err) => {
    755           if (err.name === 'AbortError' || seq !== searchSeq) return;
    756           const r = $('#search-results'); if (r) r.innerHTML = '';
    757           searchStatus('Search failed. Please try again.');
    758         });
    759     }, 300);
    760   }
    761 
    762   // ---------------------------------------------------------------------------------------------
    763   // file preview (text). Images, video, audio and PDF are plain elements rendered by the server.
    764   // ---------------------------------------------------------------------------------------------
    765   const PREVIEW_BYTES = 256 * 1024;
    766   const PREVIEW_LINES = 5000;
    767 
    768   async function loadTextPreview(box) {
    769     if (box.getAttribute('data-state') === 'done' || box.getAttribute('data-state') === 'loading') return;
    770     box.setAttribute('data-state', 'loading');
    771     const status = (msg) => { box.innerHTML = '<p class="preview__status"></p>'; box.firstChild.textContent = msg; box.classList.remove('is-skeleton'); box.setAttribute('aria-busy', 'false'); };
    772     try {
    773       const res = await fetch(box.getAttribute('data-text-src'));
    774       if (!res.ok) throw new Error('HTTP ' + res.status);
    775       const reader = res.body && res.body.getReader ? res.body.getReader() : null;
    776       let bytes, truncated = false;
    777       if (reader) {
    778         const chunks = []; let total = 0;
    779         for (;;) {
    780           const { done, value } = await reader.read();
    781           if (done) break;
    782           chunks.push(value); total += value.length;
    783           if (total >= PREVIEW_BYTES) { truncated = true; reader.cancel().catch(() => {}); break; }
    784         }
    785         bytes = new Uint8Array(Math.min(total, PREVIEW_BYTES));
    786         let off = 0;
    787         for (const c of chunks) { const part = c.subarray(0, Math.max(0, bytes.length - off)); bytes.set(part, off); off += part.length; if (off >= bytes.length) break; }
    788       } else {
    789         const buf = new Uint8Array(await res.arrayBuffer());
    790         truncated = buf.length > PREVIEW_BYTES;
    791         bytes = truncated ? buf.subarray(0, PREVIEW_BYTES) : buf;
    792       }
    793       const text = new TextDecoder('utf-8').decode(bytes);
    794       if (text.indexOf('\u0000') !== -1) { status("This file doesn't look like text, so there's no preview."); box.setAttribute('data-state', 'done'); return; }
    795       if (!text.trim()) { status('This file is empty.'); box.setAttribute('data-state', 'done'); return; }
    796 
    797       let lines = text.split(/\r?\n/);
    798       if (lines.length && lines[lines.length - 1] === '') lines.pop();
    799       if (lines.length > PREVIEW_LINES) { lines = lines.slice(0, PREVIEW_LINES); truncated = true; }
    800       // build with textContent so file contents can never become markup
    801       const pre = document.createElement('pre');
    802       pre.setAttribute('aria-label', 'File contents');
    803       const code = document.createElement('code');
    804       lines.forEach((l) => { const s = document.createElement('span'); s.className = 'ln'; s.textContent = l; code.appendChild(s); });
    805       pre.appendChild(code);
    806       const bar = document.createElement('div');
    807       bar.className = 'preview__bar';
    808       bar.innerHTML = '<span></span><button type="button" class="btn btn-sm" data-copy-preview aria-label="Copy file contents" title="Copy file contents">' + ICON.copy + '<span>Copy contents</span></button>';
    809       bar.firstChild.textContent = lines.length + ' line' + (lines.length !== 1 ? 's' : '') + (truncated ? ' · preview truncated' : '');
    810       // everything is built off-screen above, then swapped in at once (replacing the skeleton)
    811       box.replaceChildren(bar, pre);
    812       box.classList.remove('is-skeleton');
    813       box.classList.add('is-ready');
    814       box.setAttribute('aria-busy', 'false');
    815       box.setAttribute('data-state', 'done');
    816     } catch (err) {
    817       status("Couldn't load a preview. Use Open raw instead.");
    818       box.setAttribute('data-state', 'failed');
    819     } finally {
    820       settleFileView();
    821     }
    822   }
    823   document.addEventListener('click', async (e) => {
    824     const b = e.target instanceof Element && e.target.closest('[data-copy-preview]');
    825     if (!b) return;
    826     const code = $('code', b.closest('.preview__text'));
    827     const text = code ? Array.from(code.children).map((s) => s.textContent).join('\n') : '';
    828     const ok = await copyText(text);
    829     if (ok) { flashButton(b, 'Copied'); toast('Contents copied'); } else toast('Could not copy', { type: 'error' });
    830   });
    831   // an image that fails to load (deleted upstream, blocked host) degrades to the plain no-preview state
    832   function imageFailed(box) {
    833     box.className = 'preview preview--none';
    834     box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>';
    835     settleFileView();
    836   }
    837   document.addEventListener('error', (e) => {
    838     const img = e.target;
    839     if (!(img instanceof HTMLImageElement)) return;
    840     const box = img.closest('.preview--image');
    841     if (box) imageFailed(box);
    842   }, true);
    843   // File pages show a whole-page skeleton and appear at once when the preview is ready, or after a short cap
    844   // so a huge image can never hold the links back.
    845   const REVEAL_CAP_MS = 700;
    846   function revealFileView(view) {
    847     if (!view.classList.contains('is-pending')) return;
    848     view.classList.remove('is-pending');
    849     view.classList.add('is-revealed');
    850     view.removeAttribute('aria-busy');
    851   }
    852   function settleFileView() {
    853     const view = $('.file-view.is-pending');
    854     if (!view) return;
    855     const waiting = $('.preview__text[data-state="idle"], .preview__text[data-state="loading"], .preview.is-loading', view);
    856     if (!waiting) { revealFileView(view); return; }
    857     if (!view._capTimer) view._capTimer = setTimeout(() => revealFileView(view), REVEAL_CAP_MS);
    858   }
    859   // images and PDFs sit behind a shimmer until they have loaded, then appear at once
    860   document.addEventListener('load', (e) => {
    861     const t = e.target;
    862     if (!(t instanceof HTMLImageElement || t instanceof HTMLIFrameElement)) return;
    863     const box = t.closest('.preview.is-loading');
    864     if (box) { box.classList.remove('is-loading'); settleFileView(); }
    865   }, true);
    866   function settleLoadingPreviews() {
    867     $$('.preview.is-loading').forEach((box) => {
    868       const img = $('img', box);
    869       if (img && img.complete) { if (img.naturalWidth > 0) box.classList.remove('is-loading'); else imageFailed(box); return; }
    870       // never leave a skeleton up forever (e.g. a PDF viewer that doesn't fire load)
    871       setTimeout(() => box.classList.remove('is-loading'), 8000);
    872     });
    873   }
    874 
    875   // ---------------------------------------------------------------------------------------------
    876   // lifecycle
    877   // ---------------------------------------------------------------------------------------------
    878   function init() {
    879     syncThemeColor();
    880     applyPlaceholders();
    881     applySort();
    882     scheduleTruncate();
    883     const page = document.body && document.body.dataset.page;
    884     // scrollable code blocks must be reachable by keyboard
    885     $$('pre').forEach((el) => { if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); });
    886     // folder pages have no visible heading (the breadcrumb is the title); give assistive tech one
    887     if (page === 'listing' && !$('main h1')) {
    888       const name = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
    889       const main = $('#main');
    890       if (name && main) { const h = document.createElement('h1'); h.className = 'sr-only'; h.textContent = name; main.insertBefore(h, main.firstChild); }
    891     }
    892     if (page === 'history') renderHistory();
    893     $$('.preview__text[data-text-src]').forEach(loadTextPreview);
    894     settleLoadingPreviews();
    895     settleFileView();
    896   }
    897 
    898   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
    899   document.addEventListener('turbo:load', () => {
    900     if (window.Turbo && window.Turbo.setProgressBarDelay && !window.__cdnProgress) { window.__cdnProgress = true; window.Turbo.setProgressBarDelay(0); }
    901     document.body.classList.remove('turbo-loading');
    902     init();
    903   });
    904   document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading'));
    905   document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading'));
    906   document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading'));
    907   document.addEventListener('turbo:render', () => { document.body.classList.remove('turbo-loading'); init(); });
    908   document.addEventListener('turbo:before-cache', () => {
    909     // don't freeze open menus into the cached snapshot
    910     closeMenus(null);
    911     $$('.preview__text[data-state="loading"]').forEach((el) => el.setAttribute('data-state', 'idle'));
    912     const nav = $('#primary-nav'); if (nav) nav.classList.remove('is-open');
    913     const tgl = $('.nav-toggle'); if (tgl) tgl.setAttribute('aria-expanded', 'false');
    914   });
    915 
    916   // Tapping a file in a list: if the server takes more than a beat, show the file-page skeleton right away
    917   // (like GitHub) instead of leaving the old page dimmed. Fast or prefetched visits never flash it.
    918   let tappedFile = '';
    919   let skelTimer = 0;
    920   let savedMain = null;
    921   function showFileSkeleton() {
    922     const main = $('#main'); const tpl = $('#file-skel-tpl');
    923     if (!main || !tpl || savedMain) return;
    924     savedMain = { html: main.innerHTML, cls: main.className };
    925     const node = tpl.content.firstElementChild.cloneNode(true);
    926     node.classList.add('is-standalone');
    927     main.replaceChildren(node);
    928     main.setAttribute('aria-busy', 'true');
    929     window.scrollTo(0, 0);
    930   }
    931   function clearFileSkeleton(restore) {
    932     clearTimeout(skelTimer);
    933     const main = $('#main');
    934     if (restore && savedMain && main) { main.innerHTML = savedMain.html; main.className = savedMain.cls; main.removeAttribute('aria-busy'); }
    935     savedMain = null;
    936   }
    937   // Turbo only follows URLs that look like HTML, so a tap on /assets/app.js (or any file with an extension) would be
    938   // a full page reload: no skeleton, no smooth transition. File pages are ordinary HTML pages, so visit them through
    939   // Turbo's navigator ourselves. Anything unusual (modifier keys, new tab, Turbo missing) is left to the browser.
    940   function turboVisitFile(url) {
    941     const nav = window.Turbo && window.Turbo.session && window.Turbo.session.navigator;
    942     if (!nav || typeof nav.startVisit !== 'function') return false;
    943     try {
    944       // startVisit (unlike proposeVisit/Turbo.visit) skips the "looks like HTML" URL check
    945       const id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + Math.random();
    946       nav.startVisit(url, id);
    947       return true;
    948     } catch (err) { return false; }
    949   }
    950   document.addEventListener('click', (e) => {
    951     const a = e.target instanceof Element && e.target.closest('a.file-link');
    952     tappedFile = '';
    953     if (!a) return;
    954     const url = new URL(a.href, location.href);
    955     tappedFile = url.pathname;
    956     if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    957     if (a.target || url.origin !== location.origin) return;
    958     if (turboVisitFile(url)) e.preventDefault();
    959   }, true);
    960   document.addEventListener('turbo:visit', (e) => {
    961     clearFileSkeleton(true);
    962     const url = e.detail && e.detail.url;
    963     if (!tappedFile || !url) return;
    964     let path = ''; try { path = new URL(url).pathname; } catch (err) { return; }
    965     if (path !== tappedFile) return;
    966     skelTimer = setTimeout(showFileSkeleton, 140);
    967   });
    968   // the new page is about to replace this one: stop the timer, and never let Turbo cache the skeleton
    969   document.addEventListener('turbo:before-cache', () => clearFileSkeleton(true));
    970   document.addEventListener('turbo:before-render', () => { clearTimeout(skelTimer); savedMain = null; tappedFile = ''; });
    971 
    972   if ('serviceWorker' in navigator) {
    973     window.addEventListener('load', () => {
    974       navigator.serviceWorker.register('/static/sw.js').catch((err) => console.error('service worker registration failed:', err));
    975     });
    976   }
    977 })();