cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 69bb2df5e35b1862672886a267e87f0fc8a2c7b4
parent bccc3407f698280c42edf805591e41f727d7b226
Author: claude-cdn-bot <claude-bot@users.noreply.github.com>
Date:   Tue, 13 Oct 2026 08:20:02 +0000

feat(ui): preview toolbar on top and skeleton loading

- Text previews: the line count and 'Copy contents' now sit in a bar above the code instead of below it.
- Previews load behind a skeleton and are swapped in all at once with a short fade: a bar-and-lines skeleton for
  text, a shimmer box for images and PDFs. Title, actions and Links render immediately; the skeleton only
  covers the preview. aria-busy is set while loading.
- Safety: no-JS pages never hide the preview (the loading state is gated on html[data-js]); a skeleton is never
  left up forever (8 s cap); a Turbo-cached half-finished load is retried.
- 9 new browser checks incl. delayed responses (74 total); axe-core still 0 violations.

Diffstat:
Mapp/static/app.css | 17++++++++++++++++-
Mapp/static/app.js | 43++++++++++++++++++++++++++++++++-----------
Mapp/templates/_page_file.html | 8+++++---
Mapp/templates/base.html | 1+
4 files changed, 54 insertions(+), 15 deletions(-)

diff --git a/app/static/app.css b/app/static/app.css @@ -710,7 +710,22 @@ html[data-notice="off"] .site-notice { display: none; } text-align: right; color: var(--muted); opacity: 0.65; border-right: 1px solid var(--border); user-select: none; -webkit-user-select: none; } -.preview__note { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.55rem 1rem; border-top: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font-size: var(--fs-xs); } +.preview__bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 2.9rem; padding: 0.4rem 0.75rem 0.4rem 1rem; border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font-size: var(--fs-sm); } +.preview__text.is-ready { animation: page-in 0.2s var(--ease) both; } + +/* skeletons: shown while a preview loads, then swapped out in one go */ +.sk { display: block; height: 0.8rem; border-radius: 4px; background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; } +.sk-label { width: 5.5rem; } +.sk-btn { width: 7rem; height: 2rem; border-radius: var(--r-sm); } +.sk-lines { display: grid; gap: 0.85rem; padding: 1.1rem 1rem 1.3rem; min-height: 16rem; align-content: start; } +html[data-js] .preview--image.is-loading, html[data-js] .preview--pdf.is-loading { min-height: 16rem; } +html[data-js] .preview--image.is-loading img { opacity: 0; } +html[data-js] .preview--pdf.is-loading iframe { opacity: 0; } +html[data-js] .preview--image.is-loading::after, html[data-js] .preview--pdf.is-loading::after { + content: ""; position: absolute; inset: 0; z-index: 1; + background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 50%, var(--surface-3) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; +} +.preview--image img, .preview--pdf iframe { transition: opacity 0.2s var(--ease); } .link-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 1.1rem 1.25rem 0.4rem; box-shadow: var(--shadow-sm); } .link-list .section-title { margin-bottom: 0.5rem; } diff --git a/app/static/app.js b/app/static/app.js @@ -767,7 +767,7 @@ async function loadTextPreview(box) { if (box.getAttribute('data-state') === 'done' || box.getAttribute('data-state') === 'loading') return; box.setAttribute('data-state', 'loading'); - const status = (msg) => { box.innerHTML = '<p class="preview__status"></p>'; box.firstChild.textContent = msg; }; + const status = (msg) => { box.innerHTML = '<p class="preview__status"></p>'; box.firstChild.textContent = msg; box.classList.remove('is-skeleton'); box.setAttribute('aria-busy', 'false'); }; try { const res = await fetch(box.getAttribute('data-text-src')); if (!res.ok) throw new Error('HTTP ' + res.status); @@ -802,13 +802,15 @@ const code = document.createElement('code'); lines.forEach((l) => { const s = document.createElement('span'); s.className = 'ln'; s.textContent = l; code.appendChild(s); }); pre.appendChild(code); - box.innerHTML = ''; - box.appendChild(pre); - const note = document.createElement('div'); - note.className = 'preview__note'; - note.innerHTML = '<span></span><button type="button" class="btn btn-sm" data-copy-preview><span>Copy contents</span></button>'; - note.firstChild.textContent = lines.length + ' line' + (lines.length !== 1 ? 's' : '') + (truncated ? ' · preview truncated' : ''); - box.appendChild(note); + const bar = document.createElement('div'); + bar.className = 'preview__bar'; + bar.innerHTML = '<span></span><button type="button" class="btn btn-sm" data-copy-preview><span>Copy contents</span></button>'; + bar.firstChild.textContent = lines.length + ' line' + (lines.length !== 1 ? 's' : '') + (truncated ? ' · preview truncated' : ''); + // everything is built off-screen above, then swapped in at once (replacing the skeleton) + box.replaceChildren(bar, pre); + box.classList.remove('is-skeleton'); + box.classList.add('is-ready'); + box.setAttribute('aria-busy', 'false'); box.setAttribute('data-state', 'done'); } catch (err) { status("Couldn't load a preview. Use Open raw instead."); @@ -824,14 +826,31 @@ if (ok) { flashButton(b, 'Copied'); toast('Contents copied'); } else toast('Could not copy', { type: 'error' }); }); // an image that fails to load (deleted upstream, blocked host) degrades to the plain no-preview state + function imageFailed(box) { + box.className = 'preview preview--none'; + box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>'; + } document.addEventListener('error', (e) => { const img = e.target; if (!(img instanceof HTMLImageElement)) return; const box = img.closest('.preview--image'); - if (!box) return; - box.className = 'preview preview--none'; - box.innerHTML = '<div class="preview__none"><p>This image could not be loaded.</p></div>'; + if (box) imageFailed(box); + }, true); + // 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'); }, true); + function settleLoadingPreviews() { + $$('.preview.is-loading').forEach((box) => { + const img = $('img', box); + if (img && img.complete) { if (img.naturalWidth > 0) box.classList.remove('is-loading'); else imageFailed(box); return; } + // never leave a skeleton up forever (e.g. a PDF viewer that doesn't fire load) + setTimeout(() => box.classList.remove('is-loading'), 8000); + }); + } // --------------------------------------------------------------------------------------------- // lifecycle @@ -852,6 +871,7 @@ } if (page === 'history') renderHistory(); $$('.preview__text[data-text-src]').forEach(loadTextPreview); + settleLoadingPreviews(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); @@ -867,6 +887,7 @@ document.addEventListener('turbo:before-cache', () => { // don't freeze open menus into the cached snapshot closeMenus(null); + $$('.preview__text[data-state="loading"]').forEach((el) => el.setAttribute('data-state', 'idle')); const nav = $('#primary-nav'); if (nav) nav.classList.remove('is-open'); const tgl = $('.nav-toggle'); if (tgl) tgl.setAttribute('aria-expanded', 'false'); }); diff --git a/app/templates/_page_file.html b/app/templates/_page_file.html @@ -65,7 +65,7 @@ {% endif %} </section> - <section class="preview preview--{{ kind.v or 'none' }}" aria-label="Preview"> + <section class="preview preview--{{ kind.v or 'none' }}{{ ' is-loading' if kind.v in ('image', 'pdf') }}" aria-label="Preview"> {% if kind.v == 'image' %} <img src="{{ raw_url }}" alt="Preview of {{ filename }}" decoding="async"> {% elif kind.v == 'video' %} @@ -76,8 +76,10 @@ <iframe src="{{ raw_url }}" title="PDF preview of {{ filename }}" loading="lazy"></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" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle"> - <p class="preview__status">Loading preview…</p> + <div class="preview__text is-skeleton" data-text-src="{{ raw_url }}" data-text-ext="{{ ext }}" data-state="idle" aria-busy="true"> + <div class="preview__bar" aria-hidden="true"><span class="sk sk-label"></span><span class="sk sk-btn"></span></div> + <div class="sk-lines" aria-hidden="true">{% for w in [58, 82, 40, 70, 92, 34, 66, 78, 48, 86, 30, 72] %}<span class="sk" style="width: {{ w }}%"></span>{% endfor %}</div> + <p class="sr-only">Loading preview…</p> </div> {% else %} <div class="preview__none"> diff --git a/app/templates/base.html b/app/templates/base.html @@ -41,6 +41,7 @@ page, so Turbo treats it as already-run. app.js owns the toggle. -#} <script> (function () { + document.documentElement.setAttribute('data-js', ''); try { var t = localStorage.getItem('cdn_theme'); if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';