cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 2d37174d152c6a15eedcb65fd9c971adda503857
parent 613126629ab21280ab6087e3c5e36a5e3788ae2e
Author: claude-cdn-bot <claude-bot@users.noreply.github.com>
Date:   Wed, 14 Oct 2026 03:09:16 +0000

feat(ui): whole-page file skeleton, wrapping link boxes, file taps go through Turbo

- Link boxes are plain wrapping text instead of read-only inputs: the full URL is always visible, nothing scrolls
  sideways (swiping inside an input scrolled the text and showed only a truncated 'https://raw.cdn...'), a tap still
  selects the whole link, and Copy copies the same text. Embed snippet wraps too.
- File pages show a whole-page skeleton (title, actions, links, preview) and reveal everything at once when the preview
  is ready, capped at 0.7 s so a huge image can't hold the links back. Gated on html[data-js]; no-JS pages are untouched.
- Tapping a file in a list shows the same skeleton after 140 ms if the server is slow (never flashes on fast/prefetched
  visits) and it is never stored in Turbo's snapshot cache, so Back returns the real listing.
- Pre-existing issue fixed: Turbo refuses URLs that don't look like HTML, so every tap on a file with an extension
  (app.js, photo.png) was a full page reload. File-link taps now start a Turbo visit (with a plain-link fallback;
  modifier-clicks and new-tab clicks are left to the browser).
- PDF preview no longer uses loading=lazy (it never loaded while the page was hidden behind the skeleton).
- 15 new browser checks (89 total); repo tests and axe-core (0 violations) pass.

Diffstat:
MREADME.md | 5+++++
Mapp/static/app.css | 30+++++++++++++++++++++++++-----
Mapp/static/app.js | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/templates/_page_file.html | 13+++++++++----
Aapp/templates/_skeleton.html | 18++++++++++++++++++
Mapp/templates/base.html | 2++
6 files changed, 138 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md @@ -239,3 +239,8 @@ jsDelivr. shown as source and never executed on the main origin. - **File-type colours** come from the file name alone (`[data-name$=".png" i]` rules at the end of `app.css`), so server-rendered and browser-built rows look identical without extra placeholders. +- **File pages and Turbo.** Turbo refuses to follow URLs that don't look like HTML (`/assets/app.js`), which would make + every tap on a file a full page reload. `app.js` therefore starts those visits itself (`Turbo.session.navigator.startVisit`, + with a plain-link fallback). A file page shows a whole-page skeleton and reveals everything at once when its preview is + ready (capped at 0.7 s); while a tapped file page is still being fetched, the same skeleton is shown after 140 ms, and is + never put in Turbo's cache. diff --git a/app/static/app.css b/app/static/app.css @@ -662,7 +662,9 @@ html[data-notice="off"] .site-notice { display: none; } .empty-state p { margin: 0; } /* ============================== 7. File page ============================== */ -.file-view { display: grid; gap: 1.5rem; } +.file-view { display: block; } +.file-view__content { display: grid; gap: 1.5rem; } +.file-view.is-revealed .file-view__content { animation: page-in 0.22s var(--ease) both; } .file-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.9rem 1.25rem; @@ -734,18 +736,35 @@ html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is- .link-row__label { font-weight: 600; font-size: var(--fs-md); } .link-row__label small { display: block; color: var(--muted); font-weight: 400; font-size: var(--fs-xs); } .link-row__value { - min-width: 0; width: 100%; padding: 0.45rem 0.7rem; + display: block; min-width: 0; padding: 0.5rem 0.7rem; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: var(--r-sm); - font-family: var(--mono); font-size: var(--fs-xs); text-overflow: ellipsis; + font-family: var(--mono); font-size: var(--fs-xs); line-height: 1.5; + white-space: normal; overflow-wrap: anywhere; word-break: break-all; + -webkit-user-select: all; user-select: all; } -.link-row__value:focus { outline: none; border-color: var(--accent); box-shadow: var(--ring); } -.link-row__code { display: block; overflow-x: auto; white-space: nowrap; padding: 0.45rem 0.7rem; border-radius: var(--r-sm); font-size: var(--fs-xs); } .notfound { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 1rem; padding: 4rem 0 2rem; } .notfound__icon { display: grid; place-items: center; width: 4.5rem; height: 4.5rem; border-radius: 20px; background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning-border); } .notfound__actions { display: flex; gap: 0.6rem; flex-wrap: wrap; justify-content: center; margin-top: 0.5rem; } +/* whole-page skeleton for file pages: shown (JS only) until the preview is ready, then everything appears at once */ +.file-skel { display: none; gap: 1.5rem; } +html[data-js] .file-view.is-pending .file-skel, .file-skel.is-standalone { display: grid; } +html[data-js] .file-view.is-pending .file-view__content { display: none; } +.file-skel__head { display: grid; gap: 0.8rem; } +.sk-title { width: 55%; height: 1.9rem; border-radius: 6px; } +.sk-pills { width: 38%; height: 1.2rem; } +.file-skel__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.sk-action { height: 2.5rem; flex: 1 1 6rem; max-width: 9rem; border-radius: var(--r); } +.file-skel__card, .file-skel__preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); } +.file-skel__card { display: grid; gap: 1.1rem; padding: 1.2rem 1.25rem; } +.file-skel__row { display: grid; gap: 0.55rem; } +.sk-rowlabel { width: 30%; } +.sk-rowbar { width: 100%; height: 2.1rem; border-radius: var(--r-sm); } +.file-skel__preview { display: grid; gap: 0.85rem; align-content: start; min-height: 16rem; padding: 1.1rem 1rem 1.3rem; } +.sk-previewbar { width: 100%; height: 2rem; border-radius: var(--r-sm); margin-bottom: 0.4rem; } + /* ============================== 8. Upload ============================== */ .upload { max-width: 44rem; } .dropzone { @@ -922,6 +941,7 @@ html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is- .file-head__actions .btn { flex: 1 1 0; } .link-row { grid-template-columns: minmax(0, 1fr) auto; } .link-row__label { grid-column: 1 / -1; } + .link-row { align-items: start; } .link-row__label small { display: inline; margin-left: 0.4rem; } .toc ol { columns: 1; } /* small copy buttons become icon-only: the copy glyph is universal, and the link box gets the room back */ diff --git a/app/static/app.js b/app/static/app.js @@ -815,7 +815,9 @@ box.setAttribute('data-state', 'done'); } catch (err) { status("Couldn't load a preview. Use Open raw instead."); - box.setAttribute('data-state', 'idle'); + box.setAttribute('data-state', 'failed'); + } finally { + settleFileView(); } } document.addEventListener('click', async (e) => { @@ -830,6 +832,7 @@ function imageFailed(box) { box.className = 'preview preview--none'; box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>'; + settleFileView(); } document.addEventListener('error', (e) => { const img = e.target; @@ -837,12 +840,28 @@ const box = img.closest('.preview--image'); if (box) imageFailed(box); }, true); + // File pages show a whole-page skeleton and appear at once when the preview is ready, or after a short cap + // so a huge image can never hold the links back. + const REVEAL_CAP_MS = 700; + function revealFileView(view) { + if (!view.classList.contains('is-pending')) return; + view.classList.remove('is-pending'); + view.classList.add('is-revealed'); + view.removeAttribute('aria-busy'); + } + function settleFileView() { + const view = $('.file-view.is-pending'); + if (!view) return; + const waiting = $('.preview__text[data-state="idle"], .preview__text[data-state="loading"], .preview.is-loading', view); + if (!waiting) { revealFileView(view); return; } + if (!view._capTimer) view._capTimer = setTimeout(() => revealFileView(view), REVEAL_CAP_MS); + } // images and PDFs sit behind a shimmer until they have loaded, then appear at once document.addEventListener('load', (e) => { const t = e.target; if (!(t instanceof HTMLImageElement || t instanceof HTMLIFrameElement)) return; const box = t.closest('.preview.is-loading'); - if (box) box.classList.remove('is-loading'); + if (box) { box.classList.remove('is-loading'); settleFileView(); } }, true); function settleLoadingPreviews() { $$('.preview.is-loading').forEach((box) => { @@ -863,7 +882,7 @@ scheduleTruncate(); const page = document.body && document.body.dataset.page; // scrollable code blocks must be reachable by keyboard - $$('pre, .link-row__code').forEach((el) => { if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); }); + $$('pre').forEach((el) => { if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); }); // folder pages have no visible heading (the breadcrumb is the title); give assistive tech one if (page === 'listing' && !$('main h1')) { const name = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || ''); @@ -873,6 +892,7 @@ if (page === 'history') renderHistory(); $$('.preview__text[data-text-src]').forEach(loadTextPreview); settleLoadingPreviews(); + settleFileView(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); @@ -893,6 +913,62 @@ const tgl = $('.nav-toggle'); if (tgl) tgl.setAttribute('aria-expanded', 'false'); }); + // Tapping a file in a list: if the server takes more than a beat, show the file-page skeleton right away + // (like GitHub) instead of leaving the old page dimmed. Fast or prefetched visits never flash it. + let tappedFile = ''; + let skelTimer = 0; + let savedMain = null; + function showFileSkeleton() { + const main = $('#main'); const tpl = $('#file-skel-tpl'); + if (!main || !tpl || savedMain) return; + savedMain = { html: main.innerHTML, cls: main.className }; + const node = tpl.content.firstElementChild.cloneNode(true); + node.classList.add('is-standalone'); + main.replaceChildren(node); + main.setAttribute('aria-busy', 'true'); + window.scrollTo(0, 0); + } + function clearFileSkeleton(restore) { + clearTimeout(skelTimer); + const main = $('#main'); + if (restore && savedMain && main) { main.innerHTML = savedMain.html; main.className = savedMain.cls; main.removeAttribute('aria-busy'); } + savedMain = null; + } + // Turbo only follows URLs that look like HTML, so a tap on /assets/app.js (or any file with an extension) would be + // a full page reload: no skeleton, no smooth transition. File pages are ordinary HTML pages, so visit them through + // Turbo's navigator ourselves. Anything unusual (modifier keys, new tab, Turbo missing) is left to the browser. + function turboVisitFile(url) { + const nav = window.Turbo && window.Turbo.session && window.Turbo.session.navigator; + if (!nav || typeof nav.startVisit !== 'function') return false; + try { + // startVisit (unlike proposeVisit/Turbo.visit) skips the "looks like HTML" URL check + const id = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + Math.random(); + nav.startVisit(url, id); + return true; + } catch (err) { return false; } + } + document.addEventListener('click', (e) => { + const a = e.target instanceof Element && e.target.closest('a.file-link'); + tappedFile = ''; + if (!a) return; + const url = new URL(a.href, location.href); + tappedFile = url.pathname; + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (a.target || url.origin !== location.origin) return; + if (turboVisitFile(url)) e.preventDefault(); + }, true); + document.addEventListener('turbo:visit', (e) => { + clearFileSkeleton(true); + const url = e.detail && e.detail.url; + if (!tappedFile || !url) return; + let path = ''; try { path = new URL(url).pathname; } catch (err) { return; } + if (path !== tappedFile) return; + skelTimer = setTimeout(showFileSkeleton, 140); + }); + // the new page is about to replace this one: stop the timer, and never let Turbo cache the skeleton + document.addEventListener('turbo:before-cache', () => clearFileSkeleton(true)); + document.addEventListener('turbo:before-render', () => { clearTimeout(skelTimer); savedMain = null; tappedFile = ''; }); + if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/static/sw.js').catch((err) => console.error('service worker registration failed:', err)); diff --git a/app/templates/_page_file.html b/app/templates/_page_file.html @@ -1,4 +1,5 @@ {% from "_icons.html" import icon %} +{% from "_skeleton.html" import file_skeleton %} {#- Everything here is built from values the server already has (path, extension, size), so the page costs nothing extra to render. Previews load straight from the raw domain; text is fetched by app.js. -#} {% set raw_url = raw_base_url ~ '/' ~ (path | urlencode) %} @@ -19,6 +20,7 @@ } %} {% for k, exts in kinds.items() %}{% if ext in exts %}{% set kind.v = k %}{% endif %}{% endfor %} +{% set pending = kind.v in ('image', 'pdf', 'text') %} {% set embed = none %} {% if kind.v == 'image' %}{% set embed = '<img src="' ~ raw_url ~ '" alt="">' %} {% elif ext == 'css' %}{% set embed = '<link rel="stylesheet" href="' ~ raw_url ~ '">' %} @@ -27,7 +29,9 @@ {% elif kind.v == 'audio' %}{% set embed = '<audio src="' ~ raw_url ~ '" controls></audio>' %} {% endif %} -<article class="file-view"> +<article class="file-view{{ ' is-pending' if pending }}"{{ ' aria-busy="true"'|safe if pending }}> + {{ file_skeleton() }} + <div class="file-view__content"> <header class="file-head"> <div class="file-head__main"> <h1 class="file-name">{{ filename }}</h1> @@ -49,7 +53,7 @@ {% macro link_row(label, value, hint='') %} <div class="link-row"> <span class="link-row__label">{{ label }}{% if hint %}<small>{{ hint }}</small>{% endif %}</span> - <input class="link-row__value" type="text" readonly value="{{ value }}" aria-label="{{ label }}" data-select-on-focus> + <code class="link-row__value" data-link="{{ label }}">{{ value }}</code> <button type="button" class="btn btn-sm" data-copy="{{ value }}" aria-label="Copy {{ label }}">{{ icon('copy', 14) }}<span>Copy</span></button> </div> {% endmacro %} @@ -59,7 +63,7 @@ {% if embed %} <div class="link-row link-row--code"> <span class="link-row__label">Embed<small>HTML snippet</small></span> - <code class="link-row__code">{{ embed }}</code> + <code class="link-row__value" data-link="Embed">{{ embed }}</code> <button type="button" class="btn btn-sm" data-copy="{{ embed }}" aria-label="Copy embed snippet">{{ icon('copy', 14) }}<span>Copy</span></button> </div> {% endif %} @@ -73,7 +77,7 @@ {% elif kind.v == 'audio' %} <div class="preview__audio">{{ icon('file', 28, 1.5) }}<audio src="{{ raw_url }}" controls preload="metadata"></audio></div> {% elif kind.v == 'pdf' %} - <iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}" loading="lazy"></iframe> + <iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}"></iframe> <p class="preview__hint">Nothing showing? Your browser may not display PDFs inline — <a href="{{ raw_url }}" target="_blank" rel="noopener" data-turbo="false">open it directly</a>.</p> {% elif kind.v == 'text' %} <div class="preview__text is-skeleton" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle" aria-busy="true"> @@ -88,4 +92,5 @@ </div> {% endif %} </section> + </div> </article> diff --git a/app/templates/_skeleton.html b/app/templates/_skeleton.html @@ -0,0 +1,18 @@ +{#- Placeholder shown while a file page is getting ready (see .file-view.is-pending in app.css and the + turbo:visit handler in app.js). Pure decoration: hidden from assistive tech. -#} +{% macro file_skeleton() %} +<div class="file-skel" aria-hidden="true"> + <div class="file-skel__head"> + <span class="sk sk-title"></span> + <span class="sk sk-pills"></span> + <div class="file-skel__actions"><span class="sk sk-action"></span><span class="sk sk-action"></span><span class="sk sk-action"></span></div> + </div> + <div class="file-skel__card"> + {% for i in range(4) %}<div class="file-skel__row"><span class="sk sk-rowlabel"></span><span class="sk sk-rowbar"></span></div>{% endfor %} + </div> + <div class="file-skel__preview"> + <span class="sk sk-previewbar"></span> + {% for w in [58, 82, 40, 70, 92, 34, 66] %}<span class="sk" style="width: {{ w }}%"></span>{% endfor %} + </div> +</div> +{% endmacro %} diff --git a/app/templates/base.html b/app/templates/base.html @@ -1,4 +1,5 @@ {% from "_listing.html" import footer_stats %} +{% from "_skeleton.html" import file_skeleton %} {#- Shared page layout. Child templates set `page_title` (and optionally `page_description`) at the top level and fill `head_extra`, `page_class`, `content` and `scripts`. Everything shared (head, header, breadcrumb, footer, dialogs, toasts) lives here so a change is made once. @@ -99,6 +100,7 @@ </div> </dialog> + <template id="file-skel-tpl">{{ file_skeleton() }}</template> <div id="toasts" class="toasts" role="status" aria-live="polite"></div> {% block scripts %}{% endblock %} </body>