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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 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 & 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 })();