commit a955b63c439353efaf5df52dc2a8ac435b0bcae6
parent c3dd867870170f79124d75f0ca980d5dc5cf51eb
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Wed, 14 Oct 2026 08:52:18 +0530
Merge pull request #43 from notamitgamer/ui-overhaul
UI overhaul: design system, shared layout, file previews
Diffstat:
26 files changed, 2839 insertions(+), 2796 deletions(-)
diff --git a/README.md b/README.md
@@ -147,8 +147,9 @@ Moving around is meant to feel like a static site. Four things work together:
the pages linked from the current one as soon as the browser is idle.
3. **Browser-cached, gzipped pages.** Rendered pages are identical for everyone, so they are sent with
`Cache-Control: private, max-age=30` and gzip (`app/fast_html.py`). A prefetched page is answered from
- the browser's cache; one that wasn't arrives about 5x smaller (a typical folder listing is ~60 KB of
- HTML, ~12 KB gzipped).
+ the browser's cache; one that wasn't arrives about 5x smaller. The shared stylesheet and script are
+ separate cache-busted files (`static/app.css`, `static/app.js`, ~10 KB and ~12 KB gzipped, cached across
+ every page), so the page HTML itself is only markup: a small folder listing is ~38 KB raw, ~5 KB gzipped.
4. **Fewer storage round trips.** Opening a folder used to cost a wasted `HEAD` request plus the listing.
When the parent folder's listing is cached (it is, because that's the page you clicked from) the `HEAD`
is skipped, and simultaneous requests for one folder share a single Hugging Face call.
@@ -206,8 +207,10 @@ app/
gh_oidc.py GitHub Actions OIDC token verification
shortener.py URL shortener
stats.py /stats
- templates/ Jinja2 pages (index.html renders most page types)
- static/ icons, manifest, service worker, prefetch.js, fasttree.js
+ templates/ Jinja2 pages: base.html (shared layout), index.html + _page_*.html (listing, file, batch,
+ upload, history, search, 404), _listing.html (fragments shared with fasttree.js),
+ _navbar/_breadcrumb/_icons partials, plus documentation / gh_sync_docs / stats / url_shortener
+ static/ app.css + app.js (the whole UI), icons, manifest, service worker, prefetch.js, fasttree.js
tests/ JS unit/DOM/end-to-end tests (node) + offline server checks (python)
Dockerfile · render.yaml · start.sh
```
@@ -223,3 +226,21 @@ governed by the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability
[MIT](LICENSE) © 2026 Amit Dutta. All runtime dependencies are under permissive licenses (MIT, BSD,
Apache-2.0, Unlicense). The only third-party browser asset is [Turbo](https://github.com/hotwired/turbo) (MIT), loaded from
jsDelivr.
+
+## UI notes
+
+- **One stylesheet, one script.** All styling is in `app/static/app.css` (tokens at the top; light/dark via
+ `html[data-theme]`, set before first paint) and all behaviour in `app/static/app.js`. Both are loaded on every
+ page and versioned with a content hash (`?v=`), so a deploy never pairs new HTML with old CSS.
+- **Delegated events only.** Turbo swaps `<body>` on each visit, so `app.js` binds everything on `document`
+ (via `data-action`, `data-copy`, ids) and re-runs an idempotent `init()` on `turbo:load`.
+- **File previews** (`_page_file.html`): images, video, audio and PDF are plain elements loading from the raw
+ domain; text/code is fetched by `app.js` (first 256 KB) and rendered with `textContent`. HTML and SVG-as-text are
+ 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/main.py b/app/main.py
@@ -1,3 +1,5 @@
+import gzip
+import hashlib
import os
import shutil
import time
@@ -16,7 +18,7 @@ from collections import deque
from pathlib import Path
from pydantic import BaseModel
from fastapi import FastAPI, Request, File, UploadFile, HTTPException, Form
-from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse
+from fastapi.responses import StreamingResponse, HTMLResponse, Response, PlainTextResponse, RedirectResponse, FileResponse
from .fast_html import FastTemplates
from .fast_tree import tree_payload, shell_payload, json_response
@@ -50,11 +52,37 @@ templates = FastTemplates(directory="app/templates")
STATIC_DIR = Path(__file__).parent / "static"
_NO_STORE_FILES = {"manifest.json", "sw.js"}
-_REVALIDATE_FILES = {"prefetch.js", "fasttree.js"}
+_REVALIDATE_FILES = {"prefetch.js", "fasttree.js", "app.css", "app.js"}
+
+
+def _asset_version() -> str:
+ """Short content hash of the UI's own stylesheet and script, used as ?v= so a deploy is never paired with stale files."""
+ h = hashlib.sha1()
+ for name in ("app.css", "app.js"):
+ try:
+ h.update((STATIC_DIR / name).read_bytes())
+ except OSError:
+ pass
+ return h.hexdigest()[:10]
+
+
+templates.env.globals["asset_v"] = _asset_version()
+
+
+_GZ_CACHE: dict = {}
+
+
+def _gzipped(path: Path) -> bytes:
+ """gzip of a static file, kept in memory (a few files, keyed by mtime so a redeploy is picked up)."""
+ key = (path.name, path.stat().st_mtime_ns)
+ data = _GZ_CACHE.get(key)
+ if data is None:
+ data = _GZ_CACHE[key] = gzip.compress(path.read_bytes(), 9)
+ return data
@app.get("/static/{filename}")
-async def static_no_cache_root(filename: str):
+async def static_no_cache_root(filename: str, request: Request):
if filename in _NO_STORE_FILES:
cache_control = "no-store, no-cache, must-revalidate, max-age=0"
elif filename in _REVALIDATE_FILES:
@@ -65,6 +93,13 @@ async def static_no_cache_root(filename: str):
file_path = STATIC_DIR / filename
if not file_path.is_file():
raise HTTPException(status_code=404)
+ if filename in _REVALIDATE_FILES and "gzip" in request.headers.get("accept-encoding", "").lower():
+ media = "text/css" if filename.endswith(".css") else "text/javascript"
+ return Response(
+ _gzipped(file_path),
+ media_type=f"{media}; charset=utf-8",
+ headers={"Cache-Control": cache_control, "Content-Encoding": "gzip", "Vary": "Accept-Encoding"},
+ )
return FileResponse(file_path, headers={"Cache-Control": cache_control})
diff --git a/app/static/app.css b/app/static/app.css
@@ -0,0 +1,1102 @@
+/*!
+ * amit/cdn UI — one stylesheet for every page.
+ *
+ * Structure: 1 tokens · 2 base · 3 layout (header, breadcrumb, footer) · 4 controls · 5 feedback
+ * 6 lists · 7 file page · 8 upload · 9 history/search · 10 docs/prose · 11 stats · 12 shortener
+ * 13 responsive · 14 motion/print
+ *
+ * Theming: colours live in tokens. `html[data-theme]` is set before first paint (see base.html); the
+ * prefers-color-scheme block is only the no-JS fallback. Add a colour once, here, for both themes.
+ */
+
+/* ============================== 1. Tokens ============================== */
+:root {
+ color-scheme: light;
+
+ /* surfaces */
+ --bg: #fafaf9;
+ --surface: #ffffff;
+ --surface-2: #f4f4f2;
+ --surface-3: #eaeae6;
+ --border: #e4e4df;
+ --border-strong: #cdcdc5;
+
+ /* text */
+ --text: #1c1c1a;
+ --muted: #62625b;
+
+ /* brand: the logo's blue for actions, its orange as a rare highlight */
+ --accent: #2a62d8;
+ --accent-hover: #2152bd;
+ --accent-solid: #2a62d8;
+ --accent-soft: #eaf0fd;
+ --accent-contrast: #ffffff;
+ --highlight: #ff6a42;
+
+ /* status */
+ --success: #166534;
+ --success-soft: #e8f6ec;
+ --warning: #a14a08;
+ --warning-soft: #fdf3e1;
+ --warning-border: #f0cf9a;
+ --danger: #c0262d;
+ --danger-soft: #fdecec;
+ --danger-border: #f2b8bb;
+
+ /* file-type hues (icons, badges) */
+ --ft-image: #16a34a;
+ --ft-video: #db2777;
+ --ft-audio: #c026d3;
+ --ft-archive: #d97706;
+ --ft-code: #2563eb;
+ --ft-doc: #dc2626;
+ --ft-data: #0d9488;
+ --ft-font: #7c3aed;
+
+ /* shape, depth, motion */
+ --r-sm: 6px;
+ --r: 10px;
+ --r-lg: 14px;
+ --r-pill: 999px;
+ --shadow-sm: 0 1px 2px rgb(24 24 20 / 0.06);
+ --shadow-md: 0 6px 20px rgb(24 24 20 / 0.10);
+ --shadow-lg: 0 18px 48px rgb(24 24 20 / 0.22);
+ --ring: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent);
+ --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
+
+ /* type */
+ --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ --fs-xs: 0.75rem;
+ --fs-sm: 0.8125rem;
+ --fs-md: 0.9375rem;
+ --fs-base: 1rem;
+ --fs-lg: 1.125rem;
+
+ /* layout */
+ --page-w: 960px;
+ --gutter: 1.25rem;
+ --header-h: 3.5rem;
+}
+
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #0f1012;
+ --surface: #17181b;
+ --surface-2: #1e2024;
+ --surface-3: #292c31;
+ --border: #2a2d33;
+ --border-strong: #3b3f47;
+ --text: #ececea;
+ --muted: #a1a19a;
+
+ --accent: #7aa5ff;
+ --accent-hover: #9dbcff;
+ --accent-solid: #2f6bea;
+ --accent-soft: #15233f;
+ --accent-contrast: #ffffff;
+
+ --success: #4ade80;
+ --success-soft: #11251a;
+ --warning: #fbbf24;
+ --warning-soft: #2a2110;
+ --warning-border: #5c4516;
+ --danger: #ff7b72;
+ --danger-soft: #2c1517;
+ --danger-border: #6b2b2f;
+
+ --ft-image: #4ade80;
+ --ft-video: #f472b6;
+ --ft-audio: #e879f9;
+ --ft-archive: #fbbf24;
+ --ft-code: #60a5fa;
+ --ft-doc: #f87171;
+ --ft-data: #2dd4bf;
+ --ft-font: #a78bfa;
+
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
+ --shadow-md: 0 8px 24px rgb(0 0 0 / 0.45);
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.65);
+}
+
+/* no-JS fallback: follow the system when data-theme was never set */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ color-scheme: dark;
+ --bg: #0f1012; --surface: #17181b; --surface-2: #1e2024; --surface-3: #292c31;
+ --border: #2a2d33; --border-strong: #3b3f47; --text: #ececea; --muted: #a1a19a;
+ --accent: #7aa5ff; --accent-hover: #9dbcff; --accent-solid: #2f6bea; --accent-soft: #15233f;
+ --success: #4ade80; --warning: #fbbf24; --danger: #ff7b72;
+ }
+}
+
+/* ============================== 2. Base ============================== */
+*, *::before, *::after { box-sizing: border-box; }
+* { -webkit-tap-highlight-color: transparent; }
+
+html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ scroll-padding-top: calc(var(--header-h) + 1rem);
+ scrollbar-color: var(--border-strong) transparent;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ color: var(--text);
+ font: 400 var(--fs-base) / 1.6 var(--font);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+[hidden] { display: none !important; }
+
+::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); color: inherit; }
+
+h1, h2, h3, h4 { margin: 0; line-height: 1.2; letter-spacing: -0.015em; text-wrap: balance; }
+h1 { font-size: clamp(1.75rem, 1.2rem + 2.2vw, 2.5rem); font-weight: 700; letter-spacing: -0.025em; }
+h2 { font-size: 1.375rem; font-weight: 650; }
+h3 { font-size: var(--fs-lg); font-weight: 650; }
+p { margin: 0 0 1em; }
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 3px; }
+/* links inside running text must not rely on colour alone */
+p a, li a, .lede a, .callout a, .hero h1 a, .footnote a { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
+.hero h1 a { text-decoration-thickness: 0.07em; text-underline-offset: 0.14em; }
+.site-footer p a { text-decoration: underline; text-decoration-color: var(--border-strong); }
+
+code, kbd, samp, pre { font-family: var(--mono); }
+code {
+ font-size: 0.88em;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ padding: 0.1em 0.4em;
+ overflow-wrap: anywhere;
+}
+pre {
+ margin: 0.75rem 0;
+ padding: 1rem 1.1rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ overflow-x: auto;
+ font-size: var(--fs-sm);
+ line-height: 1.6;
+ tab-size: 4;
+}
+pre code { background: none; border: 0; padding: 0; font-size: inherit; }
+
+:focus { outline: none; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+/* controls that draw their own focus indicator (ring or underline) must not also get the generic outline */
+.text-input:focus-visible, .select:focus-visible, .url-input:focus-visible, .filter-input:focus-visible, .search-input:focus-visible, .link-row__value:focus-visible { outline: none; }
+main:focus { outline: none; }
+
+img, svg, video { max-width: 100%; }
+button { font: inherit; color: inherit; }
+
+.sr-only {
+ position: absolute !important;
+ width: 1px; height: 1px;
+ padding: 0; margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.skip-link {
+ position: fixed;
+ top: 0.5rem; left: 0.5rem;
+ z-index: 300;
+ padding: 0.6rem 1rem;
+ background: var(--accent-solid);
+ color: var(--accent-contrast);
+ border-radius: var(--r-sm);
+ font-weight: 600;
+ transform: translateY(-200%);
+}
+.skip-link:focus { transform: none; color: var(--accent-contrast); text-decoration: none; }
+
+/* ============================== 3. Layout ============================== */
+.page {
+ width: 100%;
+ max-width: calc(var(--page-w) + var(--gutter) * 2);
+ margin: 0 auto;
+ padding: 1.5rem var(--gutter) 0;
+ flex: 1 0 auto;
+ animation: page-in 0.22s var(--ease) both;
+}
+body.turbo-loading .page { opacity: 0.55; pointer-events: none; transition: opacity 0.15s; }
+.turbo-progress-bar { height: 3px; background: var(--highlight); }
+
+.page-head { margin-bottom: 1.5rem; }
+.page-head h1 { margin-bottom: 0.4rem; }
+.lede { color: var(--muted); font-size: var(--fs-lg); max-width: 62ch; margin: 0; text-wrap: pretty; }
+.section-title { font-size: var(--fs-lg); font-weight: 650; }
+.hero { margin: 0 0 1rem; }
+.hero h1 { margin-bottom: 0.75rem; font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2rem); }
+
+/* ---- Header ---- */
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: color-mix(in srgb, var(--bg) 86%, transparent);
+ -webkit-backdrop-filter: saturate(1.6) blur(12px);
+ backdrop-filter: saturate(1.6) blur(12px);
+ border-bottom: 1px solid var(--border);
+ padding-top: env(safe-area-inset-top);
+}
+.site-header__inner {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0 0.5rem;
+ max-width: calc(var(--page-w) + var(--gutter) * 2);
+ margin: 0 auto;
+ padding: 0 var(--gutter);
+}
+
+.wordmark {
+ display: inline-flex;
+ align-items: center;
+ min-height: var(--header-h);
+ gap: 0.6rem;
+ order: 0;
+ color: var(--text);
+ font-weight: 700;
+ font-size: var(--fs-lg);
+ letter-spacing: -0.02em;
+ white-space: nowrap;
+}
+.wordmark:hover { color: var(--text); text-decoration: none; }
+.wordmark img { border-radius: 8px; display: block; }
+.wordmark b { color: var(--highlight); font-weight: 800; margin: 0 0.04em; }
+
+.site-header__tools { display: flex; align-items: center; gap: 0.4rem; order: 2; margin-left: 0.25rem; min-height: var(--header-h); }
+.site-nav { display: flex; align-items: center; gap: 0.15rem; order: 1; flex: 1; justify-content: flex-end; }
+
+.nav-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.45rem 0.7rem;
+ border: 0;
+ border-radius: var(--r-sm);
+ background: none;
+ color: var(--muted);
+ font-size: var(--fs-md);
+ font-weight: 500;
+ line-height: 1.3;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+@media (hover: hover) { .nav-link:hover { color: var(--text); background: var(--surface-2); text-decoration: none; } }
+.nav-link.is-active { color: var(--accent); background: var(--accent-soft); }
+.nav-link svg { flex: none; }
+.nav-cta { margin-left: 0.4rem; background: var(--accent-solid); color: var(--accent-contrast); font-weight: 600; }
+@media (hover: hover) { .nav-cta:hover { background: var(--accent-hover); color: var(--accent-contrast); } }
+@media (hover: hover) { :root[data-theme="dark"] .nav-cta:hover { background: #4a7ff0; } }
+.nav-cta.is-active { background: var(--accent-solid); color: var(--accent-contrast); box-shadow: inset 0 0 0 2px color-mix(in srgb, #fff 35%, transparent); }
+
+.more { position: relative; }
+.more__menu {
+ display: none;
+ position: absolute;
+ right: 0;
+ top: calc(100% + 8px);
+ min-width: 11rem;
+ padding: 0.3rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ box-shadow: var(--shadow-md);
+ z-index: 110;
+}
+.more.is-open .more__menu { display: flex; flex-direction: column; gap: 2px; }
+.more.is-open .more__toggle { background: var(--surface-2); color: var(--text); }
+
+.theme-toggle .i-sun { display: none; }
+:root[data-theme="dark"] .theme-toggle .i-sun { display: inline-flex; }
+:root[data-theme="dark"] .theme-toggle .i-moon { display: none; }
+.nav-toggle { display: none; }
+.site-header__tools .quick-action { display: none; }
+.quick-action.is-active { color: var(--accent); background: var(--accent-soft); }
+.quick-action--primary { color: var(--accent); }
+html[data-notice="off"] .site-notice { display: none; }
+.nav-toggle .i-close { display: none; }
+.nav-toggle[aria-expanded="true"] .i-open { display: none; }
+.nav-toggle[aria-expanded="true"] .i-close { display: inline-flex; }
+
+/* ---- Breadcrumb ---- */
+.breadcrumb-container {
+ max-width: calc(var(--page-w) + var(--gutter) * 2);
+ margin: 0 auto;
+ padding: 0.85rem var(--gutter) 0;
+ width: 100%;
+}
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: var(--fs-sm);
+ min-width: 0;
+}
+.breadcrumb a {
+ color: var(--text);
+ padding: 0.15rem 0.35rem;
+ margin: 0 -0.1rem;
+ border-radius: var(--r-sm);
+}
+.breadcrumb > a:first-child { margin-left: -0.35rem; }
+.breadcrumb a:hover { color: var(--accent); background: var(--surface-2); text-decoration: none; }
+.breadcrumb-collapsed { position: relative; display: inline-flex; }
+.breadcrumb-ellipsis, .copy-path-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r-sm);
+ color: var(--muted);
+ cursor: pointer;
+ line-height: 1.4;
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
+}
+.breadcrumb-ellipsis { padding: 0.05rem 0.55rem; font-family: var(--mono); }
+.copy-path-btn { padding: 0.3rem; margin-left: 0.3rem; }
+.breadcrumb-ellipsis:hover, .copy-path-btn:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); }
+.breadcrumb-menu {
+ display: none;
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ min-width: 12rem;
+ max-width: 75vw;
+ max-height: 16rem;
+ overflow-y: auto;
+ flex-direction: column;
+ gap: 2px;
+ padding: 0.3rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ box-shadow: var(--shadow-md);
+ z-index: 60;
+}
+.breadcrumb-menu.open { display: flex; }
+.breadcrumb-menu a { padding: 0.5rem 0.7rem; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* ---- Footer ---- */
+.site-footer { margin-top: 4rem; border-top: 1px solid var(--border); padding-bottom: env(safe-area-inset-bottom); }
+.site-footer__inner {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 0.75rem 2rem;
+ max-width: calc(var(--page-w) + var(--gutter) * 2);
+ margin: 0 auto;
+ padding: 1.5rem var(--gutter) 2rem;
+ color: var(--muted);
+ font-size: var(--fs-sm);
+}
+.site-footer p { margin: 0; }
+.site-footer a { color: var(--text); font-weight: 500; }
+.site-footer a:hover { color: var(--accent); }
+.footer-stats { font-family: var(--mono); font-size: var(--fs-xs); }
+.footer-stats strong { color: var(--text); }
+
+/* ============================== 4. Controls ============================== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-height: 2.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r);
+ font-size: var(--fs-md);
+ font-weight: 500;
+ line-height: 1.2;
+ cursor: pointer;
+ text-decoration: none;
+ box-shadow: var(--shadow-sm);
+ transition: background 0.12s, border-color 0.12s, transform 0.08s, box-shadow 0.12s;
+}
+@media (hover: hover) { .btn:hover:not(:disabled) { background: var(--surface-2); color: var(--text); text-decoration: none; } }
+.btn:active:not(:disabled) { transform: translateY(1px); box-shadow: none; }
+.btn:disabled { opacity: 0.55; cursor: not-allowed; }
+.btn svg { flex: none; }
+.btn-sm { min-height: 2rem; padding: 0.3rem 0.7rem; font-size: var(--fs-sm); border-radius: var(--r-sm); }
+.btn-primary { background: var(--accent-solid); border-color: var(--accent-solid); color: var(--accent-contrast); }
+@media (hover: hover) { .btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-contrast); } }
+@media (hover: hover) { :root[data-theme="dark"] .btn-primary:hover:not(:disabled) { background: #4a7ff0; border-color: #4a7ff0; } }
+.btn-ghost-danger { box-shadow: none; color: var(--muted); }
+@media (hover: hover) { .btn-ghost-danger:hover:not(:disabled) { color: var(--danger); border-color: var(--danger-border); background: var(--danger-soft); } }
+.btn.is-done { color: var(--success); border-color: var(--success); }
+
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--r-sm);
+ color: var(--muted);
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+@media (hover: hover) { .icon-btn:hover { background: var(--surface-2); color: var(--text); } }
+.icon-btn > span { display: inline-flex; align-items: center; justify-content: center; }
+.site-header__tools .nav-toggle { display: none; }
+
+.text-input, .select, .filter-input, .search-input, .url-input {
+ width: 100%;
+ min-height: 2.75rem;
+ padding: 0.6rem 0.9rem;
+ background: var(--surface);
+ color: var(--text);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--r);
+ font: inherit;
+ font-size: var(--fs-md);
+ transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
+}
+.text-input::placeholder, .filter-input::placeholder, .search-input::placeholder, .url-input::placeholder { color: var(--muted); opacity: 0.8; }
+.text-input:focus, .select:focus, .url-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
+.select { width: auto; min-height: 2.5rem; padding-right: 2rem; cursor: pointer; appearance: none; -webkit-appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat; background-position: right 0.75rem center; }
+.select-wrap { position: relative; display: inline-flex; align-items: center; }
+.select-wrap > svg { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
+.listing-toolbar .select { padding-left: 2rem; padding-right: 1.8rem; background-position: right 0.6rem center; }
+
+.input-group { position: relative; display: flex; align-items: center; width: 100%; }
+.input-group__icon { position: absolute; left: 1rem; display: inline-flex; color: var(--muted); pointer-events: none; }
+.input-group .filter-input, .input-group .search-input { padding-left: 2.7rem; padding-right: 2.6rem; }
+.input-clear-btn {
+ position: absolute; right: 0.55rem; top: 50%; transform: translateY(-50%);
+ display: none; align-items: center; justify-content: center;
+ width: 1.9rem; height: 1.9rem; padding: 0;
+ background: none; border: 0; border-radius: 50%;
+ color: var(--muted); cursor: pointer;
+}
+.input-group[data-has-text="true"] .input-clear-btn { display: inline-flex; }
+.input-clear-btn:hover { background: var(--surface-3); color: var(--text); }
+.input-row { display: flex; gap: 0.6rem; }
+.input-row .text-input { flex: 1; min-width: 0; }
+
+.field { margin-bottom: 1.25rem; }
+.field-label { display: block; margin-bottom: 0.45rem; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
+
+.segmented { display: inline-flex; padding: 3px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r); gap: 2px; }
+.segmented__btn {
+ padding: 0.4rem 1rem; border: 0; border-radius: calc(var(--r) - 3px);
+ background: none; color: var(--muted); cursor: pointer;
+ font-family: var(--mono); font-size: var(--fs-sm); font-weight: 600;
+ transition: background 0.12s, color 0.12s;
+}
+.segmented__btn:hover { color: var(--text); }
+.segmented__btn.is-active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
+
+.tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; overflow-x: auto; }
+.tab {
+ padding: 0.7rem 1rem; margin-bottom: -1px; background: none; border: 0;
+ border-bottom: 2px solid transparent; color: var(--muted);
+ font-weight: 550; cursor: pointer; white-space: nowrap;
+}
+.tab:hover { color: var(--text); }
+.tab.is-active { color: var(--text); border-bottom-color: var(--highlight); }
+.tabpanel__head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
+
+/* ============================== 5. Feedback ============================== */
+.callout, .alert {
+ display: flex; gap: 0.7rem; align-items: flex-start;
+ margin: 0 0 1.25rem; padding: 0.85rem 1rem;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--r); font-size: var(--fs-md); line-height: 1.55;
+}
+.callout > svg { flex: none; margin-top: 0.2rem; color: var(--accent); }
+.callout-warning { background: var(--warning-soft); border-color: var(--warning-border); }
+.callout-warning > svg { color: var(--warning); }
+.callout a { font-weight: 550; }
+.alert-danger { background: var(--danger-soft); border-color: var(--danger-border); color: var(--danger); }
+.site-notice {
+ display: flex; align-items: flex-start; gap: 0.6rem;
+ margin: 0 0 1rem; padding: 0.55rem 0.4rem 0.55rem 0.85rem;
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--r);
+ color: var(--muted); font-size: var(--fs-sm); line-height: 1.5;
+}
+.site-notice > svg { flex: none; margin-top: 0.2rem; color: var(--accent); }
+.site-notice > span { flex: 1; min-width: 0; }
+.site-notice a { font-weight: 550; }
+.notice-close { flex: none; display: grid; place-items: center; width: 2rem; height: 2rem; margin: -0.2rem 0 -0.2rem 0; padding: 0; background: none; border: 0; border-radius: var(--r-sm); color: var(--muted); cursor: pointer; }
+.notice-close:hover { background: var(--surface-3); color: var(--text); }
+
+.toasts {
+ position: fixed; z-index: 400;
+ left: 50%; bottom: calc(1.25rem + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
+ width: min(92vw, 26rem); pointer-events: none;
+}
+.toast {
+ display: flex; align-items: center; gap: 0.6rem;
+ width: 100%; padding: 0.7rem 1rem;
+ background: var(--text); color: var(--bg);
+ border-radius: var(--r); box-shadow: var(--shadow-lg);
+ font-size: var(--fs-md); font-weight: 500;
+ animation: toast-in 0.22s var(--ease) both;
+}
+.toast.is-leaving { animation: toast-out 0.18s ease-in both; }
+.toast--error { background: var(--danger); color: #fff; }
+.toast--success svg { color: #4ade80; }
+:root[data-theme="dark"] .toast--success svg { color: #15803d; }
+
+.dialog {
+ width: min(92vw, 28rem); padding: 0;
+ background: var(--surface); color: var(--text);
+ border: 1px solid var(--border-strong); border-radius: var(--r-lg);
+ box-shadow: var(--shadow-lg);
+}
+.dialog::backdrop { background: rgb(8 8 10 / 0.55); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); }
+.dialog[open] { animation: page-in 0.18s var(--ease) both; }
+.dialog__body { padding: 1.5rem; }
+.dialog h2 { font-size: 1.25rem; margin-bottom: 0.75rem; }
+.dialog p { color: var(--muted); margin-bottom: 1.5rem; }
+.dialog p strong { color: var(--text); }
+.dialog__actions { display: flex; justify-content: flex-end; gap: 0.6rem; }
+
+.spin-anim { animation: spin 0.8s linear infinite; }
+.spinner {
+ display: inline-block; width: 14px; height: 14px; vertical-align: -2px;
+ border: 2px solid var(--accent); border-top-color: transparent; border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+/* ============================== 6. Lists ============================== */
+.listing-toolbar {
+ display: flex; align-items: center; justify-content: space-between;
+ flex-wrap: wrap; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem;
+}
+.listing-toolbar .select { min-height: 2.25rem; padding-top: 0.3rem; padding-bottom: 0.3rem; font-size: var(--fs-sm); border-color: var(--border); box-shadow: none; }
+.listing-toolbar .btn { min-height: 2.25rem; padding-top: 0.3rem; padding-bottom: 0.3rem; font-size: var(--fs-sm); }
+.zip-short { display: none; }
+.input-group--standalone { margin-bottom: 0.75rem; }
+.input-group--standalone .filter-input { box-shadow: var(--shadow-sm); }
+.input-group--standalone .filter-input:focus { border-color: var(--accent); box-shadow: var(--ring); }
+.listing-toolbar__counts { margin: 0; color: var(--muted); font-family: var(--mono); font-size: var(--fs-sm); }
+.listing-toolbar__counts strong { color: var(--text); }
+.listing-toolbar__actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
+
+.list-container { margin-bottom: 1.5rem; }
+.list-group {
+ display: flex; flex-direction: column; overflow: hidden;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--r-lg); box-shadow: var(--shadow-sm);
+}
+.list-group .input-group { border-bottom: 1px solid var(--border); background: var(--surface-2); }
+.list-group .filter-input, .list-group .search-input {
+ border: 0; border-radius: 0; background: transparent; box-shadow: none; min-height: 3.1rem;
+}
+.list-group .filter-input:focus, .list-group .search-input:focus { box-shadow: inset 0 -2px 0 var(--accent); }
+.list-group .input-group:focus-within { background: var(--surface); }
+
+.list-item {
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
+ min-height: 3.25rem; padding: 0.55rem 1.1rem;
+ border-bottom: 1px solid var(--border);
+ transition: background 0.1s;
+}
+.list-item:last-child { border-bottom: 0; }
+.list-item:hover { background: var(--surface-2); }
+
+.item-name { display: flex; align-items: center; gap: 0.8rem; flex: 1; min-width: 0; }
+.item-name svg {
+ flex: none; box-sizing: content-box;
+ width: 18px; height: 18px; padding: 7px; border-radius: 9px;
+}
+.item-name svg.folder-icon { color: var(--accent); background: var(--accent-soft); }
+.item-name svg.file-icon-list {
+ color: var(--ft, var(--muted));
+ background: color-mix(in srgb, var(--ft, var(--muted)) 13%, transparent);
+}
+.item-name a {
+ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ font-size: var(--fs-md); padding: 0.45rem 0;
+}
+.item-name a.folder-link { color: var(--text); font-weight: 650; }
+.item-name a.file-link { color: var(--text); font-weight: 500; }
+.item-name a:hover { color: var(--accent); text-decoration: none; }
+
+.item-meta { display: flex; align-items: center; gap: 0.5rem; flex: none; color: var(--muted); font-family: var(--mono); font-size: var(--fs-xs); white-space: nowrap; }
+.download-icon {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 2.25rem; height: 2.25rem; margin: -0.4rem -0.4rem -0.4rem 0;
+ border-radius: var(--r-sm); color: var(--muted);
+ transition: background 0.12s, color 0.12s;
+}
+.download-icon:hover { color: var(--accent); background: var(--accent-soft); text-decoration: none; }
+
+.empty-state { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 3.5rem 1.5rem; color: var(--muted); text-align: center; }
+.empty-state svg { opacity: 0.55; }
+.empty-state p { margin: 0; }
+
+/* ============================== 7. File page ============================== */
+.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;
+}
+.file-name { font-size: clamp(1.35rem, 1rem + 1.6vw, 1.9rem); overflow-wrap: anywhere; letter-spacing: -0.02em; }
+.file-meta-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem 0.5rem; margin: 0.55rem 0 0; }
+.file-pill {
+ padding: 0.1rem 0.6rem; background: var(--surface-2); border: 1px solid var(--border);
+ border-radius: var(--r-pill); color: var(--text);
+ font-family: var(--mono); font-size: var(--fs-xs); font-weight: 650;
+}
+.file-path { color: var(--muted); font-family: var(--mono); font-size: var(--fs-xs); overflow-wrap: anywhere; }
+.file-head__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; }
+
+.preview {
+ position: relative; overflow: hidden;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--r-lg); box-shadow: var(--shadow-sm);
+}
+.preview--image {
+ display: grid; place-items: center; padding: 1rem;
+ background-color: var(--surface-2);
+ background-image:
+ linear-gradient(45deg, var(--surface-3) 25%, transparent 25%, transparent 75%, var(--surface-3) 75%),
+ linear-gradient(45deg, var(--surface-3) 25%, transparent 25%, transparent 75%, var(--surface-3) 75%);
+ background-size: 20px 20px; background-position: 0 0, 10px 10px;
+}
+.preview--image img { display: block; max-width: 100%; max-height: 70vh; width: auto; height: auto; border-radius: var(--r-sm); background: transparent; }
+.preview--video { background: #000; }
+.preview--video video { display: block; width: 100%; max-height: 70vh; }
+.preview__audio { display: flex; align-items: center; gap: 1rem; padding: 1.25rem; color: var(--ft-audio); }
+.preview__audio audio { flex: 1; min-width: 0; }
+.preview--pdf iframe { display: block; width: 100%; height: 70vh; min-height: 24rem; border: 0; background: var(--surface-2); }
+.preview__hint { margin: 0; padding: 0.6rem 1rem; border-top: 1px solid var(--border); color: var(--muted); font-size: var(--fs-sm); }
+.preview__none { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem 1rem; color: var(--muted); }
+.preview__none p { margin: 0; }
+.preview__icon { display: grid; place-items: center; width: 4.5rem; height: 4.5rem; border-radius: 18px; background: var(--surface-2); border: 1px solid var(--border); }
+.preview__status { margin: 0; padding: 2rem 1rem; text-align: center; color: var(--muted); }
+.preview__text pre { margin: 0; border: 0; border-radius: 0; max-height: 34rem; overflow: auto; padding: 0.9rem 0; background: transparent; }
+.preview__text code { display: block; min-width: max-content; counter-reset: ln; }
+.preview__text .ln { display: block; padding: 0 1rem 0 0; white-space: pre; }
+.preview__text .ln::before {
+ counter-increment: ln; content: counter(ln);
+ display: inline-block; width: 3.4rem; margin-right: 0.9rem; padding-right: 0.8rem;
+ text-align: right; color: var(--muted); opacity: 0.65; border-right: 1px solid var(--border);
+ user-select: none; -webkit-user-select: none;
+}
+.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; }
+.link-row { display: grid; grid-template-columns: 9rem minmax(0, 1fr) auto; align-items: center; gap: 0.75rem; padding: 0.7rem 0; border-top: 1px solid var(--border); }
+.link-row:first-of-type { border-top: 0; }
+.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 {
+ 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); line-height: 1.5;
+ white-space: normal; overflow-wrap: anywhere; word-break: break-all;
+ -webkit-user-select: all; user-select: all;
+}
+
+.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; }
+
+.icon-btn:active { background: var(--surface-3); color: var(--text); }
+
+/* ============================== 8. Upload ============================== */
+.upload { max-width: 44rem; }
+.dropzone {
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.35rem;
+ margin-bottom: 1.5rem; padding: 3rem 1.5rem; text-align: center; cursor: pointer;
+ background: var(--surface); color: var(--muted);
+ border: 2px dashed var(--border-strong); border-radius: var(--r-lg);
+ transition: border-color 0.15s, background 0.15s, transform 0.15s;
+}
+.dropzone:hover, .dropzone:focus-within, .dropzone.dragover { border-color: var(--accent); background: var(--accent-soft); }
+.dropzone:focus-within { box-shadow: var(--ring); }
+.dropzone.dragover { transform: scale(1.01); }
+.dropzone__icon { display: grid; place-items: center; width: 3.5rem; height: 3.5rem; margin-bottom: 0.4rem; border-radius: 50%; background: var(--surface-2); color: var(--accent); transition: transform 0.15s; }
+.dropzone.dragover .dropzone__icon { transform: translateY(-3px); }
+.dropzone__title { color: var(--text); font-size: var(--fs-lg); font-weight: 600; }
+.dropzone__sub { font-size: var(--fs-md); }
+.dropzone u { text-decoration-color: var(--accent); text-underline-offset: 3px; color: var(--accent); }
+
+.upload-queue { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
+.upload-queue:empty { display: none; }
+.queue-item {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.25rem 1rem; align-items: center;
+ padding: 0.8rem 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r);
+ box-shadow: var(--shadow-sm); animation: page-in 0.2s var(--ease) both;
+}
+.queue-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; }
+.queue-item__status { color: var(--muted); font-family: var(--mono); font-size: var(--fs-xs); white-space: nowrap; display: inline-flex; align-items: center; gap: 0.4rem; }
+.queue-item__actions { display: flex; gap: 0.6rem; font-size: var(--fs-sm); font-weight: 550; }
+.queue-item.is-done { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
+.queue-item.is-done .queue-item__name { color: var(--success); }
+.queue-item.is-error { border-color: var(--danger-border); background: var(--danger-soft); }
+.queue-item.is-error .queue-item__status { color: var(--danger); }
+.queue-item__bar { grid-column: 1 / -1; height: 4px; margin-top: 0.35rem; background: var(--surface-3); border-radius: 2px; overflow: hidden; }
+.queue-item__bar i { display: block; width: 0; height: 100%; background: var(--accent-solid); border-radius: 2px; transition: width 0.15s linear; }
+.queue-item.is-saving .queue-item__bar i { width: 100% !important; background: linear-gradient(90deg, var(--accent-solid), var(--highlight), var(--accent-solid)); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
+.queue-item--group { grid-template-columns: 1fr; }
+.queue-item--group a { word-break: break-all; font-family: var(--mono); font-size: var(--fs-sm); }
+
+/* ============================== 9. History / search ============================== */
+.search-box { margin-bottom: 1.5rem; }
+.input-group--lg .search-input { min-height: 3.6rem; font-size: var(--fs-lg); }
+.search-status { padding: 0.7rem 1.1rem; border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--muted); font-size: var(--fs-md); }
+.search-status strong { color: var(--text); }
+.result-path { padding: 0 0 0.15rem 3.1rem; margin-top: -0.35rem; color: var(--muted); font-family: var(--mono); font-size: var(--fs-xs); overflow-wrap: anywhere; }
+.list-item--stack { flex-direction: column; align-items: stretch; gap: 0; padding-top: 0.7rem; padding-bottom: 0.7rem; }
+.list-item--stack .row-top { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
+.when { color: var(--muted); font-size: var(--fs-xs); }
+
+/* ============================== 10. Docs / prose ============================== */
+.prose h1 { margin-bottom: 0.4rem; }
+.prose h2 { margin: 2.5rem 0 0.75rem; font-size: 1.4rem; }
+.prose > h2 { padding-top: 1.2rem; border-top: 1px solid var(--border); }
+.toc-title, .prose h3 { margin: 1.2rem 0 0.4rem; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; }
+.prose p, .prose li { color: var(--muted); }
+.prose p { margin: 0.4em 0 0.8em; }
+.prose section { margin-top: 2.75rem; scroll-margin-top: calc(var(--header-h) + 1rem); }
+.prose section > h2 { margin-top: 0; }
+.prose .badge { display: inline-block; margin-left: 0.5rem; padding: 0.1rem 0.55rem; background: var(--accent-soft); color: var(--accent); border-radius: var(--r-pill); font-size: var(--fs-xs); font-weight: 600; vertical-align: middle; }
+.prose ul, .prose ol { padding-left: 1.3em; }
+.prose .lede { margin-bottom: 1.5rem; }
+.footnote { margin-top: 3rem; padding-top: 1.2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: var(--fs-sm); }
+
+.toc { margin: 1.5rem 0 2.5rem; padding: 1.1rem 1.3rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); }
+.toc .toc-title { margin-top: 0; }
+.toc ol { margin: 0; padding-left: 1.2em; columns: 2; column-gap: 2rem; font-size: var(--fs-md); }
+.toc a { color: var(--text); }
+.toc a:hover { color: var(--accent); }
+
+.endpoint { margin-bottom: 1.4rem; padding: 1.3rem 1.4rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); scroll-margin-top: calc(var(--header-h) + 1rem); }
+.endpoint > p:first-of-type { margin-top: 0; }
+.route-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 0.6rem; }
+.method { padding: 0.15rem 0.5rem; border-radius: var(--r-sm); font-family: var(--mono); font-size: var(--fs-xs); font-weight: 750; letter-spacing: 0.04em; }
+.method.get { background: var(--success-soft); color: var(--success); }
+.method.post { background: var(--accent-soft); color: var(--accent); }
+.route-path { font-family: var(--mono); font-size: 1rem; color: var(--text); overflow-wrap: anywhere; }
+.domain-tag { padding: 0.1rem 0.45rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--muted); font-family: var(--mono); font-size: var(--fs-xs); }
+.prose table { width: 100%; margin: 0.5rem 0; border-collapse: collapse; font-size: var(--fs-md); display: block; overflow-x: auto; }
+.prose th, .prose td { padding: 0.45rem 0.7rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
+.prose th { color: var(--muted); font-size: var(--fs-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
+.prose td { color: var(--text); }
+.prose td.desc { color: var(--muted); }
+.err-list { list-style: none; margin: 0.4rem 0; padding: 0 !important; }
+.err-list li { padding: 0.3rem 0; font-size: var(--fs-md); overflow: hidden; }
+.err-code { float: left; width: 3.2em; margin-right: 0.6em; color: var(--danger); font-family: var(--mono); font-weight: 650; }
+.code-block { position: relative; }
+.code-block .copy-code { position: absolute; top: 0.5rem; right: 0.5rem; }
+
+/* ============================== 11. Stats ============================== */
+.page-stats .muted { color: var(--muted); font-size: var(--fs-md); }
+.page-stats h1 { margin: 0 0 0.25rem; }
+.page-stats h2 { font-size: var(--fs-base); margin: 0; font-weight: 650; }
+.page-stats { --c0: #4c8dff; --c1: #3fb950; --c2: #d29922; --c3: #f85149; --c4: #a371f7; --c5: #39c5cf; --c6: #db61a2; --c7: #8b949e; --c8: #e3702d; }
+.page-stats .tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin: 1.25rem 0 1rem; }
+.page-stats .tile, .page-stats .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); }
+.page-stats .tile { padding: 0.9rem 1.1rem; }
+.page-stats .tile b { display: block; font-size: 1.6rem; line-height: 1.2; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
+.page-stats .tile span { color: var(--muted); font-size: var(--fs-sm); }
+.page-stats .grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
+.page-stats .card { padding: 1rem 1.15rem 1.15rem; min-width: 0; }
+.page-stats .card > header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 0.9rem; }
+.page-stats .seg { display: inline-flex; padding: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); }
+.page-stats .seg button { min-height: 1.9rem; padding: 0.25rem 0.8rem; border: 0; border-radius: 4px; background: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; cursor: pointer; }
+.page-stats .seg button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
+.page-stats .donut-wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 1.5rem; }
+.page-stats .donut { position: relative; flex: none; width: 150px; height: 150px; margin: 0 auto; }
+.page-stats .donut svg { width: 100%; height: 100%; transform: rotate(-90deg); }
+.page-stats .donut circle { fill: none; stroke-width: 7; }
+.page-stats .donut .hole { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; line-height: 1.2; }
+.page-stats .donut .hole b { font-size: 1.15rem; font-variant-numeric: tabular-nums; }
+.page-stats .legend { flex: 1 1 200px; display: grid; gap: 0.45rem; margin: 0; padding: 0; list-style: none; font-size: var(--fs-md); }
+.page-stats .legend li { display: flex; align-items: center; gap: 0.5rem; color: var(--text); }
+.page-stats .legend i { flex: none; width: 11px; height: 11px; border-radius: 3px; }
+.page-stats .legend .n { margin-left: auto; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.page-stats .bars { display: grid; gap: 0.7rem; }
+.page-stats .bar .top { display: flex; justify-content: space-between; gap: 0.6rem; font-size: var(--fs-md); }
+.page-stats .bar .top span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.page-stats .bar .top span:last-child { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.page-stats .track { height: 8px; margin-top: 0.3rem; border-radius: 4px; background: var(--surface-3); overflow: hidden; }
+.page-stats .track i { display: block; min-width: 2px; height: 100%; border-radius: 4px; background: var(--accent); }
+.page-stats .cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; padding-top: 18px; }
+.page-stats .col { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; }
+.page-stats .col .v { color: var(--muted); font-size: 0.7rem; font-variant-numeric: tabular-nums; }
+.page-stats .col i { width: 100%; max-width: 44px; min-height: 2px; border-radius: 4px 4px 0 0; background: var(--accent); }
+.page-stats .labels { display: flex; gap: 6px; margin-top: 6px; }
+.page-stats .labels span { flex: 1; min-width: 0; text-align: center; color: var(--muted); font-size: 0.68rem; line-height: 1.25; overflow-wrap: anywhere; }
+.page-stats .files { margin: 0; padding: 0; list-style: none; }
+.page-stats .files li { display: flex; justify-content: space-between; gap: 0.75rem; padding: 0.55rem 0; border-top: 1px solid var(--border); font-size: var(--fs-md); }
+.page-stats .files li:first-child { border-top: 0; padding-top: 0; }
+.page-stats .files a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.page-stats .files span { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
+.page-stats .note { padding: 2.5rem 0; text-align: center; color: var(--muted); }
+.page-stats .note button { margin-top: 0.75rem; padding: 0.5rem 1rem; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r-sm); cursor: pointer; }
+
+/* ============================== 12. Shortener ============================== */
+.shorten > * { max-width: 44rem; }
+.shorten .btn-icon { display: inline-flex; }
+.shorten .btn-icon-spinner { display: none; }
+.shorten .btn.is-loading .btn-icon-default { display: none; }
+.shorten .btn.is-loading .btn-icon-spinner { display: inline-flex; }
+.shorten .url-input { padding-right: 2.6rem; }
+.shorten .actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; }
+.error-box { display: none; margin-top: 1rem; padding: 0.8rem 1rem; background: var(--danger-soft); border: 1px solid var(--danger-border); border-radius: var(--r); color: var(--danger); }
+.result-box { margin-top: 1.5rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-sm); animation: page-in 0.2s var(--ease) both; }
+.result-box h3 { margin-bottom: 0.75rem; }
+.result-url { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.6rem 0.75rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r); }
+.result-url a { min-width: 0; overflow-wrap: anywhere; font-family: var(--mono); font-size: var(--fs-md); }
+.info-text { margin: 0.75rem 0 0; color: var(--muted); font-size: var(--fs-sm); }
+
+/* ============================== 13. Responsive ============================== */
+@media (max-width: 820px) {
+ .site-header__tools .nav-toggle { display: inline-flex; }
+ .site-nav {
+ display: none; order: 3; flex: 1 0 100%;
+ flex-direction: column; align-items: stretch; justify-content: flex-start;
+ gap: 2px; padding: 0.4rem 0 0.9rem;
+ }
+ .site-nav.is-open { display: flex; max-height: calc(100vh - var(--header-h) - env(safe-area-inset-top)); max-height: calc(100dvh - var(--header-h) - env(safe-area-inset-top)); overflow-y: auto; overscroll-behavior: contain; }
+ .site-nav .nav-link { padding: 0.7rem 0.75rem; font-size: var(--fs-base); }
+ .nav-cta { margin: 0.4rem 0 0; justify-content: center; }
+ .more__toggle { display: none; }
+ .more__menu { display: flex; position: static; min-width: 0; padding: 0; flex-direction: column; gap: 2px; background: none; border: 0; box-shadow: none; }
+ .site-header__tools { margin-left: auto; gap: 0.15rem; }
+ .site-header__tools .quick-action { display: inline-flex; }
+ .site-header__tools .icon-btn { width: 2.6rem; height: 2.6rem; }
+ .site-nav .nav-dup { display: none; }
+ .wordmark { order: 0; }
+}
+
+@media (max-width: 640px) {
+ :root { --gutter: 1rem; }
+ .page { padding-top: 1.1rem; }
+ .file-head { grid-template-columns: minmax(0, 1fr); }
+ .file-head__actions { justify-content: stretch; }
+ .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 */
+ .link-row .btn > span, [data-copy-preview] > span { display: none; }
+ .link-row .btn-sm, .preview__bar .btn-sm { width: 2.6rem; min-width: 2.6rem; height: 2.6rem; padding: 0; }
+ .preview__bar { padding-right: 0.5rem; }
+ .sk-btn { width: 2.6rem; height: 2.6rem; }
+ .list-item { padding-left: 0.85rem; padding-right: 0.85rem; }
+ .item-name { gap: 0.65rem; }
+ .listing-toolbar { flex-wrap: nowrap; }
+ .listing-toolbar__counts { flex: 1 1 auto; min-width: 0; line-height: 1.35; }
+ .listing-toolbar__actions { flex: 0 0 auto; flex-wrap: nowrap; gap: 0.4rem; }
+ .listing-toolbar__actions .btn { white-space: nowrap; padding-left: 0.7rem; padding-right: 0.7rem; }
+ .zip-long { display: none; }
+ .zip-short { display: inline; }
+ .listing-toolbar__actions .select { max-width: 7.6rem; }
+ .file-head__actions .btn-primary { flex: 1 1 100%; }
+ .dropzone { padding: 2.25rem 1rem; }
+ .input-row { flex-direction: column; }
+ .page-stats .tiles { grid-template-columns: repeat(2, 1fr); }
+ .result-url { flex-direction: column; align-items: stretch; }
+ .site-footer__inner { align-items: flex-start; flex-direction: column; }
+}
+
+@media (min-width: 760px) {
+ .page-stats .tiles { grid-template-columns: repeat(4, 1fr); }
+ .page-stats .grid { grid-template-columns: 1fr 1fr; }
+ .page-stats .span2 { grid-column: 1 / -1; }
+}
+
+/* ============================== 14. Motion / print ============================== */
+@keyframes spin { to { transform: rotate(360deg); } }
+@keyframes page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
+@keyframes toast-in { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }
+@keyframes toast-out { to { opacity: 0; transform: translateY(6px); } }
+@keyframes shimmer { to { background-position: -200% 0; } }
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
+}
+
+@media print {
+ .site-header, .breadcrumb-container, .site-footer, .toasts, .listing-toolbar__actions, .skip-link { display: none !important; }
+ body { background: #fff; color: #000; }
+ .page { padding: 0; max-width: none; animation: none; }
+}
+
+/* ---- File-type colours: --ft is set from the file name alone, so server-built and browser-built rows match ---- */
+[data-name$=".png" i],
+[data-name$=".jpg" i],
+[data-name$=".jpeg" i],
+[data-name$=".gif" i],
+[data-name$=".webp" i],
+[data-name$=".avif" i],
+[data-name$=".bmp" i],
+[data-name$=".ico" i],
+[data-name$=".svg" i],
+[data-name$=".heic" i],
+[data-name$=".tif" i],
+[data-name$=".tiff" i] { --ft: var(--ft-image); }
+[data-name$=".mp4" i],
+[data-name$=".webm" i],
+[data-name$=".mov" i],
+[data-name$=".mkv" i],
+[data-name$=".avi" i],
+[data-name$=".m4v" i],
+[data-name$=".ogv" i] { --ft: var(--ft-video); }
+[data-name$=".mp3" i],
+[data-name$=".wav" i],
+[data-name$=".ogg" i],
+[data-name$=".oga" i],
+[data-name$=".flac" i],
+[data-name$=".m4a" i],
+[data-name$=".aac" i],
+[data-name$=".opus" i] { --ft: var(--ft-audio); }
+[data-name$=".zip" i],
+[data-name$=".tar" i],
+[data-name$=".gz" i],
+[data-name$=".tgz" i],
+[data-name$=".bz2" i],
+[data-name$=".xz" i],
+[data-name$=".7z" i],
+[data-name$=".rar" i],
+[data-name$=".zst" i] { --ft: var(--ft-archive); }
+[data-name$=".js" i],
+[data-name$=".mjs" i],
+[data-name$=".cjs" i],
+[data-name$=".ts" i],
+[data-name$=".tsx" i],
+[data-name$=".jsx" i],
+[data-name$=".css" i],
+[data-name$=".scss" i],
+[data-name$=".sass" i],
+[data-name$=".less" i],
+[data-name$=".html" i],
+[data-name$=".htm" i],
+[data-name$=".py" i],
+[data-name$=".rb" i],
+[data-name$=".go" i],
+[data-name$=".rs" i],
+[data-name$=".c" i],
+[data-name$=".h" i],
+[data-name$=".cpp" i],
+[data-name$=".hpp" i],
+[data-name$=".cc" i],
+[data-name$=".java" i],
+[data-name$=".kt" i],
+[data-name$=".swift" i],
+[data-name$=".php" i],
+[data-name$=".sh" i],
+[data-name$=".bash" i],
+[data-name$=".zsh" i],
+[data-name$=".bat" i],
+[data-name$=".ps1" i],
+[data-name$=".sql" i],
+[data-name$=".lua" i],
+[data-name$=".r" i],
+[data-name$=".pl" i],
+[data-name$=".vue" i],
+[data-name$=".svelte" i] { --ft: var(--ft-code); }
+[data-name$=".pdf" i],
+[data-name$=".doc" i],
+[data-name$=".docx" i],
+[data-name$=".ppt" i],
+[data-name$=".pptx" i],
+[data-name$=".odt" i],
+[data-name$=".rtf" i],
+[data-name$=".md" i],
+[data-name$=".markdown" i],
+[data-name$=".txt" i],
+[data-name$=".epub" i],
+[data-name$=".rst" i],
+[data-name$=".tex" i] { --ft: var(--ft-doc); }
+[data-name$=".json" i],
+[data-name$=".jsonc" i],
+[data-name$=".csv" i],
+[data-name$=".tsv" i],
+[data-name$=".xml" i],
+[data-name$=".yml" i],
+[data-name$=".yaml" i],
+[data-name$=".toml" i],
+[data-name$=".ini" i],
+[data-name$=".cfg" i],
+[data-name$=".conf" i],
+[data-name$=".xls" i],
+[data-name$=".xlsx" i],
+[data-name$=".db" i],
+[data-name$=".sqlite" i],
+[data-name$=".log" i],
+[data-name$=".map" i] { --ft: var(--ft-data); }
+[data-name$=".woff" i],
+[data-name$=".woff2" i],
+[data-name$=".ttf" i],
+[data-name$=".otf" i],
+[data-name$=".eot" i] { --ft: var(--ft-font); }
diff --git a/app/static/app.js b/app/static/app.js
@@ -0,0 +1,977 @@
+/*!
+ * amit/cdn UI behaviour — loaded once per full page load (head, deferred) on every page.
+ *
+ * Design rules that keep this safe with Turbo and the instant-folder renderer:
+ * - Everything is DELEGATED from `document`. Turbo swaps <body> on every visit, and cached snapshots lose
+ * element-level listeners, so nothing is bound to individual elements.
+ * - Page set-up lives in init(), which is idempotent and runs on DOMContentLoaded and every turbo:load.
+ * - Page markup is built with textContent / escaped strings, never by concatenating raw names into HTML.
+ *
+ * Sections: helpers · theme · header/menus · copy · listing (filter, sort, zip, truncation) · upload ·
+ * history · search · file preview · lifecycle
+ */
+(function () {
+ 'use strict';
+ if (window.__cdnApp) return;
+ window.__cdnApp = true;
+
+ // ---------------------------------------------------------------------------------------------
+ // helpers
+ // ---------------------------------------------------------------------------------------------
+ const $ = (sel, root) => (root || document).querySelector(sel);
+ const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
+ const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ESC[c]);
+ const store = {
+ get(key, fallback) { try { const v = localStorage.getItem(key); return v == null ? fallback : v; } catch (e) { return fallback; } },
+ set(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* private mode */ } },
+ del(key) { try { localStorage.removeItem(key); } catch (e) { /* ignore */ } },
+ json(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } },
+ };
+
+ function formatBytes(n) {
+ if (!n || n < 0) return '0 B';
+ if (n < 1024) return n + ' B';
+ if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
+ return (n / 1073741824).toFixed(2) + ' GB';
+ }
+
+ const ICON = {
+ 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>',
+ 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>',
+ 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>',
+ 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>',
+ 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>',
+ 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>',
+ };
+
+ function toast(message, opts) {
+ const o = opts || {};
+ const region = $('#toasts');
+ if (!region) return;
+ const el = document.createElement('div');
+ el.className = 'toast' + (o.type ? ' toast--' + o.type : '');
+ el.innerHTML = (o.type === 'error' ? ICON.alert : ICON.check) + '<span></span>';
+ el.lastChild.textContent = message;
+ region.appendChild(el);
+ while (region.children.length > 3) region.removeChild(region.firstChild);
+ setTimeout(() => {
+ el.classList.add('is-leaving');
+ setTimeout(() => el.remove(), 200);
+ }, o.ms || 2200);
+ }
+
+ async function copyText(text) {
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch (e) { /* fall through to the legacy path */ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ ta.remove();
+ return ok;
+ } catch (e) { return false; }
+ }
+
+ function flashButton(btn, label) {
+ if (!btn) return;
+ const span = btn.querySelector('span');
+ const prev = span ? span.textContent : null;
+ btn.classList.add('is-done');
+ if (span && label) span.textContent = label;
+ clearTimeout(btn._flash);
+ btn._flash = setTimeout(() => {
+ btn.classList.remove('is-done');
+ if (span && prev != null) span.textContent = prev;
+ }, 1400);
+ }
+
+ function openDialog(id) {
+ const d = document.getElementById(id);
+ if (!d) return null;
+ if (typeof d.showModal === 'function') { if (!d.open) d.showModal(); } else d.setAttribute('open', '');
+ return d;
+ }
+ function closeDialog(d) {
+ if (!d) return;
+ if (typeof d.close === 'function') d.close(); else d.removeAttribute('open');
+ }
+
+ window.CDN = { toast, copyText, formatBytes, esc };
+
+ // ---------------------------------------------------------------------------------------------
+ // theme (the initial value is applied by an inline script in <head>; this owns changes)
+ // ---------------------------------------------------------------------------------------------
+ function syncThemeColor() {
+ const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
+ if (!bg) return;
+ $$('meta[name="theme-color"]').forEach((m) => m.setAttribute('content', bg));
+ }
+ function setTheme(t, persist) {
+ document.documentElement.setAttribute('data-theme', t);
+ if (persist) store.set('cdn_theme', t);
+ syncThemeColor();
+ }
+ try {
+ const mq = matchMedia('(prefers-color-scheme: dark)');
+ const onSystem = (e) => { if (!store.get('cdn_theme')) setTheme(e.matches ? 'dark' : 'light', false); };
+ if (mq.addEventListener) mq.addEventListener('change', onSystem); else if (mq.addListener) mq.addListener(onSystem);
+ } catch (e) { /* matchMedia unavailable */ }
+
+ // ---------------------------------------------------------------------------------------------
+ // header, menus, copy, dialogs: one delegated click handler
+ // ---------------------------------------------------------------------------------------------
+ function closeMenus(except) {
+ $$('.more.is-open').forEach((m) => {
+ if (m === except) return;
+ m.classList.remove('is-open');
+ const b = $('.more__toggle', m); if (b) b.setAttribute('aria-expanded', 'false');
+ });
+ $$('.breadcrumb-menu.open').forEach((m) => {
+ if (except && except.contains(m)) return;
+ m.classList.remove('open');
+ const b = m.parentElement && $('.breadcrumb-ellipsis', m.parentElement); if (b) b.setAttribute('aria-expanded', 'false');
+ });
+ }
+
+ document.addEventListener('click', async (e) => {
+ const t = e.target;
+ if (!(t instanceof Element)) return;
+
+ const act = t.closest('[data-action]');
+ const action = act && act.getAttribute('data-action');
+
+ if (action === 'theme-toggle') {
+ setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark', true);
+ return;
+ }
+ if (action === 'nav-toggle') {
+ const nav = $('#primary-nav');
+ const open = !nav.classList.contains('is-open');
+ nav.classList.toggle('is-open', open);
+ act.setAttribute('aria-expanded', String(open));
+ return;
+ }
+ if (action === 'more-toggle') {
+ const more = act.closest('.more');
+ const open = !more.classList.contains('is-open');
+ closeMenus(open ? more : null);
+ more.classList.toggle('is-open', open);
+ act.setAttribute('aria-expanded', String(open));
+ return;
+ }
+ if (action === 'crumb-menu') {
+ const wrap = act.closest('.breadcrumb-collapsed');
+ const menu = wrap && $('.breadcrumb-menu', wrap);
+ if (!menu) return;
+ const open = !menu.classList.contains('open');
+ closeMenus(open ? wrap : null);
+ menu.classList.toggle('open', open);
+ act.setAttribute('aria-expanded', String(open));
+ return;
+ }
+ if (action === 'copy-path') {
+ const holder = act.closest('.breadcrumb-container');
+ const base = (holder && holder.getAttribute('data-cdn-base')) || location.origin;
+ const ok = await copyText(base.replace(/\/$/, '') + location.pathname);
+ toast(ok ? 'Link copied' : 'Could not copy', ok ? {} : { type: 'error' });
+ return;
+ }
+ if (action === 'dialog-close') {
+ closeDialog(act.closest('dialog'));
+ // an <a data-action="dialog-close"> still follows its href (the zip download)
+ return;
+ }
+ if (action === 'dismiss-notice') {
+ store.set('cdn_notice_off_v1', '1');
+ document.documentElement.setAttribute('data-notice', 'off');
+ return;
+ }
+ if (action === 'clear-history') {
+ store.del(act.getAttribute('data-key'));
+ renderHistory();
+ toast('History cleared');
+ return;
+ }
+
+ // copy buttons: data-copy="text", data-copy-url="page"
+ const copyBtn = t.closest('[data-copy], [data-copy-url]');
+ if (copyBtn) {
+ const text = copyBtn.hasAttribute('data-copy') ? copyBtn.getAttribute('data-copy') : location.href;
+ const ok = await copyText(text);
+ if (ok) { flashButton(copyBtn, 'Copied'); toast('Copied to clipboard'); } else toast('Could not copy', { type: 'error' });
+ return;
+ }
+
+ // click on a dialog's backdrop closes it
+ if (t.tagName === 'DIALOG') { closeDialog(t); return; }
+
+ // click elsewhere closes open menus
+ if (!t.closest('.more') && !t.closest('.breadcrumb-collapsed')) closeMenus(null);
+
+ // listing / batch
+ if (t.closest('#filter-clear')) {
+ const input = $('#filter-input');
+ if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); }
+ return;
+ }
+ if (t.closest('#search-clear')) { clearSearch(); return; }
+ if (t.closest('#url-clear')) { clearShortener(); return; }
+ if (t.closest('#shorten-btn')) { shorten(); return; }
+ const zipBtn = t.closest('#download-zip-btn');
+ if (zipBtn) { e.preventDefault(); onZip(zipBtn); return; }
+
+ // upload
+ const folderBtn = t.closest('#folder-picker [data-folder]');
+ if (folderBtn) {
+ $$('#folder-picker [data-folder]').forEach((b) => {
+ const on = b === folderBtn;
+ b.classList.toggle('is-active', on);
+ b.setAttribute('aria-pressed', String(on));
+ });
+ return;
+ }
+ if (t.closest('#url-upload-btn')) { uploadFromUrl(); return; }
+
+ // history tabs
+ const tab = t.closest('[data-history-tab]');
+ if (tab) { switchTab(Number(tab.getAttribute('data-history-tab')), false); return; }
+ });
+
+ document.addEventListener('focusin', (e) => {
+ const t = e.target;
+ if (t instanceof HTMLInputElement && t.hasAttribute('data-select-on-focus')) t.select();
+ });
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ closeMenus(null);
+ const nav = $('#primary-nav'), tgl = $('.nav-toggle');
+ if (nav && nav.classList.contains('is-open')) { nav.classList.remove('is-open'); if (tgl) { tgl.setAttribute('aria-expanded', 'false'); tgl.focus(); } }
+ return;
+ }
+ // history tabs: arrow keys move between tabs
+ const tabEl = e.target instanceof Element && e.target.closest('[role="tab"]');
+ if (tabEl && (e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'Home' || e.key === 'End')) {
+ const tabs = $$('[role="tab"]', tabEl.parentElement);
+ let i = tabs.indexOf(tabEl);
+ if (e.key === 'ArrowRight') i = (i + 1) % tabs.length;
+ else if (e.key === 'ArrowLeft') i = (i - 1 + tabs.length) % tabs.length;
+ else if (e.key === 'Home') i = 0; else i = tabs.length - 1;
+ e.preventDefault();
+ switchTab(i, true);
+ return;
+ }
+ // "/" focuses the page's main search/filter/url box
+ if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
+ const tag = (document.activeElement && document.activeElement.tagName) || '';
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
+ const target = $('#filter-input') || $('#search-input') || $('#url-input');
+ if (target) { e.preventDefault(); target.focus(); }
+ }
+ });
+
+ // ---------------------------------------------------------------------------------------------
+ // listing: filter, sort, zip, long-name truncation
+ // ---------------------------------------------------------------------------------------------
+ document.addEventListener('input', (e) => {
+ const t = e.target;
+ if (!(t instanceof HTMLInputElement)) return;
+ if (t.id === 'filter-input') {
+ const term = t.value.toLowerCase();
+ const group = $('#filter-group');
+ if (group) { if (t.value.length) group.setAttribute('data-has-text', 'true'); else group.removeAttribute('data-has-text'); }
+ $$('#file-list .list-item[data-name]').forEach((item) => {
+ const name = item.getAttribute('data-name').toLowerCase();
+ if (name === '..') return;
+ item.hidden = !name.includes(term);
+ });
+ scheduleTruncate();
+ } else if (t.id === 'search-input') {
+ onSearchInput(t);
+ } else if (t.id === 'url-input') {
+ const g = $('#shorten-group');
+ if (g) { if (t.value.length) g.setAttribute('data-has-text', 'true'); else g.removeAttribute('data-has-text'); }
+ }
+ });
+
+ document.addEventListener('change', (e) => {
+ const t = e.target;
+ if (t instanceof HTMLSelectElement && t.id === 'sort-select') {
+ store.set('cdn_sort_v1', t.value);
+ applySort();
+ } else if (t instanceof HTMLInputElement && t.id === 'file-input') {
+ if (t.files && t.files.length) handleFiles(t.files);
+ }
+ });
+
+ const SIZE_UNITS = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
+ function rowBytes(row) {
+ const el = $('.size-label', row);
+ const m = el && /([\d.]+)\s*(B|KB|MB|GB|TB)/i.exec(el.textContent);
+ return m ? parseFloat(m[1]) * SIZE_UNITS[m[2].toUpperCase()] : -1;
+ }
+ function applySort() {
+ const list = $('#file-list');
+ const select = $('#sort-select');
+ if (!list || !select) return;
+ const rows = Array.from(list.children).filter((r) => r.classList.contains('list-item'));
+ if (!rows.length) return;
+ // Folders have no size, so a folder with only sub-folders has nothing to size-sort. Disable those options
+ // there, show "Default order" instead of a stale saved choice (without overwriting it), and hide the control
+ // entirely when there is nothing to reorder.
+ const hasSizes = rows.some((r) => rowBytes(r) >= 0);
+ $$('option', select).forEach((o) => { if (o.value.indexOf('size') === 0) o.disabled = !hasSizes; });
+ let mode = store.get('cdn_sort_v1', 'default');
+ if (!hasSizes && mode.indexOf('size') === 0) mode = 'default';
+ select.value = $$('option', select).some((o) => o.value === mode && !o.disabled) ? mode : 'default';
+ const wrap = select.closest('.select-wrap');
+ if (wrap) wrap.hidden = rows.filter((r) => r.getAttribute('data-name') !== '..').length < 2;
+ rows.forEach((r, i) => { if (!r.dataset.i) r.dataset.i = String(i); });
+ const isUp = (r) => r.getAttribute('data-name') === '..';
+ const isDir = (r) => !!$('.folder-link', r) && !$('.size-label', r);
+ const byName = (a, b) => a.getAttribute('data-name').localeCompare(b.getAttribute('data-name'), undefined, { numeric: true, sensitivity: 'base' });
+ const cmp = {
+ 'default': (a, b) => Number(a.dataset.i) - Number(b.dataset.i),
+ 'name': byName,
+ 'name-desc': (a, b) => byName(b, a),
+ 'size-desc': (a, b) => rowBytes(b) - rowBytes(a) || byName(a, b),
+ 'size-asc': (a, b) => rowBytes(a) - rowBytes(b) || byName(a, b),
+ }[select.value] || byName;
+ rows.sort((a, b) => (isUp(b) - isUp(a)) || (isDir(b) - isDir(a)) || cmp(a, b));
+ const frag = document.createDocumentFragment();
+ rows.forEach((r) => frag.appendChild(r));
+ list.insertBefore(frag, list.firstChild);
+ }
+
+ async function onZip(btn) {
+ if (btn.disabled) return;
+ const batchId = btn.getAttribute('data-batch-id');
+ const path = btn.getAttribute('data-path') || '';
+ const statsUrl = batchId ? '/api/pack-info-batch/' + batchId : '/api/pack-info/' + path;
+ const downloadUrl = batchId ? '/api/pack-batch/' + batchId : '/api/pack/' + path;
+ const spinner = $('#zip-spinner');
+ const label = $('#zip-btn-text');
+ const labelHtml = label ? label.innerHTML : '';
+ btn.disabled = true;
+ if (spinner) spinner.style.display = 'block';
+ if (label) label.textContent = 'Calculating…';
+ try {
+ const res = await fetch(statsUrl);
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.detail || 'Failed to get file stats');
+ const what = batchId ? 'this batch' : (path ? path.split('/').pop() : 'root');
+ $('#zip-dialog-text').innerHTML =
+ 'Download <strong>' + esc(what) + '</strong> as a single .zip? It packages <strong>' + esc(data.file_count) +
+ ' file(s)</strong> totalling <strong>' + esc(data.size_str) + '</strong>.';
+ $('#zip-dialog-confirm').href = downloadUrl;
+ openDialog('zip-dialog');
+ } catch (err) {
+ showErrorDialog(err.message);
+ } finally {
+ btn.disabled = false;
+ if (spinner) spinner.style.display = 'none';
+ if (label) label.innerHTML = labelHtml;
+ }
+ }
+ function showErrorDialog(message) {
+ const text = $('#error-dialog-text');
+ if (text && openDialog('error-dialog')) text.textContent = message; else toast(message, { type: 'error' });
+ }
+
+ // Phones: keep the extension visible by truncating the middle of long names (CSS can only cut the end).
+ const MOBILE_BP = 480;
+ let measureCanvas = null;
+ function textWidth(text, font) {
+ if (!measureCanvas) measureCanvas = document.createElement('canvas');
+ const ctx = measureCanvas.getContext('2d');
+ ctx.font = font;
+ return ctx.measureText(text).width;
+ }
+ function middleTruncate(full, maxWidth, font) {
+ if (textWidth(full, font) <= maxWidth) return full;
+ const dot = full.lastIndexOf('.');
+ const hasExt = dot > 0 && dot < full.length - 1;
+ const ext = hasExt ? full.slice(dot) : '';
+ const base = hasExt ? full.slice(0, dot) : full;
+ for (let head = base.length; head > 1; head--) {
+ const cand = base.slice(0, head) + '...' + ext;
+ if (textWidth(cand, font) <= maxWidth) return cand;
+ }
+ for (let end = full.length; end > 1; end--) {
+ const cand = full.slice(0, end) + '...';
+ if (textWidth(cand, font) <= maxWidth) return cand;
+ }
+ return full.slice(0, 1) + '...';
+ }
+ function applyTruncation() {
+ const names = $$('.item-name');
+ if (!names.length) return;
+ const mobile = window.innerWidth <= MOBILE_BP;
+ names.forEach((box) => {
+ const a = box.querySelector('a[data-full-name]');
+ const full = a && a.getAttribute('data-full-name');
+ if (!a || !full) return;
+ if (!mobile) { if (a.textContent !== full) a.textContent = full; return; }
+ const width = box.clientWidth;
+ if (!width) return;
+ const icon = box.querySelector('svg');
+ const gap = parseFloat(getComputedStyle(box).columnGap) || 0;
+ const avail = width - (icon ? icon.getBoundingClientRect().width : 0) - gap - 4;
+ if (avail <= 0) return;
+ const cs = getComputedStyle(a);
+ const out = middleTruncate(full, avail, cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily);
+ if (a.textContent !== out) a.textContent = out;
+ if (!a.title) a.title = full;
+ });
+ }
+ let truncRaf = 0;
+ function scheduleTruncate() {
+ cancelAnimationFrame(truncRaf);
+ truncRaf = requestAnimationFrame(() => requestAnimationFrame(applyTruncation));
+ }
+ window.scheduleTruncate = scheduleTruncate;
+
+ function applyPlaceholders() {
+ const mobile = window.innerWidth <= MOBILE_BP;
+ const f = $('#filter-input'); if (f) f.placeholder = mobile ? 'Search this folder...' : 'Search this folder... (press / to focus)';
+ const s = $('#search-input'); if (s) s.placeholder = mobile ? 'Find files...' : 'Find a file anywhere in the CDN...';
+ }
+ let resizeTimer = 0;
+ window.addEventListener('resize', () => {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(() => { scheduleTruncate(); applyPlaceholders(); }, 120);
+ });
+
+ // ---------------------------------------------------------------------------------------------
+ // upload
+ // ---------------------------------------------------------------------------------------------
+ const MAX_FILE_BYTES = 200 * 1024 * 1024;
+
+ function recordUpload(item) {
+ try {
+ const entries = store.json('cdn_uploads_v1');
+ 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() });
+ store.set('cdn_uploads_v1', JSON.stringify(entries.slice(0, 50)));
+ } catch (e) { /* ignore */ }
+ // a new file exists now: refresh the cached tree so instant folder navigation shows it
+ if (window.__cdnFast) window.__cdnFast.refresh(true);
+ }
+ const selectedFolder = () => { const a = $('#folder-picker .is-active'); return a ? a.getAttribute('data-folder') : 'uploads'; };
+ function uploadError(msg) { const box = $('#error-box'); if (!box) return; box.textContent = msg; box.hidden = false; }
+ function clearUploadError() { const box = $('#error-box'); if (box) { box.hidden = true; box.textContent = ''; } }
+
+ function queueItem(name) {
+ const li = document.createElement('li');
+ li.className = 'queue-item';
+ li.innerHTML = '<span class="queue-item__name"></span><span class="queue-item__status"></span><div class="queue-item__bar"><i></i></div>';
+ li.firstChild.textContent = name;
+ li.setAttribute('title', name);
+ $('#results').prepend(li);
+ return li;
+ }
+ function setStatus(li, html) { $('.queue-item__status', li).innerHTML = html; }
+ function markDone(li, item) {
+ li.className = 'queue-item is-done';
+ li.innerHTML = '<span class="queue-item__name"></span><span class="queue-item__actions"><a href="/' + esc(item.hf_path) + '">View</a>' +
+ '<a href="#" data-copy="' + esc(item.cdn_url || (location.origin + '/' + item.hf_path)) + '">Copy link</a></span>';
+ li.firstChild.textContent = item.filename;
+ const copy = $('[data-copy]', li);
+ copy.addEventListener('click', (ev) => ev.preventDefault());
+ }
+ function markFailed(li, msg) {
+ li.className = 'queue-item is-error';
+ $('.queue-item__bar', li) && $('.queue-item__bar', li).remove();
+ setStatus(li, ICON.alert + '<span>' + esc(msg || 'Failed') + '</span>');
+ }
+
+ function handleFiles(fileList) {
+ clearUploadError();
+ const all = Array.from(fileList);
+ const files = all.filter((f) => f.size <= MAX_FILE_BYTES);
+ const rejected = all.filter((f) => f.size > MAX_FILE_BYTES).map((f) => f.name);
+ if (rejected.length) uploadError('Skipped ' + rejected.length + ' file(s) over ' + formatBytes(MAX_FILE_BYTES) + ': ' + rejected.join(', '));
+ if (!files.length) return;
+
+ const rows = new Map();
+ files.forEach((f) => {
+ const li = queueItem(f.name);
+ setStatus(li, formatBytes(f.size) + ' · waiting…');
+ rows.set(f.name, li);
+ });
+
+ const form = new FormData();
+ files.forEach((f) => form.append('files', f));
+ form.append('folder', selectedFolder());
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/put', true);
+ xhr.upload.onprogress = (ev) => {
+ if (!ev.lengthComputable) return;
+ const pct = Math.round((ev.loaded / ev.total) * 100);
+ rows.forEach((li) => {
+ if (pct >= 100) { li.classList.add('is-saving'); setStatus(li, '<span class="spinner"></span><span>saving…</span>'); } else {
+ setStatus(li, pct + '%');
+ const bar = $('.queue-item__bar i', li); if (bar) bar.style.width = pct + '%';
+ }
+ });
+ };
+ xhr.onload = () => {
+ const fileInput = $('#file-input'); if (fileInput) fileInput.value = '';
+ if (xhr.status >= 200 && xhr.status < 300) {
+ let data;
+ try { data = JSON.parse(xhr.responseText); } catch (e) { uploadError('Invalid response from server.'); return; }
+ const rowList = Array.from(rows.values());
+ (data.files || []).forEach((item, idx) => {
+ recordUpload(item);
+ // match by name; if the server renamed the file, fall back to upload order so no row hangs on "waiting"
+ const li = rows.get(item.filename) || rowList[idx];
+ if (li) markDone(li, item);
+ });
+ if (data.batch_url) {
+ const li = document.createElement('li');
+ li.className = 'queue-item queue-item--group is-done';
+ li.innerHTML = '<span class="queue-item__status">All ' + esc((data.files || []).length) + ' files uploaded. Group link:</span>' +
+ '<a href="' + esc(data.batch_url) + '" target="_blank" rel="noopener">' + esc(data.batch_url) + '</a>';
+ $('#results').prepend(li);
+ }
+ toast(files.length === 1 ? 'Upload complete' : files.length + ' files uploaded');
+ } else {
+ let msg = 'Upload failed.';
+ try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) { /* keep default */ }
+ uploadError(msg);
+ rows.forEach((li) => markFailed(li, 'Failed'));
+ }
+ };
+ xhr.onerror = () => { uploadError('Connection error.'); rows.forEach((li) => markFailed(li, 'Failed')); };
+ xhr.send(form);
+ }
+
+ async function uploadFromUrl() {
+ const input = $('#url-input');
+ const btn = $('#url-upload-btn');
+ if (!input || !btn) return;
+ const url = input.value.trim();
+ clearUploadError();
+ if (!url) return;
+ let label;
+ try { label = decodeURIComponent(new URL(url).pathname.split('/').pop()) || url; } catch (e) { uploadError("That doesn't look like a valid URL."); return; }
+ const li = queueItem(label);
+ setStatus(li, '<span class="spinner"></span><span>fetching & saving…</span>');
+ li.classList.add('is-saving');
+ btn.disabled = true;
+ try {
+ const resp = await fetch('/api/put-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, folder: selectedFolder() }) });
+ const data = await resp.json().catch(() => ({}));
+ if (!resp.ok) throw new Error(data.detail || 'Upload from URL failed.');
+ const item = data.files[0];
+ recordUpload(item);
+ markDone(li, item);
+ input.value = '';
+ toast('Upload complete');
+ } catch (err) {
+ markFailed(li, 'Failed');
+ uploadError(err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ }
+
+ // drag & drop (delegated: the page can be swapped by Turbo at any time)
+ document.addEventListener('dragover', (e) => { const z = e.target instanceof Element && e.target.closest('#dropzone'); if (z) { e.preventDefault(); z.classList.add('dragover'); } });
+ document.addEventListener('dragleave', (e) => { const z = e.target instanceof Element && e.target.closest('#dropzone'); if (z && !z.contains(e.relatedTarget)) z.classList.remove('dragover'); });
+ document.addEventListener('drop', (e) => {
+ const z = e.target instanceof Element && e.target.closest('#dropzone');
+ if (!z) return;
+ e.preventDefault();
+ z.classList.remove('dragover');
+ if (e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
+ });
+ document.addEventListener('paste', (e) => {
+ if (document.body.dataset.page !== 'upload') return;
+ const t = e.target;
+ if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
+ const files = e.clipboardData && e.clipboardData.files;
+ if (files && files.length) { e.preventDefault(); handleFiles(files); }
+ });
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && e.target instanceof HTMLInputElement && e.target.id === 'url-input') {
+ const page = document.body.dataset.page;
+ if (page === 'upload') { e.preventDefault(); uploadFromUrl(); } else if (page === 'shorten') { e.preventDefault(); shorten(); }
+ }
+ });
+
+ // ---------------------------------------------------------------------------------------------
+ // url shortener
+ // ---------------------------------------------------------------------------------------------
+ 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;
+ function isPrivateHost(raw) { try { return PRIVATE_HOST_RE.test(new URL(raw).hostname); } catch (e) { return false; } }
+ function shortenError(msg) { const b = $('#error-box'); if (!b) return; b.textContent = msg; b.style.display = msg ? 'block' : 'none'; }
+ function clearShortener() {
+ const input = $('#url-input'); const group = $('#shorten-group'); const result = $('#result');
+ if (input) { input.value = ''; input.focus(); }
+ if (group) group.removeAttribute('data-has-text');
+ shortenError('');
+ if (result) result.hidden = true;
+ }
+ async function shorten() {
+ const input = $('#url-input'); const btn = $('#shorten-btn'); const result = $('#result');
+ if (!input || !btn || btn.disabled) return;
+ const url = input.value.trim();
+ shortenError('');
+ if (result) result.hidden = true;
+ if (!url) { shortenError('Please enter a URL.'); return; }
+ if (!/^https:\/\//i.test(url)) { shortenError('Only https:// URLs are supported.'); return; }
+ if (isPrivateHost(url)) { shortenError('Localhost and private network URLs cannot be shortened.'); return; }
+ btn.disabled = true;
+ btn.classList.add('is-loading');
+ try {
+ const res = await fetch('/api/shorten', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.detail || 'Failed to shorten URL.');
+ const link = $('#masked-link');
+ link.href = '/mask/' + data.id;
+ link.textContent = data.masked_url;
+ $('#copy-btn').setAttribute('data-copy', data.masked_url);
+ result.hidden = false;
+ } catch (err) {
+ shortenError(err.message || 'An unexpected error occurred.');
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove('is-loading');
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // history
+ // ---------------------------------------------------------------------------------------------
+ function relTime(ts) {
+ if (!ts) return '';
+ const mins = Math.round((Date.now() - ts) / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return mins + 'm ago';
+ if (mins < 1440) return Math.round(mins / 60) + 'h ago';
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ }
+ function historyRow(opts) {
+ const dl = opts.download
+ ? '<a href="/api/get/' + esc(opts.download) + '" class="download-icon" title="Download" aria-label="Download ' + esc(opts.name) + '" data-turbo="false">' + ICON.download + '</a>' : '';
+ return '<div class="list-item" data-name="' + esc(opts.name) + '"><div class="item-name">' + (opts.dir ? ICON.folder : ICON.file) +
+ '<a href="' + esc(opts.href) + '" class="' + (opts.dir ? 'folder-link' : 'file-link') + '" data-full-name="' + esc(opts.name) + '">' + esc(opts.name) + '</a></div>' +
+ '<div class="item-meta">' + (opts.size ? '<span class="size-label">' + esc(opts.size) + '</span>' : '') +
+ '<span class="when">' + esc(relTime(opts.ts)) + '</span>' + dl + '</div></div>';
+ }
+ function renderHistory() {
+ const up = $('#history-uploads-list');
+ const vis = $('#history-visited-list');
+ if (up) {
+ const items = store.json('cdn_uploads_v1');
+ up.innerHTML = items.length
+ ? 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('')
+ : '<div class="empty-state"><p>No uploads logged yet on this browser.</p></div>';
+ }
+ if (vis) {
+ const items = store.json('cdn_visited_v1');
+ vis.innerHTML = items.length
+ ? items.map((i) => {
+ const dir = i.type === 'folder';
+ const name = dir ? String(i.path).split('/').pop() + '/' : (i.filename || String(i.path).split('/').pop());
+ return historyRow({ name, href: '/' + i.path, ts: i.ts, dir, download: dir ? '' : i.path });
+ }).join('')
+ : '<div class="empty-state"><p>No visited links logged yet.</p></div>';
+ }
+ scheduleTruncate();
+ }
+ function switchTab(index, focus) {
+ const tabs = $$('[data-history-tab]');
+ if (!tabs.length) return;
+ tabs.forEach((t, i) => {
+ const on = i === index;
+ t.classList.toggle('is-active', on);
+ t.setAttribute('aria-selected', String(on));
+ t.tabIndex = on ? 0 : -1;
+ const panel = document.getElementById(t.getAttribute('aria-controls'));
+ if (panel) panel.hidden = !on;
+ if (on && focus) t.focus();
+ });
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // search
+ // ---------------------------------------------------------------------------------------------
+ let searchTimer = 0, searchCtl = null, searchSeq = 0;
+ function searchStatus(html) {
+ const el = $('#search-status');
+ if (!el) return;
+ el.hidden = !html;
+ el.innerHTML = html || '';
+ }
+ function clearSearch() {
+ const input = $('#search-input'); const group = $('#search-group'); const results = $('#search-results');
+ if (input) { input.value = ''; input.focus(); }
+ if (group) group.removeAttribute('data-has-text');
+ if (results) results.innerHTML = '';
+ searchStatus('');
+ }
+ function renderResults(items) {
+ const box = $('#search-results');
+ if (!box) return;
+ if (!items.length) { box.innerHTML = ''; searchStatus('No matching files found. Try a different term.'); return; }
+ searchStatus('<strong>' + items.length + '</strong> result' + (items.length !== 1 ? 's' : ''));
+ box.innerHTML = items.map((it) => {
+ const name = it.is_dir ? it.name + '/' : it.name;
+ 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>';
+ 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) +
+ '<a href="/' + esc(it.path) + '" class="' + (it.is_dir ? 'folder-link' : 'file-link') + '" data-full-name="' + esc(name) + '">' + esc(name) + '</a></div>' +
+ '<div class="item-meta">' + (it.is_dir ? '' : '<span class="size-label">' + esc(it.size_str) + '</span>' + dl) + '</div></div>' +
+ '<div class="result-path">' + esc(it.path) + '</div></div>';
+ }).join('');
+ scheduleTruncate();
+ }
+ function onSearchInput(input) {
+ const term = input.value.trim();
+ const group = $('#search-group');
+ clearTimeout(searchTimer);
+ if (group) { if (input.value.length) group.setAttribute('data-has-text', 'true'); else group.removeAttribute('data-has-text'); }
+ if (!term) { const r = $('#search-results'); if (r) r.innerHTML = ''; searchStatus(''); return; }
+ searchStatus('<span class="spinner"></span> Searching…');
+ searchTimer = setTimeout(() => {
+ const seq = ++searchSeq;
+ if (searchCtl) searchCtl.abort();
+ searchCtl = new AbortController();
+ fetch('/api/find?q=' + encodeURIComponent(term), { signal: searchCtl.signal })
+ .then((r) => r.json())
+ .then((d) => { if (seq === searchSeq) renderResults(d.results || []); })
+ .catch((err) => {
+ if (err.name === 'AbortError' || seq !== searchSeq) return;
+ const r = $('#search-results'); if (r) r.innerHTML = '';
+ searchStatus('Search failed. Please try again.');
+ });
+ }, 300);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // file preview (text). Images, video, audio and PDF are plain elements rendered by the server.
+ // ---------------------------------------------------------------------------------------------
+ const PREVIEW_BYTES = 256 * 1024;
+ const PREVIEW_LINES = 5000;
+
+ 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; 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);
+ const reader = res.body && res.body.getReader ? res.body.getReader() : null;
+ let bytes, truncated = false;
+ if (reader) {
+ const chunks = []; let total = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value); total += value.length;
+ if (total >= PREVIEW_BYTES) { truncated = true; reader.cancel().catch(() => {}); break; }
+ }
+ bytes = new Uint8Array(Math.min(total, PREVIEW_BYTES));
+ let off = 0;
+ 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; }
+ } else {
+ const buf = new Uint8Array(await res.arrayBuffer());
+ truncated = buf.length > PREVIEW_BYTES;
+ bytes = truncated ? buf.subarray(0, PREVIEW_BYTES) : buf;
+ }
+ const text = new TextDecoder('utf-8').decode(bytes);
+ if (text.indexOf('\u0000') !== -1) { status("This file doesn't look like text, so there's no preview."); box.setAttribute('data-state', 'done'); return; }
+ if (!text.trim()) { status('This file is empty.'); box.setAttribute('data-state', 'done'); return; }
+
+ let lines = text.split(/\r?\n/);
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
+ if (lines.length > PREVIEW_LINES) { lines = lines.slice(0, PREVIEW_LINES); truncated = true; }
+ // build with textContent so file contents can never become markup
+ const pre = document.createElement('pre');
+ pre.setAttribute('aria-label', 'File contents');
+ 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);
+ const bar = document.createElement('div');
+ bar.className = 'preview__bar';
+ 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>';
+ 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.");
+ box.setAttribute('data-state', 'failed');
+ } finally {
+ settleFileView();
+ }
+ }
+ document.addEventListener('click', async (e) => {
+ const b = e.target instanceof Element && e.target.closest('[data-copy-preview]');
+ if (!b) return;
+ const code = $('code', b.closest('.preview__text'));
+ const text = code ? Array.from(code.children).map((s) => s.textContent).join('\n') : '';
+ const ok = await copyText(text);
+ 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>';
+ settleFileView();
+ }
+ document.addEventListener('error', (e) => {
+ const img = e.target;
+ if (!(img instanceof HTMLImageElement)) return;
+ 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'); settleFileView(); }
+ }, 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
+ // ---------------------------------------------------------------------------------------------
+ function init() {
+ syncThemeColor();
+ applyPlaceholders();
+ applySort();
+ scheduleTruncate();
+ const page = document.body && document.body.dataset.page;
+ // scrollable code blocks must be reachable by keyboard
+ $$('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() || '');
+ const main = $('#main');
+ if (name && main) { const h = document.createElement('h1'); h.className = 'sr-only'; h.textContent = name; main.insertBefore(h, main.firstChild); }
+ }
+ if (page === 'history') renderHistory();
+ $$('.preview__text[data-text-src]').forEach(loadTextPreview);
+ settleLoadingPreviews();
+ settleFileView();
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+ document.addEventListener('turbo:load', () => {
+ if (window.Turbo && window.Turbo.setProgressBarDelay && !window.__cdnProgress) { window.__cdnProgress = true; window.Turbo.setProgressBarDelay(0); }
+ document.body.classList.remove('turbo-loading');
+ init();
+ });
+ document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading'));
+ document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading'));
+ document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading'));
+ document.addEventListener('turbo:render', () => { document.body.classList.remove('turbo-loading'); init(); });
+ 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');
+ });
+
+ // 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/static/manifest.json b/app/static/manifest.json
@@ -5,8 +5,8 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
- "background_color": "#000000",
- "theme_color": "#000000",
+ "background_color": "#fafaf9",
+ "theme_color": "#fafaf9",
"orientation": "any",
"lang": "en",
"icons": [
diff --git a/app/templates/_breadcrumb.html b/app/templates/_breadcrumb.html
@@ -0,0 +1,46 @@
+{% from "_listing.html" import crumb_container, crumb_link, crumb_collapsed, copy_btn %}
+{#- Breadcrumb trail under the header. Folder pages get theirs from the instant-folders shell, which is why
+ the skeleton leaves a slot here instead of rendering it. -#}
+{% if skeleton %}<!--@breadcrumb-->{% elif not (page == 'listing' and not path) %}
+ {% set current = namespace(val='') %}
+ {% set ns = namespace(items=[{'name': 'root', 'href': '/'}]) %}
+ {% if path %}
+ {% set path_parts = path.split('/') %}
+ {% if page == 'file' %}{% set path_parts = path_parts[:-1] %}{% endif %}
+ {% for part in path_parts %}
+ {% set current.val = current.val + part + '/' %}
+ {% set ns.items = ns.items + [{'name': part, 'href': '/' + current.val[:-1]}] %}
+ {% endfor %}
+ {% endif %}
+ {% set leaf = {'upload': ('new', '/new'), 'search': ('find', '/find'), 'history': ('history', '/history'),
+ 'stats': ('stats', '/stats'), 'documentation': ('documentation', '/documentation'),
+ 'gh-sync': ('gh-sync', '/gh-sync'), 'shorten': ('shorten', '/shorten')} %}
+ {% if page in leaf %}
+ {% set ns.items = ns.items + [{'name': leaf[page][0], 'href': leaf[page][1]}] %}
+ {% elif page == 'file' %}
+ {% set ns.items = ns.items + [{'name': filename, 'href': none}] %}
+ {% endif %}
+
+ {% set crumbs %}
+ {% if ns.items|length > 4 %}
+ {% set hidden = ns.items[1:-2] %}
+ {{ crumb_link(ns.items[0].href, ns.items[0].name) }}
+ {% set menu %}
+ {% for c in hidden %}
+ {{ crumb_link(c.href, c.name) }}
+ {% endfor %}
+ {% endset %}
+ {{ crumb_collapsed(menu) }}
+ {% for c in ns.items[-2:] %}
+ / {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
+ {% endfor %}
+ {% else %}
+ {% for c in ns.items %}
+ {% if loop.first %}{{ crumb_link(c.href, c.name) }}
+ {% else %}/ {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+ {% endset %}
+ {{ crumb_container(crumbs, cdn_base_url, copy_btn(path) if (path or page == 'file') else '') }}
+{% endif %}
diff --git a/app/templates/_icons.html b/app/templates/_icons.html
@@ -0,0 +1,32 @@
+{#- One place for the UI's inline SVG icons (Feather-style, 24x24 grid, stroked with currentColor).
+ Usage: {{ icon('search') }} or {{ icon('copy', 14) }}. Decorative by default (aria-hidden). -#}
+{% set _paths = {
+ 'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
+ 'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
+ 'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
+ 'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
+ 'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
+ 'bars': '<line x1="6" y1="20" x2="6" y2="12"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="9"/>',
+ 'sync': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
+ 'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
+ 'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
+ 'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
+ 'moon': '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
+ 'menu': '<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>',
+ 'close': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
+ 'copy': '<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"/>',
+ 'check': '<polyline points="20 6 9 17 4 12"/>',
+ 'folder': '<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"/>',
+ 'file': '<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"/>',
+ 'external': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
+ 'info': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
+ 'alert': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
+ 'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
+ 'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
+ 'arrow-up': '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
+ 'sort': '<path d="M7 4v16M7 4L4 7M7 4l3 3"/><path d="M17 20V4M17 20l-3-3M17 20l3-3"/>',
+ 'loader': '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
+} %}
+{% macro icon(name, size=16, width=2) -%}
+<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="{{ width }}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">{{ _paths[name]|safe }}</svg>
+{%- endmacro %}
diff --git a/app/templates/_listing.html b/app/templates/_listing.html
@@ -3,18 +3,26 @@
(static/fasttree.js). Each fragment is written once, here. The server calls these macros with real
values; /api/shell calls them with @@placeholders@@ that the browser fills in from the cached tree.
Change markup here and both stay in step (tests/test_fast_tree.py checks they do).
+
+ Rules for editing: keep every macro's signature; only put data in via the macro arguments; keep the
+ hooks the scripts use (ids, data-name / data-full-name / data-path, data-action).
-#}
+{% from "_icons.html" import icon %}
{% macro root_header() %}
- <h1>A small file shelf for <a href="https://amit.is-a.dev">my</a> projects.</h1>
- <blockquote class="site-notice">
- Files here can now be synced straight from GitHub on every push — click
- <a href="/gh-sync">Sync</a> at the top to see how to set it up. Project assets live here;
- files and paths may change over time, so use direct links with that in mind.
- </blockquote>
+ <section class="hero">
+ <h1>A small file shelf for <a href="https://amit.is-a.dev">my</a> projects.</h1>
+ <p class="site-notice" role="note">
+ {{ icon('info', 16) }}
+ <span>Files here can now be synced straight from GitHub on every push — click
+ <a href="/gh-sync">Sync</a> at the top to see how to set it up. Project assets live here;
+ files and paths may change over time, so use direct links with that in mind.</span>
+ <button type="button" class="notice-close" data-action="dismiss-notice" aria-label="Dismiss this notice" title="Dismiss">{{ icon('close', 14, 2.5) }}</button>
+ </p>
+ </section>
{% endmacro %}
-{% macro count(n, word, s) %}<strong style="color: var(--text-main);">{{ n }}</strong> {{ word }}{{ s }}{% endmacro %}
+{% macro count(n, word, s) %}<strong>{{ n }}</strong> {{ word }}{{ s }}{% endmacro %}
{% macro counts(dir_count, file_count) %}
{% if dir_count > 0 %}
@@ -31,31 +39,34 @@
{% macro toolbar(counts_html, path) %}
<div class="listing-toolbar">
- <div style="color: var(--text-dim);">
-
+ <p class="listing-toolbar__counts">
{{ counts_html }}
+ </p>
+ <div class="listing-toolbar__actions">
+ <label class="select-wrap" title="Sort this folder">
+ {{ icon('sort', 14) }}
+ <span class="sr-only">Sort by</span>
+ <select id="sort-select" class="select" data-action="sort">
+ <option value="default">Default</option>
+ <option value="name">Name A–Z</option>
+ <option value="name-desc">Name Z–A</option>
+ <option value="size-desc">Largest</option>
+ <option value="size-asc">Smallest</option>
+ </select>
+ </label>
+ <button id="download-zip-btn" type="button" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip" aria-label="Download everything in this folder as a .zip">
+ <svg id="zip-spinner" class="spin-anim" style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
+ {{ icon('download') }}
+ <span id="zip-btn-text"><span class="zip-long">Download all (.zip)</span><span class="zip-short">.zip</span></span>
+ </button>
</div>
- <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip">
- <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <line x1="12" y1="2" x2="12" y2="6"></line>
- <line x1="12" y1="18" x2="12" y2="22"></line>
- <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
- <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
- <line x1="2" y1="12" x2="6" y2="12"></line>
- <line x1="18" y1="12" x2="22" y2="12"></line>
- <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
- <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line>
- </svg>
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
- <span id="zip-btn-text">Download all (.zip)</span>
- </button>
</div>
{% endmacro %}
{% macro go_up(parent_path) %}
<div class="list-item" data-name="..">
<div class="item-name">
- <svg class="folder-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
+ <svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
<a href="{{ parent_path }}" class="folder-link">Go up a directory</a>
</div>
</div>
@@ -64,7 +75,7 @@
{% macro row_dir(name, path) %}
<div class="list-item" data-name="{{ name }}">
<div class="item-name">
- <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"><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>
+ <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>
<a href="/{{ path }}" class="folder-link" data-full-name="{{ name }}/">{{ name }}/</a>
</div>
<div class="item-meta">
@@ -75,13 +86,13 @@
{% macro row_file(name, path, size_str) %}
<div class="list-item" data-name="{{ name }}">
<div class="item-name">
- <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"><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>
+ <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>
<a href="/{{ path }}" class="file-link" data-full-name="{{ name }}">{{ name }}</a>
</div>
<div class="item-meta">
<span class="size-label">{{ size_str }}</span>
- <a href="/api/get/{{ path }}" class="download-icon" title="Download" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
+ <a href="/api/get/{{ path }}" class="download-icon" title="Download" aria-label="Download {{ name }}" data-turbo="false">
+ <svg xmlns="http://www.w3.org/2000/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>
</a>
</div>
</div>
@@ -89,23 +100,24 @@
{% macro empty_state() %}
<div class="empty-state">
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
- <span>This directory is empty. <a href="/new" style="text-decoration: underline;">Upload a file</a> to get started.</span>
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>
+ <p>This folder is empty.</p>
+ <a href="/new" class="btn btn-sm">Upload a file</a>
</div>
{% endmacro %}
{% macro footer_stats(n, s, size_str) %}
- <div title="Across the whole repo">
- <strong style="color: var(--text-main);">{{ n }}</strong> file{{ s }} · {{ size_str }} total
+ <div class="footer-stats" title="Across the whole repo">
+ <strong>{{ n }}</strong> file{{ s }} · {{ size_str }} total
</div>
{% endmacro %}
{% macro crumb_container(inner, cdn_base, tail) %}
<div class="breadcrumb-container" data-cdn-base="{{ cdn_base }}">
- <div class="breadcrumb" id="breadcrumb">
+ <nav class="breadcrumb" id="breadcrumb" aria-label="Breadcrumb">
{{ inner }}
{{ tail }}
- </div>
+ </nav>
</div>
{% endmacro %}
@@ -113,7 +125,7 @@
{% macro crumb_collapsed(menu) %}
<div class="breadcrumb-collapsed">
- <button type="button" class="breadcrumb-ellipsis" onclick="event.stopPropagation(); toggleBreadcrumbMenu(this)" aria-label="Show hidden path segments">…</button>
+ <button type="button" class="breadcrumb-ellipsis" data-action="crumb-menu" aria-expanded="false" aria-label="Show hidden path segments">…</button>
<div class="breadcrumb-menu">
{{ menu }}
</div>
@@ -121,8 +133,8 @@
{% endmacro %}
{% macro copy_btn(path) %}
- <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}">
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
+ <button type="button" class="copy-path-btn" title="Copy link to this location" aria-label="Copy link to this location" data-action="copy-path" data-path="{{ path }}">
+ <svg xmlns="http://www.w3.org/2000/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>
</button>
{% endmacro %}
diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html
@@ -1,139 +1,43 @@
-{% from "_listing.html" import crumb_container, crumb_link, crumb_collapsed, copy_btn %}
-{% include "_styles.html" %}
-<style>
-/* Navbar + breadcrumb. Uses the shared --ui- tokens from _styles.html. */
-.navbar-wrapper {
- position: sticky; top: 0; z-index: 100;
- background: var(--ui-bg);
- border-bottom: 1px solid var(--ui-border);
- margin-bottom: 1.5rem;
- font-family: var(--ui-font);
-}
-.navbar {
- display: flex; align-items: center; justify-content: space-between; gap: 1rem;
- max-width: var(--ui-width); margin: 0 auto; padding: 0.75rem 1rem;
-}
-.wordmark { font-weight: 600; font-size: 1rem; color: var(--ui-text) !important; text-decoration: none !important; white-space: nowrap; }
-.navlinks { display: flex; align-items: center; gap: 0.25rem; }
-.navlinks a {
- display: inline-flex; align-items: center; gap: 0.4rem;
- padding: 0.4rem 0.65rem; border-radius: var(--ui-radius);
- color: var(--ui-muted); font-size: 0.9rem; text-decoration: none; white-space: nowrap;
-}
-.navlinks a svg { flex-shrink: 0; }
-.navlinks a:hover { color: var(--ui-text); background: var(--ui-surface); }
-.navlinks a.nav-admin { padding: 0.4rem 0.5rem; margin-left: 0.25rem; border: 1px solid var(--ui-border); }
-.navlinks a.nav-admin span { display: none; }
-.navlinks a.active { color: var(--ui-accent); background: var(--ui-accent-bg); font-weight: 500; }
-.nav-toggle {
- display: none; align-items: center; justify-content: center;
- width: 2.25rem; height: 2.25rem; padding: 0;
- background: transparent; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);
- color: var(--ui-text); cursor: pointer;
-}
-.nav-toggle:hover { background: var(--ui-surface); }
-.nav-toggle .icon-close { display: none; }
-.nav-toggle[aria-expanded="true"] .icon-open { display: none; }
-.nav-toggle[aria-expanded="true"] .icon-close { display: block; }
-@media (max-width: 720px) {
- .navbar { flex-wrap: wrap; }
- .nav-toggle { display: inline-flex; }
- .navlinks { display: none; flex-basis: 100%; flex-direction: column; align-items: stretch; gap: 0.15rem; padding-top: 0.5rem; }
- .navlinks.open { display: flex; }
- .navlinks a { padding: 0.65rem 0.75rem; font-size: 1rem; }
- .navlinks a.nav-admin { padding: 0.65rem 0.75rem; margin-left: 0; border: 0; }
- .navlinks a.nav-admin span { display: inline; }
-}
-.breadcrumb-container { max-width: var(--ui-width); margin: 0 auto; padding: 0 1rem 0.75rem; }
-.breadcrumb {
- display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
- color: var(--ui-muted); font-family: var(--ui-mono); font-size: 0.85rem;
-}
-.breadcrumb a { color: var(--ui-accent); text-decoration: none; }
-.breadcrumb a:hover { text-decoration: underline; }
-.breadcrumb-collapsed { position: relative; display: inline-flex; }
-.breadcrumb-ellipsis, .copy-path-btn {
- background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius);
- color: var(--ui-muted); cursor: pointer; font: inherit; line-height: 1.4;
-}
-.breadcrumb-ellipsis { padding: 0.1rem 0.6rem; }
-.copy-path-btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.3rem; margin-left: 0.4rem; }
-.breadcrumb-ellipsis:hover, .copy-path-btn:hover { color: var(--ui-text); border-color: var(--ui-muted); }
-.breadcrumb-menu {
- display: none; position: absolute; top: calc(100% + 6px); left: 0;
- min-width: 180px; max-width: 70vw; max-height: 260px; overflow-y: auto;
- flex-direction: column; gap: 0.15rem; padding: 0.35rem; z-index: 20;
- background: var(--ui-bg); border: 1px solid var(--ui-border); border-radius: var(--ui-radius);
-}
-.breadcrumb-menu.open { display: flex; }
-.breadcrumb-menu a { padding: 0.5rem 0.75rem; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.breadcrumb-menu a:hover { background: var(--ui-surface); text-decoration: none; }
-</style>
-<div class="navbar-wrapper">
- <header class="navbar">
- <a href="/" class="wordmark">amit/cdn</a>
- <button type="button" class="nav-toggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="primary-nav" onclick="var o=document.getElementById('primary-nav').classList.toggle('open');this.setAttribute('aria-expanded',o)"><svg class="icon-open" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg><svg class="icon-close" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
- <nav class="navlinks" id="primary-nav" aria-label="Primary">
- <a href="/find" class="{{ 'active' if page == 'search' }}"{{ ' aria-current="page"'|safe if page == 'search' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span>Find</span></a>
- <a href="/new" class="{{ 'active' if page == 'upload' }}"{{ ' aria-current="page"'|safe if page == 'upload' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg><span>Upload</span></a>
- <a href="/shorten" class="{{ 'active' if page == 'shorten' }}"{{ ' aria-current="page"'|safe if page == 'shorten' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg><span>Shorten</span></a>
- <a href="/history" class="{{ 'active' if page == 'history' }}"{{ ' aria-current="page"'|safe if page == 'history' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span>History</span></a>
- <a href="/stats" class="{{ 'active' if page == 'stats' }}"{{ ' aria-current="page"'|safe if page == 'stats' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="20" x2="6" y2="12"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="9"/></svg><span>Stats</span></a>
- <a href="/gh-sync" class="{{ 'active' if page == 'gh-sync' }}"{{ ' aria-current="page"'|safe if page == 'gh-sync' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>Sync</span></a>
- <a href="/documentation" class="{{ 'active' if page == 'documentation' }}"{{ ' aria-current="page"'|safe if page == 'documentation' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg><span>Docs</span></a>
- <a href="/admin" class="nav-admin" title="Admin" aria-label="Admin"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></a>
- </nav>
- </header>
- {% if skeleton %}<!--@breadcrumb-->{% elif not (page == 'listing' and not path) %}
- {% set current = namespace(val='') %}
- {% set ns = namespace(items=[{'name': 'root', 'href': '/'}]) %}
- {% if path %}
- {% set path_parts = path.split('/') %}
- {% if page == 'file' %}{% set path_parts = path_parts[:-1] %}{% endif %}
- {% for part in path_parts %}
- {% set current.val = current.val + part + '/' %}
- {% set ns.items = ns.items + [{'name': part, 'href': '/' + current.val[:-1]}] %}
- {% endfor %}
- {% endif %}
- {% if page == 'upload' %}
- {% set ns.items = ns.items + [{'name': 'new', 'href': '/new'}] %}
- {% elif page == 'search' %}
- {% set ns.items = ns.items + [{'name': 'find', 'href': '/find'}] %}
- {% elif page == 'history' %}
- {% set ns.items = ns.items + [{'name': 'history', 'href': '/history'}] %}
- {% elif page == 'stats' %}
- {% set ns.items = ns.items + [{'name': 'stats', 'href': '/stats'}] %}
- {% elif page == 'documentation' %}
- {% set ns.items = ns.items + [{'name': 'documentation', 'href': '/documentation'}] %}
- {% elif page == 'gh-sync' %}
- {% set ns.items = ns.items + [{'name': 'gh-sync', 'href': '/gh-sync'}] %}
- {% elif page == 'shorten' %}
- {% set ns.items = ns.items + [{'name': 'shorten', 'href': '/shorten'}] %}
- {% elif page == 'file' %}
- {% set ns.items = ns.items + [{'name': filename, 'href': none}] %}
- {% endif %}
+{% from "_icons.html" import icon %}
+{#- Site header. `page` marks the active link. Keep this free of per-request data so the "instant folders"
+ shell (api/shell) and the server render stay identical. -#}
+{% macro nav_link(href, key, label, icon_name, extra='') -%}
+<a href="{{ href }}" class="nav-link {{ extra }}{{ ' is-active' if page == key }}"{{ ' aria-current="page"'|safe if page == key }}>{{ icon(icon_name) }}<span>{{ label }}</span></a>
+{%- endmacro %}
+<header class="site-header">
+ <div class="site-header__inner">
+ <a href="/" class="wordmark" aria-label="amit/cdn, home">
+ <img src="/static/icons/logo.svg" alt="" width="28" height="28" decoding="async">
+ <span class="wordmark__text">amit<b>/</b>cdn</span>
+ </a>
+
+ <div class="site-header__tools">
+ <a href="/find" class="icon-btn quick-action{{ ' is-active' if page == 'search' }}" aria-label="Find a file" title="Find"{{ ' aria-current="page"'|safe if page == 'search' }}>{{ icon('search', 18) }}</a>
+ <a href="/new" class="icon-btn quick-action quick-action--primary{{ ' is-active' if page == 'upload' }}" aria-label="Upload" title="Upload"{{ ' aria-current="page"'|safe if page == 'upload' }}>{{ icon('upload', 18) }}</a>
+ <button type="button" class="icon-btn theme-toggle" data-action="theme-toggle" aria-label="Switch colour theme" title="Switch colour theme">
+ <span class="i-sun">{{ icon('sun', 18) }}</span><span class="i-moon">{{ icon('moon', 18) }}</span>
+ </button>
+ <button type="button" class="icon-btn nav-toggle" data-action="nav-toggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="primary-nav">
+ <span class="i-open">{{ icon('menu', 20) }}</span><span class="i-close">{{ icon('close', 20) }}</span>
+ </button>
+ </div>
- {% set crumbs %}
- {% if ns.items|length > 4 %}
- {% set hidden = ns.items[1:-2] %}
- {{ crumb_link(ns.items[0].href, ns.items[0].name) }}
- {% set menu %}
- {% for c in hidden %}
- {{ crumb_link(c.href, c.name) }}
- {% endfor %}
- {% endset %}
- {{ crumb_collapsed(menu) }}
- {% for c in ns.items[-2:] %}
- / {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
- {% endfor %}
- {% else %}
- {% for c in ns.items %}
- {% if loop.first %}{{ crumb_link(c.href, c.name) }}
- {% else %}/ {% if c.href %}{{ crumb_link(c.href, c.name) }}{% else %}{{ c.name }}{% endif %}
- {% endif %}
- {% endfor %}
- {% endif %}
- {% endset %}
- {{ crumb_container(crumbs, cdn_base_url, copy_btn(path) if (path or page == 'file') else '') }}
- {% endif %}
-</div>
+ <nav class="site-nav" id="primary-nav" aria-label="Primary">
+ {{ nav_link('/find', 'search', 'Find', 'search', 'nav-dup') }}
+ {{ nav_link('/shorten', 'shorten', 'Shorten', 'link') }}
+ {{ nav_link('/gh-sync', 'gh-sync', 'Sync', 'sync') }}
+ {{ nav_link('/documentation', 'documentation', 'Docs', 'book') }}
+ <div class="more">
+ <button type="button" class="nav-link more__toggle{{ ' is-active' if page in ('history', 'stats') }}" data-action="more-toggle" aria-expanded="false" aria-haspopup="true" aria-controls="more-menu">
+ <span>More</span>{{ icon('chevron-down', 14) }}
+ </button>
+ <div class="more__menu" id="more-menu">
+ {{ nav_link('/history', 'history', 'History', 'clock') }}
+ {{ nav_link('/stats', 'stats', 'Stats', 'bars') }}
+ <a href="/admin" class="nav-link">{{ icon('shield') }}<span>Admin</span></a>
+ </div>
+ </div>
+ {{ nav_link('/new', 'upload', 'Upload', 'upload', 'nav-cta nav-dup') }}
+ </nav>
+ </div>
+</header>
diff --git a/app/templates/_page_404.html b/app/templates/_page_404.html
@@ -0,0 +1,10 @@
+{% from "_icons.html" import icon %}
+<section class="notfound">
+ <div class="notfound__icon">{{ icon('alert', 36, 1.5) }}</div>
+ <h1>404 — Not found</h1>
+ <p class="lede">The file or directory you're looking for doesn't exist. It may have been moved, renamed or removed.</p>
+ <div class="notfound__actions">
+ <a href="/" class="btn btn-primary">Go to root</a>
+ <a href="/find" class="btn">{{ icon('search') }}<span>Find a file</span></a>
+ </div>
+</section>
diff --git a/app/templates/_page_batch.html b/app/templates/_page_batch.html
@@ -0,0 +1,27 @@
+{% from "_listing.html" import row_file %}
+{% from "_icons.html" import icon %}
+<section class="page-head">
+ <h1>Batch upload</h1>
+ <p class="lede">A group of files uploaded together. Share this page to hand them all over at once.</p>
+</section>
+<div class="listing-toolbar">
+ <p class="listing-toolbar__counts"><strong>{{ batch_files | length }}</strong> file{{ 's' if batch_files | length != 1 else '' }} in this batch</p>
+ <div class="listing-toolbar__actions">
+ <button type="button" class="btn" data-copy-url="page">{{ icon('copy') }}<span>Copy batch link</span></button>
+ <button id="download-zip-btn" type="button" class="btn btn-primary" data-batch-id="{{ batch_id }}" title="Download every file in this batch as a .zip">
+ <svg id="zip-spinner" class="spin-anim" style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
+ {{ icon('download') }}
+ <span id="zip-btn-text"><span class="zip-long">Download all (.zip)</span><span class="zip-short">.zip</span></span>
+ </button>
+ </div>
+</div>
+<div class="list-container">
+ <div class="list-group" id="file-list">
+ {% for f in batch_files %}
+ {{ row_file(f.filename, f.hf_path, f.size_str) }}
+ {% endfor %}
+ {% if not batch_files %}
+ <div class="empty-state"><p>This batch has no files.</p></div>
+ {% endif %}
+ </div>
+</div>
diff --git a/app/templates/_page_file.html b/app/templates/_page_file.html
@@ -0,0 +1,96 @@
+{% 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) %}
+{% set page_url = cdn_base_url ~ '/' ~ (path | urlencode) %}
+{% set dl_url = cdn_base_url ~ '/api/get/' ~ (path | urlencode) %}
+{% set ext = file_ext or '' %}
+
+{% set kind = namespace(v='') %}
+{% set kinds = {
+ 'image': ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'],
+ 'video': ['mp4', 'webm', 'mov', 'm4v', 'ogv'],
+ 'audio': ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'opus'],
+ 'pdf': ['pdf'],
+ 'text': ['txt', 'md', 'markdown', 'rst', 'json', 'jsonc', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'scss', 'sass', 'less',
+ 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'csv', 'tsv', 'log', 'py', 'rb', 'go', 'rs', 'c', 'h',
+ 'cpp', 'hpp', 'cc', 'java', 'kt', 'swift', 'php', 'sh', 'bash', 'zsh', 'fish', 'bat', 'ps1', 'sql', 'lua', 'r', 'pl',
+ 'vue', 'svelte', 'diff', 'patch', 'tex', 'srt', 'vtt', 'gitignore', 'map'],
+} %}
+{% 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 ~ '">' %}
+{% elif ext in ('js', 'mjs') %}{% set embed = '<script src="' ~ raw_url ~ '"></script>' %}
+{% elif kind.v == 'video' %}{% set embed = '<video src="' ~ raw_url ~ '" controls></video>' %}
+{% elif kind.v == 'audio' %}{% set embed = '<audio src="' ~ raw_url ~ '" controls></audio>' %}
+{% endif %}
+
+<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>
+ <p class="file-meta-row">
+ {% if ext %}<span class="file-pill">{{ ext | upper }}</span>{% endif %}
+ {% if file_size_str and file_size_str != '-' %}<span class="file-pill">{{ file_size_str }}</span>{% endif %}
+ <span class="file-path">{{ path }}</span>
+ </p>
+ </div>
+ <div class="file-head__actions">
+ <button type="button" class="btn btn-primary" data-copy="{{ raw_url }}" title="Copy the raw link (for embedding)">{{ icon('copy') }}<span>Copy link</span></button>
+ <a href="/api/get/{{ path | urlencode }}" class="btn" data-turbo="false">{{ icon('download') }}<span>Download</span></a>
+ <a href="{{ raw_url }}" class="btn" data-turbo="false" target="_blank" rel="noopener">{{ icon('external') }}<span>Open raw</span></a>
+ </div>
+ </header>
+
+ <section class="link-list" aria-labelledby="links-title">
+ <h2 id="links-title" class="section-title">Links</h2>
+ {% macro link_row(label, value, hint='') %}
+ <div class="link-row">
+ <span class="link-row__label">{{ label }}{% if hint %}<small>{{ hint }}</small>{% endif %}</span>
+ <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 %}
+ {{ link_row('CDN page', page_url, 'This page') }}
+ {{ link_row('Raw link', raw_url, 'For embedding') }}
+ {{ link_row('Download link', dl_url, 'Starts a download') }}
+ {% if embed %}
+ <div class="link-row link-row--code">
+ <span class="link-row__label">Embed<small>HTML snippet</small></span>
+ <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 %}
+ </section>
+
+ <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' %}
+ <video src="{{ raw_url }}" controls preload="metadata" playsinline></video>
+ {% 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 }}"></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">
+ <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">
+ <div class="preview__icon">{{ icon('file', 34, 1.5) }}</div>
+ <p>No preview for this file type.</p>
+ </div>
+ {% endif %}
+ </section>
+ </div>
+</article>
diff --git a/app/templates/_page_history.html b/app/templates/_page_history.html
@@ -0,0 +1,25 @@
+<section class="page-head">
+ <h1>History</h1>
+ <p class="lede">Uploads and pages you've opened, kept only in this browser. Nothing here is sent to the server.</p>
+</section>
+
+<div class="tabs" role="tablist" aria-label="History sections">
+ <button type="button" class="tab is-active" id="history-tab-uploads" role="tab" aria-selected="true" aria-controls="history-uploads-panel" data-history-tab="0">Recent uploads</button>
+ <button type="button" class="tab" id="history-tab-visited" role="tab" aria-selected="false" aria-controls="history-visited-panel" tabindex="-1" data-history-tab="1">Visited files & folders</button>
+</div>
+
+<section class="tabpanel" id="history-uploads-panel" role="tabpanel" aria-labelledby="history-tab-uploads">
+ <div class="tabpanel__head">
+ <h2 class="section-title">Recent uploads</h2>
+ <button type="button" class="btn btn-sm btn-ghost-danger" data-action="clear-history" data-key="cdn_uploads_v1" data-target="history-uploads-list">Clear uploads</button>
+ </div>
+ <div class="list-group" id="history-uploads-list"></div>
+</section>
+
+<section class="tabpanel" id="history-visited-panel" role="tabpanel" aria-labelledby="history-tab-visited" hidden>
+ <div class="tabpanel__head">
+ <h2 class="section-title">Visited files & folders</h2>
+ <button type="button" class="btn btn-sm btn-ghost-danger" data-action="clear-history" data-key="cdn_visited_v1" data-target="history-visited-list">Clear visits</button>
+ </div>
+ <div class="list-group" id="history-visited-list"></div>
+</section>
diff --git a/app/templates/_page_listing.html b/app/templates/_page_listing.html
@@ -0,0 +1,47 @@
+{% from "_listing.html" import root_header, counts, toolbar, go_up, row_dir, row_file, empty_state %}
+{% from "_icons.html" import icon %}
+{% if skeleton %}<!--@root-header-->{% elif not path %}
+{{ root_header() }}
+{% endif %}
+
+{% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
+{% set file_count = visible_items | rejectattr("is_dir") | list | length %}
+{% set dir_count = visible_items | selectattr("is_dir") | list | length %}
+{% set parent_path = '/' %}
+{% if path %}
+ {% set path_parts = path.split('/') %}
+ {% if path_parts|length > 1 %}{% set parent_path = '/' + path_parts[:-1]|join('/') %}{% endif %}
+{% endif %}
+<div class="input-group input-group--standalone" id="filter-group">
+ <span class="input-group__icon">{{ icon('search', 16) }}</span>
+ <input type="text" id="filter-input" class="filter-input" placeholder="Search this folder... (press / to focus)" aria-label="Filter this folder" autocomplete="off" spellcheck="false">
+ <button type="button" class="input-clear-btn" id="filter-clear" aria-label="Clear filter">{{ icon('close', 16, 2.5) }}</button>
+</div>
+{% if skeleton %}<!--@toolbar-->{% elif items %}
+{{ toolbar(counts(dir_count, file_count), path) }}
+{% endif %}
+ <div class="list-container">
+ <div class="list-group">
+ <div id="file-list">
+ {% if skeleton %}<!--@list-->{% else %}
+ {% if path %}
+ {{ go_up(parent_path) }}
+ {% endif %}
+
+ {% for item in items %}
+ {% if item.name != '.gitattributes' %}
+ {% if item.is_dir %}
+ {{ row_dir(item.name, item.path) }}
+ {% else %}
+ {{ row_file(item.name, item.path, item.size_str) }}
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+
+ {% if not visible_items %}
+ {{ empty_state() }}
+ {% endif %}
+ {% endif %}
+ </div>
+ </div>
+ </div>
diff --git a/app/templates/_page_search.html b/app/templates/_page_search.html
@@ -0,0 +1,14 @@
+{% from "_icons.html" import icon %}
+<section class="page-head">
+ <h1>Find a file</h1>
+ <p class="lede">Search every file and folder on the CDN by name or path.</p>
+</section>
+<div class="list-group search-box">
+ <div class="input-group input-group--lg" id="search-group">
+ <span class="input-group__icon">{{ icon('search', 18) }}</span>
+ <input type="search" id="search-input" class="search-input" placeholder="Find a file anywhere in the CDN..." aria-label="Search files" autocomplete="off" spellcheck="false" autofocus>
+ <button type="button" class="input-clear-btn" id="search-clear" aria-label="Clear search">{{ icon('close', 18, 2.5) }}</button>
+ </div>
+ <div id="search-status" class="search-status" role="status" aria-live="polite" hidden></div>
+ <div id="search-results"></div>
+</div>
diff --git a/app/templates/_page_upload.html b/app/templates/_page_upload.html
@@ -0,0 +1,38 @@
+{% from "_icons.html" import icon %}
+<section class="page-head">
+ <h1>Upload</h1>
+ <p class="lede">Drop files here to get direct links. Can't remember a link later? Check the <a href="/uploads">uploads folder</a>, or your <a href="/history">history</a>.</p>
+</section>
+
+<div class="upload">
+ <div class="field">
+ <span class="field-label" id="folder-label">Save to</span>
+ <div class="segmented" role="group" aria-labelledby="folder-label" id="folder-picker">
+ <button type="button" class="segmented__btn is-active" aria-pressed="true" data-folder="uploads">uploads</button>
+ <button type="button" class="segmented__btn" aria-pressed="false" data-folder="third-party">third-party</button>
+ </div>
+ </div>
+
+ <p class="callout callout-warning" role="note">
+ {{ icon('clock', 18) }}
+ <span><strong>Rate limits apply.</strong> Each IP can upload <strong>200 MB per hour</strong> (a single file can be at most <strong>200 MB</strong>). Uploading from a link is limited to <strong>50 MB per hour</strong>. Going over returns an error until your allowance comes back. <a href="/documentation#rate-limits">How rate limits work</a></span>
+ </p>
+
+ <label class="dropzone" id="dropzone" for="file-input">
+ <span class="dropzone__icon">{{ icon('upload', 26, 1.75) }}</span>
+ <span class="dropzone__title">Drag & drop files here</span>
+ <span class="dropzone__sub">or <u>click to browse</u> · you can also paste an image</span>
+ <input type="file" id="file-input" class="sr-only" multiple>
+ </label>
+
+ <div class="field">
+ <label class="field-label" for="url-input">Or fetch a file from a link</label>
+ <div class="input-row">
+ <input type="url" id="url-input" class="text-input" placeholder="https://example.com/file.zip" autocomplete="off" spellcheck="false">
+ <button type="button" class="btn" id="url-upload-btn">{{ icon('link') }}<span>Fetch & upload</span></button>
+ </div>
+ </div>
+
+ <p id="error-box" class="alert alert-danger" role="alert" hidden></p>
+ <ul id="results" class="upload-queue" aria-live="polite"></ul>
+</div>
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/_styles.html b/app/templates/_styles.html
@@ -1,59 +0,0 @@
-<style>
-/* Shared design tokens (light by default, dark via prefers-color-scheme).
- Prefixed --ui- so they never collide with a page's own legacy variables
- while pages are being migrated. Included once, via _navbar.html. */
-:root {
- color-scheme: light dark;
- --ui-bg: #ffffff;
- --ui-surface: #f6f8fa;
- --ui-border: #d0d7de;
- --ui-text: #1f2328;
- --ui-muted: #59636e;
- --ui-accent: #0b5fff;
- --ui-accent-bg: #eaf1ff;
- --ui-accent-solid: #0b5fff; /* filled buttons/badges with white text */
- --ui-danger: #cf222e;
- --ui-success: #116329;
- --ui-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- --ui-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- --ui-radius: 6px;
- --ui-width: 960px;
-}
-@media (prefers-color-scheme: dark) {
- :root {
- --ui-bg: #0d1117;
- --ui-surface: #161b22;
- --ui-border: #30363d;
- --ui-text: #e6edf3;
- --ui-muted: #9198a1;
- --ui-accent: #4c8dff;
- --ui-accent-bg: #12233f;
- --ui-accent-solid: #2f6feb;
- --ui-danger: #f85149;
- --ui-success: #3fb950;
- }
-}
-:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
-
-/* ---- Shared polish (loads after page CSS, so it wins at equal specificity) ---- */
-body { font-family: var(--ui-font); line-height: 1.55; -webkit-font-smoothing: antialiased; }
-h1 { font-size: clamp(1.6rem, 3.2vw, 2.1rem) !important; font-weight: 600 !important; letter-spacing: -0.01em !important; line-height: 1.25; }
-/* UI text in sans; monospace stays for paths, file names and code */
-input, button, textarea, select, .btn, .folder-option, .folder-picker-label, .file-pill, .file-ext-tag, footer, .history-tab { font-family: var(--ui-font) !important; }
-/* Notices: neutral info banner instead of an accent bar */
-blockquote { border-left: 1px solid var(--ui-border) !important; background: var(--ui-surface); border-radius: var(--ui-radius); max-width: none !important; }
-/* Inputs and buttons */
-.filter-input, .search-input, .url-input { font-size: 0.95rem; background: var(--ui-bg); }
-.filter-input:focus, .search-input:focus, .url-input:focus { border-color: var(--ui-accent) !important; outline: 2px solid var(--ui-accent-bg); }
-.btn { font-weight: 500 !important; }
-.btn-primary { font-weight: 500 !important; }
-/* Destination chips become a compact segmented control */
-.folder-option { border-radius: var(--ui-radius) !important; font-size: 0.85rem !important; font-weight: 500 !important; background: var(--ui-bg); }
-.folder-option.active { background: var(--ui-accent-solid) !important; border-color: var(--ui-accent-solid) !important; color: #fff !important; }
-.file-pill { border-radius: var(--ui-radius) !important; font-weight: 500 !important; }
-/* Lists: quiet hover, tabular numbers for sizes */
-.list-item:hover { background: var(--ui-surface); }
-.item-size, .file-pill { font-variant-numeric: tabular-nums; }
-footer { color: var(--ui-muted); font-size: 0.85rem; }
-@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation-duration: 0.01ms !important; } }
-</style>
diff --git a/app/templates/base.html b/app/templates/base.html
@@ -0,0 +1,107 @@
+{% 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.
+ The three pieces the fast-navigation code depends on are kept intact: the <!--@breadcrumb--> slot
+ (_breadcrumb.html), the <!--@stats--> slot (footer below) and <meta name="cdn-fast"> opt-out. -#}
+{% set site_name = 'amit/cdn' %}
+{% set full_title = (page_title ~ ' · ' ~ site_name) if page_title else site_name %}
+{% set description = page_description or "Static file host for amit's projects. Browse, upload, and grab direct links." %}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+ <title>{{ full_title }}</title>
+ <meta name="description" content="{{ description }}">
+
+ <link rel="icon" href="/favicon.ico" sizes="any">
+ <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
+ <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
+ <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
+
+ <link rel="manifest" href="/static/manifest.json">
+ <meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)">
+ <meta name="theme-color" content="#0f1012" media="(prefers-color-scheme: dark)">
+ <meta name="color-scheme" content="light dark">
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <meta name="mobile-web-app-capable" content="yes">
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
+ <meta name="apple-mobile-web-app-title" content="{{ site_name }}">
+
+ <meta property="og:site_name" content="{{ site_name }}">
+ <meta property="og:title" content="{{ full_title }}">
+ <meta property="og:description" content="{{ description }}">
+ <meta property="og:image" content="{{ (cdn_base_url ~ '/static/icons/og-logo.png') if cdn_base_url else '/static/icons/og-logo.png' }}">
+ <meta property="og:type" content="website">
+ <meta name="twitter:card" content="summary">
+
+ {#- Applies the saved (or system) colour theme before first paint, so there is no flash. Identical on every
+ 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';
+ document.documentElement.setAttribute('data-theme', t);
+ if (localStorage.getItem('cdn_notice_off_v1') === '1') document.documentElement.setAttribute('data-notice', 'off');
+ } catch (e) {}
+ })();
+ </script>
+
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
+ <link rel="stylesheet" href="/static/app.css?v={{ asset_v }}">
+ <script src="/static/app.js?v={{ asset_v }}" defer></script>
+ <script src="/static/prefetch.js" defer></script>
+ <script src="/static/fasttree.js" defer></script>
+ {% block head_extra %}{% endblock %}
+</head>
+<body data-page="{{ page }}">
+ <a class="skip-link" href="#main">Skip to content</a>
+ {% include "_navbar.html" %}
+ {% include "_breadcrumb.html" %}
+
+ <main id="main" class="page {% block page_class %}{% endblock %}" tabindex="-1">
+ {% block content %}{% endblock %}
+ </main>
+
+ <footer class="site-footer">
+ <div class="site-footer__inner">
+ <p class="site-footer__about">
+ amit/cdn · built by <a href="https://github.com/notamitgamer">notamitgamer</a><br>
+ nothing here is a permanent fixture.
+ </p>
+ {% if skeleton %}<!--@stats-->{% elif repo_file_count is defined and repo_file_count is not none %}
+ {{ footer_stats(repo_file_count, '' if repo_file_count == 1 else 's', repo_size_str) }}
+ {% endif %}
+ </div>
+ </footer>
+
+ <dialog id="zip-dialog" class="dialog" aria-labelledby="zip-dialog-title">
+ <div class="dialog__body">
+ <h2 id="zip-dialog-title">Download as .zip</h2>
+ <p id="zip-dialog-text"></p>
+ <div class="dialog__actions">
+ <button type="button" class="btn" data-action="dialog-close">Cancel</button>
+ <a href="#" class="btn btn-primary" id="zip-dialog-confirm" data-turbo="false" data-action="dialog-close">Download (.zip)</a>
+ </div>
+ </div>
+ </dialog>
+ <dialog id="error-dialog" class="dialog" aria-labelledby="error-dialog-title">
+ <div class="dialog__body">
+ <h2 id="error-dialog-title">Something went wrong</h2>
+ <p id="error-dialog-text"></p>
+ <div class="dialog__actions">
+ <button type="button" class="btn" data-action="dialog-close">Close</button>
+ </div>
+ </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>
+</html>
diff --git a/app/templates/documentation.html b/app/templates/documentation.html
@@ -1,70 +1,16 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Documentation · amit/cdn</title>
-<meta name="description" content="Full endpoint reference for cdn.amit.is-a.dev - what each route does, what it needs, what it returns, and how it works.">
-<link rel="icon" href="/favicon.ico" sizes="any">
-<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
-<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
-<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
-<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
-<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
-<meta name="color-scheme" content="light dark">
-<style>
- :root{--bg:var(--ui-bg);--surface:var(--ui-surface);--surface-2:var(--ui-surface);--border:var(--ui-border);--text:var(--ui-text);--muted:var(--ui-muted);--accent:var(--ui-accent);--get:var(--ui-success);--post:var(--ui-accent);--err:var(--ui-danger);}
- *{box-sizing:border-box;}
- body{background:var(--bg);color:var(--text);font-family:var(--ui-font);margin:0;line-height:1.6;}
- .wrap{max-width:1000px;margin:0 auto;padding:48px 20px 96px;}
- h1{font-family:var(--ui-font);font-weight:600;font-size:1.9rem;margin-bottom:.2em;}
- h2{font-size:1.15rem;margin:0 0 1rem;font-family:var(--ui-mono);}
- h3{font-size:.95rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin:1.1em 0 .4em;}
- p{color:var(--muted);margin:.4em 0;}
- code{font-family:var(--ui-mono);background:var(--surface-2);padding:.15em .4em;border-radius:4px;font-size:.85em;color:var(--text);word-break:break-word;overflow-wrap:anywhere;}
- pre{font-family:var(--ui-mono);background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px;overflow-x:auto;font-size:.82rem;margin:.5em 0;}
- a{color:var(--accent);}
- .lede{max-width:70ch;}
- .toc{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:1.1rem 1.3rem;margin:1.6rem 0 2.4rem;}
- .toc h3{margin-top:0;}
- .toc ol{margin:0;padding-left:1.2em;color:var(--muted);columns:2;font-size:.92rem;}
- .toc a{color:var(--text);text-decoration:none;}
- .toc a:hover{color:var(--accent);}
- .endpoint{border:1px solid var(--border);border-radius:6px;padding:1.4rem 1.5rem;margin-bottom:1.6rem;background:var(--surface);scroll-margin-top:20px;}
- .route-row{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.6rem;}
- .method{font-family:var(--ui-mono);font-weight:700;font-size:.78rem;padding:.2rem .5rem;border-radius:5px;letter-spacing:.03em;}
- .method.get{background:color-mix(in srgb,var(--ui-success) 14%,transparent);color:var(--get);}
- .method.post{background:var(--ui-accent-bg);color:var(--post);}
- .route-path{font-family:var(--ui-mono);font-size:1rem;color:var(--text);}
- .domain-tag{font-family:var(--ui-mono);font-size:.72rem;color:var(--muted);background:var(--surface-2);border:1px solid var(--border);border-radius:5px;padding:.15rem .4rem;}
- .endpoint > p:first-of-type{margin-top:0;}
- table{width:100%;border-collapse:collapse;margin:.5em 0;font-size:.88rem;}
- th,td{text-align:left;padding:.4em .6em;border-bottom:1px solid var(--border);vertical-align:top;}
- th{color:var(--muted);font-weight:500;font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;}
- td{color:var(--text);}
- td.desc{color:var(--muted);}
- .err-list{list-style:none;padding:0;margin:.4em 0;}
- .err-list li{padding:.3em 0;font-size:.88rem;overflow:hidden;}
- .err-code{font-family:var(--ui-mono);color:var(--err);font-weight:600;float:left;width:3.2em;margin-right:.6em;}
- section{margin-top:2.6rem;}
- section > h2 { font-family:var(--ui-font); font-size:1.4rem; font-weight:600; color:var(--text); }
- .footnote{font-size:.82rem;color:var(--muted);margin-top:3rem;border-top:1px solid var(--border);padding-top:1.2rem;}
- footer{margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--border);font-size:.85rem;color:var(--muted);}
-</style>
- <script src="/static/prefetch.js" defer></script>
- <script src="/static/fasttree.js" defer></script>
-</head>
-<body>
-{% include "_navbar.html" %}
-<div class="wrap">
+{% extends "base.html" %}
+{% set page_title = 'Documentation' %}
+{% set page_description = 'Full endpoint reference for cdn.amit.is-a.dev - what each route does, what it needs, what it returns, and how it works.' %}
+{% block page_class %}prose{% endblock %}
+
+{% block content %}
<h1>Documentation</h1>
<p class="lede">Every route this service exposes: what it's for, what it needs, what it returns, what makes it fail, and
how it actually works under the hood. This page is generated from reading the live source, not from memory or
guesswork — if something here is wrong, the code is the tie-breaker, and this page should get fixed to match it.</p>
<div class="toc">
- <h3>Jump to</h3>
+ <p class="toc-title">Jump to</p>
<ol>
<li><a href="#browsing">Browsing & viewing</a></li>
<li><a href="#gh-sync">GitHub Actions sync</a></li>
@@ -486,10 +432,4 @@
(<code>app/main.py</code>, <code>app/storage.py</code>, <code>app/gh_oidc.py</code> in
<a href="https://github.com/notamitgamer/cdnV2">notamitgamer/cdnV2</a>) over this page, and open an issue/PR
so this page gets corrected.</p>
-
- <footer>
- <p>© 2025 - 2026 notamitgamer. All rights reserved.</p>
- </footer>
-</div>
-</body>
-</html>
+{% endblock %}
diff --git a/app/templates/gh_sync_docs.html b/app/templates/gh_sync_docs.html
@@ -1,131 +1,9 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>GitHub Actions Sync · amit/cdn</title>
-<meta name="description" content="Push any repo's files to the CDN automatically on every git push.">
-<link rel="icon" href="/favicon.ico" sizes="any">
-<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
-<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
-<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
-<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
-<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
-<meta name="color-scheme" content="light dark">
+{% extends "base.html" %}
+{% set page_title = 'GitHub Actions sync' %}
+{% set page_description = "Push any repo's files to the CDN automatically on every git push." %}
+{% block page_class %}prose{% endblock %}
-<style>
- :root {
- --bg: var(--ui-bg);
- --surface: var(--ui-surface);
- --surface-2: var(--ui-surface);
- --border: var(--ui-border);
- --text: var(--ui-text);
- --muted: var(--ui-muted);
- --accent: var(--ui-accent);
- }
-
- *,
- *::before,
- *::after {
- box-sizing: border-box;
- }
-
- *,
- *::before,
- *::after,
- html {
- -webkit-tap-highlight-color: transparent;
- }
-
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--ui-font);
- margin: 0;
- line-height: 1.6;
- }
-
- .wrap {
- max-width: 1000px;
- margin: 0 auto;
- padding: 48px 20px 96px;
- }
-
- h1 {
- font-family: var(--ui-font);
- font-weight: 600;
- font-size: 1.9rem;
- margin-bottom: .2em;
- }
-
- h2 {
- font-size: 1.25rem;
- margin-top: 2.4em;
- border-top: 1px solid var(--border);
- padding-top: 1.2em;
- }
-
- p {
- color: var(--muted);
- }
-
- code, pre {
- font-family: var(--ui-mono);
- }
-
- code {
- background: var(--surface-2);
- padding: .15em .4em;
- border-radius: 4px;
- font-size: .9em;
- color: var(--text);
- word-break: break-word;
- overflow-wrap: anywhere;
- }
-
- pre {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 16px;
- overflow-x: auto;
- font-size: .85rem;
- }
-
- ul {
- color: var(--muted);
- }
-
- .badge {
- display: inline-block;
- background: var(--surface-2);
- color: var(--accent);
- border-radius: 6px;
- padding: 2px 8px;
- font-size: .75rem;
- margin-left: 8px;
- vertical-align: middle;
- }
-
- a {
- color: var(--accent);
- }
-
- footer {
- margin-top: 4rem;
- padding-top: 1.5rem;
- border-top: 1px solid var(--border);
- font-size: 0.85rem;
- color: var(--muted);
- }
-</style>
- <script src="/static/prefetch.js" defer></script>
- <script src="/static/fasttree.js" defer></script>
-</head>
-<body>
-{% include "_navbar.html" %}
-<div class="wrap">
+{% block content %}
<h1>Auto-sync any repo to the CDN <span class="badge">GitHub Actions</span></h1>
<p>Add one workflow file to a repo. On every push, its contents land in the CDN under
<code>/<your-github-username>/<repo-name>/</code> — no PAT, no shared secret, and it's
@@ -231,10 +109,4 @@ jobs:
</ul>
<p>The full request/response details are in the <a href="/documentation#gh-sync">documentation</a>.</p>
-
- <footer>
- <p>© 2025 - 2026 notamitgamer. All rights reserved.</p>
- </footer>
-</div>
-</body>
-</html>
+{% endblock %}
diff --git a/app/templates/index.html b/app/templates/index.html
@@ -1,1843 +1,29 @@
-{% from "_listing.html" import root_header, count, counts, toolbar, go_up, row_dir, row_file, empty_state, footer_stats, visit_script %}
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{% if page == 'file' %}{{ filename }} · amit/cdn{% elif page == 'upload' %}New · amit/cdn{% elif page == 'search' %}Find · amit/cdn{% elif page == 'history' %}History · amit/cdn{% else %}amit/cdn · {{ page | capitalize }}{% endif %}</title>
- <meta name="description" content="Static file host for amit's projects. Browse, upload, and grab direct links.">
-
- <link rel="icon" href="/favicon.ico" sizes="any">
- <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
- <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
- <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
- <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
-
- <link rel="manifest" href="/static/manifest.json">
- <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
- <meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
- <meta name="color-scheme" content="light dark">
- <meta name="apple-mobile-web-app-capable" content="yes">
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
- <meta name="apple-mobile-web-app-title" content="amit/cdn">
-
- <meta property="og:title" content="{% if page == 'upload' %}New · amit/cdn{% elif page == 'search' %}Find · amit/cdn{% elif page == 'history' %}History · amit/cdn{% else %}amit/cdn · {{ page | capitalize }}{% endif %}">
- <meta property="og:description" content="Static file host for amit's projects. Browse, upload, and grab direct links.">
- <meta property="og:image" content="/static/icons/og-logo.png">
- <meta property="og:type" content="website">
- <meta name="twitter:card" content="summary">
-
- <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
- <script src="/static/prefetch.js" defer></script>
- <script src="/static/fasttree.js" defer></script>
-
- <style>
- :root {
- /* Legacy names mapped onto the shared --ui- tokens (_styles.html) */
- --bg: var(--ui-bg);
- --surface: var(--ui-surface);
- --surface-2: var(--ui-surface);
- --surface-3: var(--ui-border);
- --text-main: var(--ui-text);
- --text-dim: var(--ui-muted);
- --accent-primary: var(--ui-accent);
- --accent-primary-hover: var(--ui-accent);
- --accent-secondary: var(--ui-accent-solid);
- --success: var(--ui-success);
- --danger: var(--ui-danger);
- --border: var(--ui-border);
- --border-hover: var(--ui-muted);
- --font-heading: var(--ui-font);
- --font-body: var(--ui-font);
- --font-mono: var(--ui-mono);
- --radius-md: 6px;
- --radius-lg: 6px;
- --radius-xl: 6px;
- }
-
- * { box-sizing: border-box; }
- * { -webkit-tap-highlight-color: transparent; }
-
- ::selection { background: var(--accent-primary); color: #ffffff; }
- ::-webkit-scrollbar { width: 10px; height: 10px; }
- ::-webkit-scrollbar-track { background: var(--bg); }
- ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); }
- ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
-
- body {
- font-family: var(--font-body);
- background: var(--bg);
- color: var(--text-main);
- margin: 0;
- padding: 0 0 4rem 0;
- line-height: 1.6;
- font-size: 1rem;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
-
- @media (max-width: 480px) {
- body { font-size: 0.95rem; }
- }
-
- .container {
- width: 90%;
- max-width: 1000px;
- margin: 0 auto;
- }
-
- @media (max-width: 480px) {
- .container {
- width: 100%;
- padding: 0 1.25rem;
- }
- .breadcrumb-container { padding: 0 1.25rem 1rem; }
- }
-
- h1 {
- font-family: var(--font-heading);
- font-size: clamp(2.2rem, 5vw, 3rem);
- letter-spacing: -0.02em;
- color: var(--text-main);
- font-weight: 600;
- margin: 0 0 1.25rem 0;
- line-height: 1.1;
- max-width: 20ch;
- }
-
- blockquote {
- background: var(--surface);
- border: 1px solid var(--border);
- border-left: 3px solid var(--accent-primary);
- border-radius: var(--radius-md);
- margin: 0 0 2.25rem 0;
- padding: 1rem 1.25rem;
- color: var(--text-dim);
- max-width: 68ch;
- font-size: 0.95rem;
- }
-
- .site-notice { margin-top: 0.5rem; }
-
- a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s;}
- a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
-
- .breadcrumb-container {
- background: var(--bg);
- max-width: 1000px;
- margin: 0 auto;
- padding: 0 5% 1rem 5%;
- }
-
- .breadcrumb {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- flex-wrap: wrap;
- color: var(--text-dim);
- font-weight: 500;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- }
- .breadcrumb a {
- color: var(--text-main);
- text-decoration: none;
- padding: 0.2rem 0.4rem;
- margin: -0.2rem -0.4rem;
- border-radius: 4px;
- transition: background 0.15s, color 0.15s;
- }
- .breadcrumb a:hover {
- color: var(--accent-primary);
- background: var(--surface-2);
- text-decoration: none;
- }
-
- .breadcrumb-collapsed {
- position: relative;
- display: inline-flex;
- }
- .breadcrumb-ellipsis {
- background: var(--surface);
- border: 1px solid var(--border);
- color: var(--text-dim);
- border-radius: 6px;
- padding: 0.25rem 0.75rem;
- cursor: pointer;
- font-family: var(--font-mono);
- font-size: 0.9rem;
- line-height: 1.4;
- transition: all 0.2s;
- }
- .breadcrumb-ellipsis:hover {
- color: var(--text-main);
- border-color: var(--border-hover);
- background: var(--surface-2);
- }
- .breadcrumb-menu {
- display: none;
- position: absolute;
- top: calc(100% + 8px);
- left: 0;
- min-width: 180px;
- max-width: 70vw;
- max-height: 260px;
- overflow-y: auto;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 0.5rem;
- flex-direction: column;
- gap: 0.25rem;
- z-index: 20;
- box-shadow: none;
- }
- .breadcrumb-menu.open { display: flex; }
-
- .breadcrumb-menu a {
- padding: 0.75rem 1rem;
- border-radius: 6px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- margin: 0;
- }
-
- .copy-path-btn {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 6px;
- color: var(--text-dim);
- cursor: pointer;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.4rem;
- margin-left: 0.5rem;
- transition: all 0.2s;
- }
- .copy-path-btn:hover {
- color: var(--text-main);
- border-color: var(--border-hover);
- background: var(--surface-2);
- }
- .copy-path-btn:active {
- transform: scale(0.95);
- }
-
- .input-group {
- position: relative;
- display: flex;
- align-items: center;
- width: 100%;
- }
- .filter-input, .search-input, .url-input {
- width: 100%;
- padding: 0.85rem 2.5rem 0.85rem 1.25rem;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- color: var(--text-main);
- font-family: var(--font-mono);
- font-size: 0.9rem;
- transition: all 0.2s ease;
- box-shadow: none;
- }
- .search-input { font-size: 1rem; padding: 1rem 2.5rem 1rem 1.25rem; }
-
- .filter-input:focus, .search-input:focus, .url-input:focus {
- outline: none;
- background: var(--surface-2);
- border-color: var(--accent-primary);
- box-shadow: none;
- }
- .filter-input::placeholder, .search-input::placeholder, .url-input::placeholder {
- color: var(--text-dim);
- }
-
- .input-clear-btn {
- position: absolute;
- right: 0.75rem;
- top: 50%;
- transform: translateY(-50%);
- background: transparent;
- border: none;
- color: var(--text-dim);
- cursor: pointer;
- padding: 0.25rem;
- display: none;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- transition: color 0.2s, background 0.2s;
- }
- .input-group[data-has-text="true"] .input-clear-btn {
- display: flex;
- }
- .input-clear-btn:hover {
- color: var(--text-main);
- background: var(--surface-3);
- }
-
- .list-container { margin-bottom: 2rem; }
-
- .listing-toolbar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- flex-wrap: wrap;
- gap: 1rem;
- margin-bottom: 1.25rem;
- padding: 0.5rem 0;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- }
-
- .list-group {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- overflow: hidden;
- box-shadow: none;
- margin-bottom: 2rem;
- display: flex;
- flex-direction: column;
- }
-
- .list-group .input-group {
- border-bottom: 1px solid var(--border);
- background: var(--surface-2);
- }
-
- .list-group .filter-input, .list-group .search-input {
- background: transparent;
- box-shadow: none;
- border-radius: 0;
- border: none;
- }
-
- .list-group .filter-input:focus, .list-group .search-input:focus {
- background: var(--ui-surface);
- box-shadow: none;
- }
-
- .list-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.85rem 1.25rem;
- border-bottom: 1px solid var(--border);
- background: transparent;
- color: var(--text-main);
- gap: 1rem;
- transition: background 0.15s ease;
- }
- .list-item:last-child {
- border-bottom: none;
- }
- .list-item:hover {
- background: var(--ui-surface);
- }
- .list-item:active {
- background: var(--ui-surface);
- }
-
- .item-name {
- display: flex;
- align-items: center;
- gap: 0.85rem;
- word-break: break-all;
- min-width: 0;
- flex: 1;
- font-family: var(--font-mono);
- font-size: 0.95rem;
- }
- .item-name svg {
- flex-shrink: 0;
- transition: color 0.2s;
- }
-
- .item-name svg.folder-icon { color: var(--accent-primary); }
- .item-name svg.file-icon-list { color: var(--text-dim); }
-
- .list-item:hover .item-name svg.folder-icon { color: var(--accent-primary-hover); }
- .list-item:hover .item-name svg.file-icon-list { color: var(--text-main); }
-
- .item-name a {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .item-name a.folder-link {
- color: var(--accent-primary);
- font-weight: 600;
- }
- .item-name a.file-link {
- color: var(--text-main);
- font-weight: 500;
- }
-
- .item-meta {
- display: flex;
- align-items: center;
- gap: 1rem;
- color: var(--text-dim);
- font-family: var(--font-mono);
- font-size: 0.8rem;
- white-space: nowrap;
- flex-shrink: 0;
- }
-
- .size-label {
- display: inline-block;
- }
-
- .download-icon {
- color: var(--text-dim);
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.5rem;
- min-width: 36px;
- min-height: 36px;
- margin: -0.5rem;
- border-radius: 8px;
- background: transparent;
- }
- .download-icon:hover {
- color: var(--accent-primary);
- background: var(--ui-accent-bg);
- }
- .download-icon:active {
- transform: scale(0.9);
- }
-
- .btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- background: var(--surface-2);
- color: var(--text-main);
- padding: 0.65rem 1.25rem;
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- font-family: var(--font-body);
- font-size: 0.9rem;
- font-weight: 500;
- transition: all 0.2s ease, transform 0.1s ease;
- }
- .btn:hover:not(:disabled) {
- background: var(--surface-3);
- border-color: var(--border-hover);
- text-decoration: none;
- color: var(--text-main);
- }
- .btn:active:not(:disabled) {
- transform: scale(0.97);
- }
- .btn:disabled { opacity: 0.6; cursor: not-allowed; }
-
- .btn-primary {
- background: var(--ui-accent-solid) !important;
- color: #ffffff !important;
- border-color: var(--ui-accent-solid) !important;
- font-weight: 600;
- transition: none !important;
- }
- .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) {
- background: var(--ui-accent-solid) !important;
- border-color: var(--ui-accent-solid) !important;
- color: #ffffff !important;
- }
- .btn-primary svg { stroke: #ffffff !important; }
-
- .icon-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- padding: 0.65rem;
- transition: all 0.2s ease;
- }
- .icon-btn:hover:not(:disabled) {
- background: var(--surface-2);
- border-color: var(--border-hover);
- }
- .icon-btn:hover:not(:disabled) svg { stroke: var(--accent-primary); }
-
- .file-card {
- border: 1px solid var(--border);
- border-radius: var(--radius-xl);
- background: var(--surface);
- margin-bottom: 2.5rem;
- overflow: hidden;
- }
-
- .file-preview {
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--surface-2);
- border-bottom: 1px solid var(--border);
- min-height: 200px;
- padding: 3rem 1rem;
- position: relative;
- }
-
- .file-icon {
- position: relative;
- z-index: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 88px;
- height: 88px;
- border-radius: var(--radius-lg);
- background: var(--surface);
- border: 1px solid var(--border-hover);
- color: var(--text-main);
- }
-
- .file-ext-tag {
- position: absolute;
- bottom: -0.6rem;
- font-family: var(--font-mono);
- font-size: 0.65rem;
- letter-spacing: 0.5px;
- font-weight: 600;
- color: #ffffff;
- background: var(--accent-secondary);
- padding: 0.2rem 0.6rem;
- border-radius: 4px;
- text-transform: uppercase;
- }
-
- .file-info {
- padding: 2rem;
- text-align: center;
- }
- .file-name {
- font-family: var(--font-heading);
- margin: 0 0 0.75rem 0;
- font-weight: 600;
- font-size: 1.5rem;
- color: var(--text-main);
- word-break: break-all;
- }
- .file-meta-row {
- display: flex;
- gap: 0.75rem;
- justify-content: center;
- margin-bottom: 1.5rem;
- }
- .file-pill {
- font-family: var(--font-mono);
- font-size: 0.75rem;
- font-weight: 600;
- color: var(--accent-primary);
- border: 1px solid var(--ui-accent);
- background: var(--ui-accent-bg);
- border-radius: 6px;
- padding: 0.35rem 0.85rem;
- letter-spacing: 0.5px;
- }
- .file-pill:nth-child(2) {
- color: var(--text-main);
- border-color: var(--border);
- background: var(--surface-2);
- }
- .file-path {
- font-family: var(--font-mono);
- color: var(--text-dim);
- margin: 0 auto 2.5rem auto;
- font-size: 0.85rem;
- word-break: break-all;
- background: var(--ui-surface);
- display: inline-block;
- padding: 0.75rem 1.25rem;
- border-radius: var(--radius-md);
- border: 1px solid var(--border);
- box-shadow: none;
- max-width: 90%;
- }
- .file-actions {
- display: flex;
- gap: 0.75rem;
- justify-content: center;
- flex-wrap: wrap;
- }
-
- .folder-picker {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 1.5rem;
- flex-wrap: wrap;
- }
- .folder-picker-label {
- color: var(--text-dim);
- font-size: 0.9rem;
- font-weight: 500;
- margin-right: 0.5rem;
- }
- .folder-option {
- background: var(--surface);
- border: 1px solid var(--border);
- color: var(--text-dim);
- border-radius: 99px;
- padding: 0.4rem 1rem;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .folder-option:hover {
- border-color: var(--text-main);
- color: var(--text-main);
- }
- .folder-option.active {
- background: var(--text-main);
- border-color: var(--text-main);
- color: var(--bg);
- font-weight: 600;
- }
-
- .rate-note { display:flex; gap:.65rem; align-items:flex-start; margin:0 0 1.25rem; padding:.75rem 1rem; border:1px solid rgba(245,158,11,.45); background:rgba(245,158,11,.1); border-radius:var(--radius-md); font-size:.88rem; line-height:1.5; color:var(--text-main); }
- .rate-note svg { flex:none; margin-top:.15rem; color:#d97706; }
- .rate-note a { color:var(--accent-primary); font-weight:500; white-space:nowrap; }
- #dropzone {
- border: 2px dashed var(--border-hover);
- border-radius: var(--radius-lg);
- padding: 4rem 2rem;
- text-align: center;
- cursor: pointer;
- color: var(--text-dim);
- margin-bottom: 1.5rem;
- background: var(--ui-surface);
- transition: all 0.2s ease;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- }
- #dropzone svg {
- width: 48px;
- height: 48px;
- color: var(--border-hover);
- margin-bottom: 0.5rem;
- transition: color 0.2s;
- }
- #dropzone:hover, #dropzone.dragover {
- border-color: var(--accent-primary);
- background: var(--ui-accent-bg);
- color: var(--accent-primary);
- box-shadow: none;
- }
- #dropzone:hover svg, #dropzone.dragover svg {
- color: var(--accent-primary);
- }
-
- #results:not(:empty) {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- overflow: hidden;
- box-shadow: none;
- }
-
- .file-row {
- display: flex; justify-content: space-between; padding: 1rem 1.25rem;
- border-bottom: 1px solid var(--border);
- background: transparent;
- align-items: center;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- }
- .file-row:last-child {
- border-bottom: none;
- }
- .file-row.done { background: var(--ui-surface); }
- .file-row.error { background: var(--ui-surface); }
-
- .turbo-progress-bar {
- height: 3px;
- background-color: var(--accent-primary);
- }
-
- .container { transition: opacity 0.2s ease; }
- body.turbo-loading .container { opacity: 0.6; pointer-events: none; }
-
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 1rem;
- padding: 5rem 2rem;
- color: var(--text-dim);
- background: var(--surface);
- border: 1px dashed var(--border);
- border-radius: var(--radius-lg);
- }
- .list-group .empty-state {
- border: none;
- background: transparent;
- padding: 4rem 2rem;
- border-radius: 0;
- }
- .empty-state svg { opacity: 0.5; width: 48px; height: 48px; }
-
- @keyframes spin { 100% { transform: rotate(360deg); } }
- .spin-anim { animation: spin 1s linear infinite; }
- .upload-spinner {
- display: inline-block;
- width: 14px; height: 14px;
- border: 2px solid var(--accent-primary);
- border-top-color: transparent;
- border-radius: 50%;
- margin-right: 8px;
- vertical-align: middle;
- }
-
- .modal-overlay {
- position: fixed; top: 0; left: 0; width: 100%; height: 100%;
- background: rgba(0, 0, 0, 0.5);
-
- display: flex; align-items: center; justify-content: center;
- z-index: 1000;
- }
- .modal {
- background: var(--surface);
- border: 1px solid var(--border-hover);
- border-radius: var(--radius-lg);
- padding: 2rem;
- max-width: 440px;
- width: 90%;
- box-shadow: none;
- }
- .modal h3 {
- font-family: var(--font-heading);
- margin-top: 0; margin-bottom: 1rem;
- color: var(--text-main); font-size: 1.4rem;
- font-weight: 600;
- }
- .modal p { margin-bottom: 2rem; color: var(--text-dim); }
- .modal-actions {
- display: flex; justify-content: flex-end; gap: 0.75rem;
- }
-
- footer {
- margin-top: 5rem;
- padding-top: 2rem;
- border-top: 1px solid var(--border);
- color: var(--text-dim);
- font-family: var(--font-mono);
- font-size: 0.8rem;
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 1rem;
- }
- footer a { color: var(--text-main); font-weight: 500; }
- footer a:hover { color: var(--accent-primary); }
-
- @media (max-width: 480px) {
- .list-item { padding: 0.85rem 1rem; }
- .file-info { padding: 1.5rem 1.25rem; }
-
- .file-actions {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.75rem;
- }
- .file-actions .btn { width: 100%; padding: 0.65rem 0.5rem; font-size: 0.85rem;}
- }
-
- .history-tabs {
- display: flex;
- width: 100%;
- border-bottom: 1px solid var(--border);
- margin-bottom: 1.5rem;
- }
- .history-tab {
- flex: 1;
- background: transparent;
- border: none;
- border-bottom: 2px solid transparent;
- color: var(--text-dim);
- font-family: var(--font-body);
- font-size: 0.95rem;
- font-weight: 500;
- padding: 0.85rem 1rem;
- cursor: pointer;
- transition: color 0.2s, border-color 0.2s, background 0.2s;
- }
- .history-tab:hover {
- color: var(--text-main);
- background: var(--ui-surface);
- }
- .history-tab.active {
- color: var(--text-main);
- border-bottom-color: var(--accent-primary);
- }
- .history-slider-viewport {
- width: 100%;
- overflow: hidden;
- }
- .history-slider {
- display: flex;
- width: 200%;
- transform: translateX(0);
- transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
- will-change: transform;
- }
- .history-section {
- width: 50%;
- flex: 0 0 50%;
- margin-bottom: 0;
- min-width: 0;
- }
- .history-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
- }
- .history-title {
- font-family: var(--font-heading);
- font-size: 1.4rem;
- font-weight: 600;
- margin: 0;
- color: var(--text-main);
- }
- .clear-history-btn {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--text-dim);
- font-size: 0.8rem;
- font-family: var(--font-mono);
- padding: 0.3rem 0.6rem;
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.2s;
- }
- .clear-history-btn:hover {
- color: var(--danger);
- border-color: var(--danger);
- }
-
- @media (max-width: 480px) {
- .history-tab {
- font-size: 0.85rem;
- padding: 0.75rem 0.5rem;
- }
- }
- </style>
-</head>
-<body>
- {% include "_navbar.html" %}
- </div>
-
- <div class="container">
- {% if skeleton %}<!--@root-header-->{% elif page == 'listing' and not path %}
- {{ root_header() }}
- {% endif %}
-
- {% if page == 'listing' %}
- {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
- {% set file_count = visible_items | rejectattr("is_dir") | list | length %}
- {% set dir_count = visible_items | selectattr("is_dir") | list | length %}
- {% set parent_path = '/' %}
- {% if path %}
- {% set path_parts = path.split('/') %}
- {% if path_parts|length > 1 %}{% set parent_path = '/' + path_parts[:-1]|join('/') %}{% endif %}
- {% endif %}
- {% if skeleton %}<!--@toolbar-->{% elif items %}
- {{ toolbar(counts(dir_count, file_count), path) }}
- {% endif %}
- <div class="list-container">
- <div class="list-group">
- <div class="input-group" id="filter-group">
- <input type="text" id="filter-input" class="filter-input" placeholder="Search this folder... (press / to focus)" autocomplete="off" spellcheck="false">
- <button type="button" class="input-clear-btn" id="filter-clear" aria-label="Clear filter">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
- </button>
- </div>
-
- <div id="file-list">
- {% if skeleton %}<!--@list-->{% else %}
- {% if path %}
- {{ go_up(parent_path) }}
- {% endif %}
-
- {% for item in items %}
- {% if item.name != '.gitattributes' %}
- {% if item.is_dir %}
- {{ row_dir(item.name, item.path) }}
- {% else %}
- {{ row_file(item.name, item.path, item.size_str) }}
- {% endif %}
- {% endif %}
- {% endfor %}
-
- {% if not visible_items %}
- {{ empty_state() }}
- {% endif %}
- {% endif %}
- </div>
- </div>
- </div>
-
- {% elif page == 'file' %}
- <div class="file-card">
- <div class="file-preview file-preview-none">
- <div class="file-icon">
- {% if file_ext %}<span class="file-ext-tag">.{{ file_ext }}</span>{% endif %}
- <svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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>
- </div>
- </div>
-
- <div class="file-info">
- <h2 class="file-name">{{ filename }}</h2>
- <div class="file-meta-row">
- {% if file_ext %}<span class="file-pill">{{ file_ext | upper }}</span>{% endif %}
- {% if file_size_str and file_size_str != '-' %}<span class="file-pill">{{ file_size_str }}</span>{% endif %}
- </div>
- <p class="file-path">{{ path }}</p>
-
- <div class="file-actions">
- <a href="/api/get/{{ path }}" class="btn btn-primary" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
- Download File
- </a>
- <a href="{{ raw_base_url }}/{{ path }}" class="btn" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
- Open Raw
- </a>
- <button onclick="copyToClipboard('{{ cdn_base_url }}/{{ path }}', this)" class="btn">Copy Link</button>
- <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}', this)" class="btn">Copy Raw Link</button>
- <button onclick="copyToClipboard('{{ cdn_base_url }}/api/get/{{ path | urlencode }}', this)" class="btn" title="Anyone opening this link downloads the file directly">Copy Download Link</button>
- </div>
- </div>
- </div>
-
- {% elif page == 'batch' %}
- <div class="listing-toolbar">
- <div style="color: var(--text-dim); font-size: 0.95rem;">
- <strong style="color: var(--text-main);">{{ batch_files | length }}</strong> file{{ 's' if batch_files | length != 1 else '' }} in this batch
- </div>
- <div style="display:flex; gap:0.75rem; flex-wrap: wrap;">
- <button onclick="copyToClipboard(window.location.href, this)" class="btn">Copy Batch Link</button>
- <button id="download-zip-btn" class="btn btn-primary" data-batch-id="{{ batch_id }}" title="Download every file in this batch as a .zip">
- <svg id="zip-spinner" class="spin-anim" style="display: none; width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <line x1="12" y1="2" x2="12" y2="6"></line>
- <line x1="12" y1="18" x2="12" y2="22"></line>
- <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
- <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
- <line x1="2" y1="12" x2="6" y2="12"></line>
- <line x1="18" y1="12" x2="22" y2="12"></line>
- <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
- <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line>
- </svg>
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
- <span id="zip-btn-text">Download all (.zip)</span>
- </button>
- </div>
- </div>
- <div class="list-container">
- <div class="list-group" id="file-list">
- {% for f in batch_files %}
- <div class="list-item" data-name="{{ f.filename }}">
- <div class="item-name">
- <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"><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>
- <a href="/{{ f.hf_path }}" class="file-link" data-full-name="{{ f.filename }}">{{ f.filename }}</a>
- </div>
- <div class="item-meta">
- <span class="batch-size-label">{{ f.size_str }}</span>
- <a href="/api/get/{{ f.hf_path }}" class="download-icon" title="Download" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
- </a>
- </div>
- </div>
- {% endfor %}
-
- {% if not batch_files %}
- <div class="empty-state">
- <span>This batch has no files.</span>
- </div>
- {% endif %}
- </div>
- </div>
-
- {% elif page == '404' %}
- <div class="list-container" style="padding: 6rem 1.5rem; text-align: center;">
- <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="1.5" style="margin-bottom: 1.5rem;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
- <center><h1 style="margin: 0 0 1rem 0; color: var(--text-main);">404 - Not Found</h1></center>
- <p style="color: var(--text-dim); margin-bottom: 2.5rem; font-size: 1.1rem;">The file or directory you're looking for doesn't exist.</p>
- <a href="/" class="btn btn-primary">Go to Root</a>
- </div>
-
- {% elif page == 'upload' %}
- <div>
- <blockquote style="margin-bottom: 2.5rem;">
- If you do not remember the link, go to the <a href="/uploads">uploads folder</a> to find your file.
- </blockquote>
-
- <div class="folder-picker" id="folder-picker">
- <span class="folder-picker-label">Save to:</span>
- <button type="button" class="folder-option active" data-folder="uploads">uploads</button>
- <button type="button" class="folder-option" data-folder="third-party">third-party</button>
- </div>
-
- <div class="rate-note" role="note"><svg 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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limits apply.</strong> Each IP can upload <strong>200 MB per hour</strong> (a single file can be at most <strong>200 MB</strong>). Uploading from a link is limited to <strong>50 MB per hour</strong>. Going over returns an error until your allowance comes back. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
-
- <div style="display:flex; gap:0.75rem; margin-bottom: 1.5rem;">
- <input type="text" id="url-input" class="url-input" placeholder="Paste a link to fetch and upload…">
- <button type="button" class="icon-btn" id="url-upload-btn" title="Upload from URL" aria-label="Upload from URL">
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07l-1.5 1.5"/>
- <path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07l1.5-1.5"/>
- </svg>
- </button>
- </div>
-
- <div id="dropzone">
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
- <div style="font-size: 1.1rem; font-weight: 500; color: var(--text-main);">Drag & drop files here</div>
- <div style="font-size: 0.9rem;">or click to browse</div>
- <input type="file" id="file-input" multiple style="display:none;">
- </div>
-
- <div id="error-box" style="display: none; color: var(--danger); margin-bottom: 1.5rem; padding: 1rem 1.25rem; border: 1px solid rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.1); border-radius: var(--radius-md);"></div>
- <div id="results"></div>
- </div>
-
- <script>
- const dropzone = document.getElementById('dropzone');
- const fileInput = document.getElementById('file-input');
- const urlInput = document.getElementById('url-input');
- const urlUploadBtn = document.getElementById('url-upload-btn');
- const results = document.getElementById('results');
- const errorBox = document.getElementById('error-box');
- const folderPicker = document.getElementById('folder-picker');
-
- const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024;
-
- // Records an upload to this browser's local /history log
- function recordUpload(item) {
- try {
- var KEY = 'cdn_uploads_v1';
- var MAX = 50;
- var entries = JSON.parse(localStorage.getItem(KEY) || '[]');
- 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()
- });
- localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX)));
- } catch (e) {}
- // A new file exists now: refresh the cached file tree so folder navigation shows it.
- if (window.__cdnFast) window.__cdnFast.refresh(true);
- }
-
- function selectedFolder() {
- const active = folderPicker.querySelector('.folder-option.active');
- return active ? active.dataset.folder : 'uploads';
- }
-
- folderPicker.querySelectorAll('.folder-option').forEach(btn => {
- btn.addEventListener('click', () => {
- folderPicker.querySelectorAll('.folder-option').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- });
- });
-
- dropzone.addEventListener('click', () => fileInput.click());
- dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
- dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
- dropzone.addEventListener('drop', (e) => {
- e.preventDefault(); dropzone.classList.remove('dragover');
- if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
- });
- fileInput.addEventListener('change', () => {
- if (fileInput.files.length) handleFiles(fileInput.files);
- });
-
- function showError(msg) {
- errorBox.textContent = msg;
- errorBox.style.display = 'block';
- }
-
- function formatBytes(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- }
-
- function handleFiles(fileList) {
- errorBox.style.display = 'none';
-
- const allFiles = Array.from(fileList);
- const files = [];
- const rejectedSize = [];
-
- allFiles.forEach(f => {
- if (f.size > MAX_FILE_SIZE_BYTES) {
- rejectedSize.push(f.name);
- } else {
- files.push(f);
- }
- });
-
- const errorMessages = [];
- if (rejectedSize.length) {
- errorMessages.push(`Skipped ${rejectedSize.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejectedSize.join(', ')}`);
- }
-
- if (errorMessages.length) {
- showError(errorMessages.join(' | '));
- }
-
- if (!files.length) return;
-
- const rows = {};
- files.forEach(f => {
- const row = document.createElement('div');
- row.className = 'file-row';
- row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${f.name}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;">uploading...</div>`;
- results.prepend(row);
- rows[f.name] = row;
- });
-
- const formData = new FormData();
- files.forEach(f => formData.append('files', f));
- formData.append('folder', selectedFolder());
-
- const xhr = new XMLHttpRequest();
- xhr.open('POST', '/api/put', true);
-
- xhr.upload.onprogress = (e) => {
- if (!e.lengthComputable) return;
- const pct = Math.round((e.loaded / e.total) * 100);
- files.forEach(f => {
- const statusNode = rows[f.name].querySelector('.status');
- if (pct === 100) {
- statusNode.innerHTML = `<span class="upload-spinner spin-anim"></span><span style="color: var(--accent-primary);">saving to HF...</span>`;
- } else {
- statusNode.textContent = pct + '%';
- }
- });
- };
-
- xhr.onload = () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- try {
- const data = JSON.parse(xhr.responseText);
- data.files.forEach(item => {
- const row = rows[item.filename];
- recordUpload(item);
- if (!row) return;
- row.classList.add('done');
- const filePath = item.hf_path;
- row.innerHTML = `
- <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: var(--success); font-weight: 500;">${item.filename}</div>
- <div style="white-space:nowrap; display:flex; gap:0.75rem;">
- <a href="/${filePath}">View File</a>
- </div>
- `;
- });
- if (data.batch_url) {
- const batchRow = document.createElement('div');
- batchRow.className = 'file-row done';
- batchRow.style.cssText = 'display:flex; flex-direction:column; align-items:flex-start; gap:0.5rem;';
- batchRow.innerHTML = `
- <div style="color: var(--text-dim); font-size: 0.9rem;">All ${data.files.length} files uploaded. Group link:</div>
- <a href="${data.batch_url}" target="_blank" style="word-break: break-all; font-size: 1rem;">${data.batch_url}</a>
- `;
- results.prepend(batchRow);
- }
- } catch (err) { showError('Invalid response from server.'); }
- } else {
- let msg = 'Upload failed.';
- try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) {}
- showError(msg);
- files.forEach(f => {
- rows[f.name].classList.add('error');
- rows[f.name].querySelector('.status').textContent = 'Failed';
- rows[f.name].querySelector('.status').style.color = 'var(--danger)';
- });
- }
- fileInput.value = '';
- };
- xhr.onerror = () => { showError('Connection error.'); };
- xhr.send(formData);
- }
-
- urlUploadBtn.addEventListener('click', () => {
- const url = urlInput.value.trim();
- errorBox.style.display = 'none';
- if (!url) return;
-
- let label;
- try {
- label = decodeURIComponent(new URL(url).pathname.split('/').pop()) || url;
- } catch (e) {
- showError('That doesn\'t look like a valid URL.');
- return;
- }
-
- const row = document.createElement('div');
- row.className = 'file-row';
- row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${label}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;"><span class="upload-spinner spin-anim"></span><span style="color: var(--accent-primary);">fetching & saving...</span></div>`;
- results.prepend(row);
-
- urlUploadBtn.disabled = true;
-
- fetch('/api/put-url', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url, folder: selectedFolder() })
- })
- .then(async (resp) => {
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok) {
- throw new Error(data.detail || 'Upload from URL failed.');
- }
- const item = data.files[0];
- recordUpload(item);
- row.classList.add('done');
- const filePath = item.hf_path;
- row.innerHTML = `
- <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: var(--success); font-weight: 500;">${item.filename}</div>
- <div style="white-space:nowrap; display:flex; gap:0.75rem;">
- <a href="/${filePath}">View File</a>
- </div>
- `;
- urlInput.value = '';
- })
- .catch((err) => {
- row.classList.add('error');
- row.querySelector('.status').textContent = 'Failed';
- row.querySelector('.status').style.color = 'var(--danger)';
- showError(err.message);
- })
- .finally(() => { urlUploadBtn.disabled = false; });
- });
- </script>
-
- {% elif page == 'history' %}
- <div class="history-tabs" role="tablist" aria-label="History sections">
- <button type="button" class="history-tab active" id="history-tab-uploads" role="tab" aria-selected="true" aria-controls="history-uploads-panel" data-history-tab="0">Recent Uploads</button>
- <button type="button" class="history-tab" id="history-tab-visited" role="tab" aria-selected="false" aria-controls="history-visited-panel" data-history-tab="1">Visited Files & Folders</button>
- </div>
-
- <div class="history-slider-viewport">
- <div class="history-slider" id="history-slider">
- <div class="history-section" id="history-uploads-panel" role="tabpanel" aria-labelledby="history-tab-uploads">
- <div class="history-header">
- <h2 class="history-title">Recent Uploads</h2>
- <button type="button" class="clear-history-btn" onclick="clearLocalHistory('cdn_uploads_v1', 'history-uploads-list')">Clear Uploads</button>
- </div>
- <div class="list-group" id="history-uploads-list"></div>
- </div>
-
- <div class="history-section" id="history-visited-panel" role="tabpanel" aria-labelledby="history-tab-visited">
- <div class="history-header">
- <h2 class="history-title">Visited Files & Folders</h2>
- <button type="button" class="clear-history-btn" onclick="clearLocalHistory('cdn_visited_v1', 'history-visited-list')">Clear Visits</button>
- </div>
- <div class="list-group" id="history-visited-list"></div>
- </div>
- </div>
- </div>
-
- <script>
- function renderHistory() {
- function formatRelative(ts) {
- if (!ts) return '';
- var diff = Math.round((Date.now() - ts) / 60000);
- if (diff < 1) return 'just now';
- if (diff < 60) return diff + 'm ago';
- if (diff < 1440) return Math.round(diff / 60) + 'h ago';
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
- }
-
- function formatBytes(bytes) {
- if (!bytes || bytes === '-') return '-';
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- }
-
- // Uploads
- var uploadContainer = document.getElementById('history-uploads-list');
- var uploads = JSON.parse(localStorage.getItem('cdn_uploads_v1') || '[]');
- if (!uploads.length) {
- uploadContainer.innerHTML = '<div class="empty-state"><span>No uploads logged yet on this browser.</span></div>';
- } else {
- uploadContainer.innerHTML = uploads.map(function(item) {
- return `
- <div class="list-item">
- <div class="item-name">
- <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"><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>
- <a href="/${item.hf_path}" class="file-link" data-full-name="${item.filename}">${item.filename}</a>
- </div>
- <div class="item-meta">
- <span class="size-label">${formatBytes(item.size)}</span>
- <span style="color: var(--text-dim); font-size: 0.8rem;">${formatRelative(item.ts)}</span>
- <a href="/api/get/${item.hf_path}" class="download-icon" title="Download" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
- </a>
- </div>
- </div>
- `;
- }).join('');
- }
-
- // Visited
- var visitedContainer = document.getElementById('history-visited-list');
- var visited = JSON.parse(localStorage.getItem('cdn_visited_v1') || '[]');
- if (!visited.length) {
- visitedContainer.innerHTML = '<div class="empty-state"><span>No visited links logged yet.</span></div>';
- } else {
- visitedContainer.innerHTML = visited.map(function(item) {
- var isDir = item.type === 'folder';
- var icon = isDir
- ? '<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"><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>'
- : '<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"><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>';
- var name = isDir ? item.path.split('/').pop() + '/' : (item.filename || item.path.split('/').pop());
- var link = '/' + item.path;
-
- return `
- <div class="list-item">
- <div class="item-name">
- ${icon}
- <a href="${link}" class="${isDir ? 'folder-link' : 'file-link'}" data-full-name="${name}">${name}</a>
- </div>
- <div class="item-meta">
- <span style="color: var(--text-dim); font-size: 0.8rem;">${formatRelative(item.ts)}</span>
- ${!isDir ? `
- <a href="/api/get/${item.path}" class="download-icon" title="Download" data-turbo="false">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
- </a>` : ''}
- </div>
- </div>
- `;
- }).join('');
- }
- }
-
- function clearLocalHistory(key, containerId) {
- localStorage.removeItem(key);
- var container = document.getElementById(containerId);
- if (container) {
- container.innerHTML = '<div class="empty-state"><span>Cleared.</span></div>';
- }
- }
-
- function switchHistoryTab(index) {
- var slider = document.getElementById('history-slider');
- var tabs = document.querySelectorAll('.history-tab');
- if (!slider || !tabs.length) return;
-
- slider.style.transform = index === 1 ? 'translateX(-50%)' : 'translateX(0)';
-
- tabs.forEach(function(tab, tabIndex) {
- var active = tabIndex === index;
- tab.classList.toggle('active', active);
- tab.setAttribute('aria-selected', active ? 'true' : 'false');
- });
- }
-
- document.querySelectorAll('.history-tab').forEach(function(tab) {
- tab.addEventListener('click', function() {
- switchHistoryTab(Number(tab.dataset.historyTab));
- });
- });
-
- renderHistory();
- document.addEventListener('turbo:load', function() {
- switchHistoryTab(0);
- renderHistory();
- });
- </script>
-
- {% elif page == 'search' %}
- <div>
- <div class="list-group">
- <div class="input-group" id="search-group">
- <input type="text" id="search-input" class="search-input" placeholder="Find a file anywhere in the CDN..." autocomplete="off" spellcheck="false" autofocus>
- <button type="button" class="input-clear-btn" id="search-clear" aria-label="Clear search">
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
- </button>
- </div>
- <div id="search-status" style="color: var(--text-dim); font-size: 0.9rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--surface-2); display: none;"></div>
-
- <div id="search-results"></div>
- </div>
- </div>
-
- <script>
- (function () {
- const searchInput = document.getElementById('search-input');
- const searchGroup = document.getElementById('search-group');
- const searchClear = document.getElementById('search-clear');
- const searchStatus = document.getElementById('search-status');
- const searchResults = document.getElementById('search-results');
-
- let debounceTimer = null;
- let currentController = null;
- let requestSeq = 0;
-
- if (searchClear) {
- searchClear.addEventListener('click', () => {
- searchInput.value = '';
- searchGroup.removeAttribute('data-has-text');
- searchResults.innerHTML = '';
- searchStatus.textContent = '';
- searchStatus.style.display = 'none';
- searchInput.focus();
- });
- }
-
- function escapeHtml(str) {
- return String(str)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
- function fileIconSvg() {
- return '<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"><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>';
- }
-
- function folderIconSvg() {
- return '<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"><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>';
- }
-
- function downloadIconSvg() {
- return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>';
- }
-
- function renderResults(items) {
- if (!items.length) {
- searchResults.innerHTML = '';
- searchStatus.textContent = 'No matching files found. Try a different term.';
- searchStatus.style.display = 'block';
- return;
- }
- searchStatus.innerHTML = `<strong style="color:var(--text-main);">${items.length}</strong> result${items.length !== 1 ? 's' : ''}`;
- searchStatus.style.display = 'block';
-
- searchResults.innerHTML = items.map((item) => {
- const href = '/' + item.path;
- const icon = item.is_dir ? folderIconSvg() : fileIconSvg();
- const linkClass = item.is_dir ? 'folder-link' : 'file-link';
- const displayName = item.is_dir ? item.name + '/' : item.name;
- const downloadBtn = item.is_dir ? '' :
- '<a href="/api/get/' + escapeHtml(item.path) + '" class="download-icon" title="Download" data-turbo="false">' + downloadIconSvg() + '</a>';
- const metaContent = item.is_dir ? '' : ('<span class="size-label">' + escapeHtml(item.size_str) + '</span>' + downloadBtn);
-
- return '<div class="list-item" style="flex-direction: column; align-items: stretch; gap: 0.5rem; padding: 1rem 1.25rem;">' +
- '<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">' +
- '<div class="item-name">' + icon +
- '<a href="' + escapeHtml(href) + '" class="' + linkClass + '" data-full-name="' + escapeHtml(displayName) + '">' + escapeHtml(displayName) + '</a></div>' +
- '<div class="item-meta">' + metaContent + '</div>' +
- '</div>' +
- '<div style="color: var(--text-dim); font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; margin-left: 26px;">' + escapeHtml(item.path) + '</div>' +
- '</div>';
- }).join('');
-
- if (window.scheduleTruncate) window.scheduleTruncate();
- }
-
- searchInput.addEventListener('input', () => {
- const term = searchInput.value.trim();
- clearTimeout(debounceTimer);
-
- if (searchInput.value.length > 0) {
- searchGroup.setAttribute('data-has-text', 'true');
- } else {
- searchGroup.removeAttribute('data-has-text');
- }
-
- if (!term) {
- searchResults.innerHTML = '';
- searchStatus.textContent = '';
- searchStatus.style.display = 'none';
- return;
- }
-
- searchStatus.innerHTML = '<span class="upload-spinner spin-anim" style="width:12px; height:12px; margin-right:6px;"></span> Searching...';
- searchStatus.style.display = 'block';
-
- debounceTimer = setTimeout(() => {
- const seq = ++requestSeq;
- if (currentController) currentController.abort();
- currentController = new AbortController();
-
- fetch('/api/find?q=' + encodeURIComponent(term), { signal: currentController.signal })
- .then(res => res.json())
- .then(data => {
- if (seq !== requestSeq) return;
- renderResults(data.results || []);
- })
- .catch(err => {
- if (err.name === 'AbortError') return;
- if (seq !== requestSeq) return;
- searchStatus.textContent = 'Search failed.';
- searchResults.innerHTML = '';
- });
- }, 300);
- });
- })();
- </script>
- {% endif %}
-
- <div id="zip-modal-overlay" class="modal-overlay" style="display: none;">
- <div class="modal">
- <h3>Confirm Download</h3>
- <p id="zip-modal-text" style="color: var(--text-main); font-size: 1rem; line-height: 1.5;"></p>
- <div class="modal-actions">
- <button type="button" class="btn" id="zip-modal-cancel">Cancel</button>
- <a href="#" class="btn btn-primary" id="zip-modal-confirm" data-turbo="false">Download (.zip)</a>
- </div>
- </div>
- </div>
-
- <div id="error-modal-overlay" class="modal-overlay" style="display: none;">
- <div class="modal">
- <h3 style="color: var(--danger); font-size: 1.3rem;">Notice</h3>
- <p id="error-modal-text" style="color: var(--text-main); font-size: 1rem;"></p>
- <div class="modal-actions">
- <button type="button" class="btn" id="error-modal-close">Close</button>
- </div>
- </div>
- </div>
-
- <footer>
- <div>
- amit/cdn · built by <a href="https://github.com/notamitgamer">notamitgamer</a><br>nothing here is a permanent fixture.
- </div>
- {% if skeleton %}<!--@stats-->{% elif repo_file_count is not none %}
- {{ footer_stats(repo_file_count, '' if repo_file_count == 1 else 's', repo_size_str) }}
- {% endif %}
- </footer>
- </div>
-
- <script>
- function copyToClipboard(text, btn) {
- navigator.clipboard.writeText(text).then(() => {
- if (!btn) return;
- const originalStyle = btn.style.cssText;
- btn.style.borderColor = 'var(--success)';
- btn.style.color = 'var(--success)';
-
- setTimeout(() => {
- btn.style.cssText = originalStyle;
- }, 800);
- }).catch(err => {
- console.error('failed to copy: ', err);
- });
- }
-
- function copyBreadcrumbPath(btn) {
- const rawPath = btn.getAttribute('data-path') || '';
- const container = btn.closest('.breadcrumb-container');
- const base = (container && container.getAttribute('data-cdn-base')) || window.location.origin;
- const fullUrl = base + '/' + rawPath;
-
- navigator.clipboard.writeText(fullUrl).then(() => {
- const original = btn.innerHTML;
- btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
- setTimeout(() => { btn.innerHTML = original; }, 1200);
- }).catch(err => {
- console.error('failed to copy: ', err);
- });
- }
-
- function toggleBreadcrumbMenu(btn) {
- const menu = btn.nextElementSibling;
- if (!menu) return;
- const isOpen = menu.classList.contains('open');
- document.querySelectorAll('.breadcrumb-menu.open').forEach(m => m.classList.remove('open'));
- if (!isOpen) {
- menu.classList.add('open');
- }
- }
-
- if (!window.__CDN_App_Initialized) {
- window.__CDN_App_Initialized = true;
-
- document.addEventListener('click', (e) => {
- if (!e.target.closest('.breadcrumb-collapsed')) {
- document.querySelectorAll('.breadcrumb-menu.open').forEach(m => m.classList.remove('open'));
- }
- });
-
- document.addEventListener('input', (e) => {
- if (e.target.id === 'filter-input') {
- const term = e.target.value.toLowerCase();
- const fileList = document.querySelectorAll('#file-list .list-item[data-name]');
- const filterGroup = document.getElementById('filter-group');
-
- if (filterGroup) {
- if (e.target.value.length > 0) {
- filterGroup.setAttribute('data-has-text', 'true');
- } else {
- filterGroup.removeAttribute('data-has-text');
- }
- }
-
- fileList.forEach(item => {
- const name = item.getAttribute('data-name').toLowerCase();
- if (name === '..') return;
- item.style.display = name.includes(term) ? 'flex' : 'none';
- });
- }
- });
-
- document.addEventListener('click', async (e) => {
- const filterClearBtn = e.target.closest('#filter-clear');
- if (filterClearBtn) {
- const filterInput = document.getElementById('filter-input');
- const filterGroup = document.getElementById('filter-group');
- if (filterInput && filterGroup) {
- filterInput.value = '';
- filterGroup.removeAttribute('data-has-text');
- filterInput.dispatchEvent(new Event('input', { bubbles: true }));
- filterInput.focus();
- }
- return;
- }
-
- const zipBtn = e.target.closest('#download-zip-btn');
- if (zipBtn) {
- e.preventDefault();
- if (zipBtn.disabled) return;
-
- const batchId = zipBtn.getAttribute('data-batch-id');
- const path = zipBtn.getAttribute('data-path') || '';
- const statsUrl = batchId ? ('/api/pack-info-batch/' + batchId) : ('/api/pack-info/' + path);
- const downloadUrl = batchId ? ('/api/pack-batch/' + batchId) : ('/api/pack/' + path);
- const zipSpinner = document.getElementById('zip-spinner');
- const zipBtnText = document.getElementById('zip-btn-text');
- const zipModalOverlay = document.getElementById('zip-modal-overlay');
-
- zipBtn.disabled = true;
- if (zipSpinner) zipSpinner.style.display = 'block';
- if (zipBtnText) zipBtnText.textContent = 'Calculating...';
-
- try {
- const res = await fetch(statsUrl);
- const data = await res.json();
-
- if (!res.ok) {
- throw new Error(data.detail || 'Failed to get file stats');
- }
-
- const label = batchId ? 'this batch' : (path ? path.split('/').pop() : 'root');
- document.getElementById('zip-modal-text').innerHTML =
- `Are you sure you want to download <strong>${label}</strong>?<br><br>` +
- `This will package <strong style="color:var(--text-main);">${data.file_count} file(s)</strong> totaling <strong style="color:var(--text-main);">${data.size_str}</strong> into a .zip.`;
-
- document.getElementById('zip-modal-confirm').href = downloadUrl;
- zipModalOverlay.style.display = 'flex';
- } catch (err) {
- const errorModalOverlay = document.getElementById('error-modal-overlay');
- if (errorModalOverlay) {
- document.getElementById('error-modal-text').textContent = err.message;
- errorModalOverlay.style.display = 'flex';
- } else {
- alert(err.message);
- }
- } finally {
- zipBtn.disabled = false;
- if (zipSpinner) zipSpinner.style.display = 'none';
- if (zipBtnText) zipBtnText.textContent = 'Download all (.zip)';
- }
- }
-
- if (e.target.closest('#zip-modal-cancel') || e.target.closest('#zip-modal-confirm')) {
- const zipOverlay = document.getElementById('zip-modal-overlay');
- if (zipOverlay) zipOverlay.style.display = 'none';
- }
-
- if (e.target.closest('#error-modal-close')) {
- const errOverlay = document.getElementById('error-modal-overlay');
- if (errOverlay) errOverlay.style.display = 'none';
- }
- });
-
- if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker.register('/static/sw.js').catch(err => {
- console.error('service worker registration failed:', err);
- });
- });
- }
-
- document.addEventListener('turbo:load', () => {
- if (window.Turbo && window.Turbo.setProgressBarDelay) {
- window.Turbo.setProgressBarDelay(0);
- }
- }, { once: true });
- document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading'));
- document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading'));
- document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading'));
- document.addEventListener('turbo:render', () => document.body.classList.remove('turbo-loading'));
- document.addEventListener('turbo:load', () => document.body.classList.remove('turbo-loading'));
-
- document.addEventListener('keydown', (e) => {
- if (e.key !== '/') return;
- const tag = (document.activeElement && document.activeElement.tagName) || '';
- if (tag === 'INPUT' || tag === 'TEXTAREA') return;
- const filterInput = document.getElementById('filter-input');
- if (filterInput) {
- e.preventDefault();
- filterInput.focus();
- }
- });
-
- const MOBILE_BREAKPOINT = 480;
- let measureCanvas = null;
-
- function getTextWidth(text, font) {
- if (!measureCanvas) measureCanvas = document.createElement('canvas');
- const ctx = measureCanvas.getContext('2d');
- ctx.font = font;
- return ctx.measureText(text).width;
- }
-
- function middleTruncate(fullName, maxWidth, font) {
- if (getTextWidth(fullName, font) <= maxWidth) return fullName;
-
- const dotIndex = fullName.lastIndexOf('.');
- const hasExt = dotIndex > 0 && dotIndex < fullName.length - 1;
- const ext = hasExt ? fullName.slice(dotIndex) : '';
- const base = hasExt ? fullName.slice(0, dotIndex) : fullName;
- const ellipsis = '...';
-
- let head = base.length;
- let tail = 0;
-
- while (head > 1) {
- const candidate = base.slice(0, head) + ellipsis + ext;
- if (getTextWidth(candidate, font) <= maxWidth) {
- return candidate;
- }
- head--;
- }
-
- let end = fullName.length;
- while (end > 1) {
- const candidate = fullName.slice(0, end) + ellipsis;
- if (getTextWidth(candidate, font) <= maxWidth) return candidate;
- end--;
- }
- return fullName.slice(0, 1) + ellipsis;
- }
-
- function applyMiddleTruncation() {
- const items = document.querySelectorAll('.item-name');
- if (!items.length) return;
-
- const isMobile = window.innerWidth <= MOBILE_BREAKPOINT;
-
- items.forEach((itemName) => {
- const a = itemName.querySelector('a[data-full-name]');
- if (!a) return;
- const fullName = a.getAttribute('data-full-name');
- if (!fullName) return;
-
- if (!isMobile) {
- if (a.textContent !== fullName) a.textContent = fullName;
- return;
- }
-
- const containerWidth = itemName.clientWidth;
- if (!containerWidth) return;
-
- const icon = itemName.querySelector('svg');
- const iconWidth = icon ? icon.getBoundingClientRect().width : 0;
- const containerStyle = window.getComputedStyle(itemName);
- const gap = parseFloat(containerStyle.columnGap || containerStyle.gap) || 0;
- const safetyBuffer = 4;
-
- const availableWidth = containerWidth - iconWidth - gap - safetyBuffer;
- if (availableWidth <= 0) return;
-
- const style = window.getComputedStyle(a);
- const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
-
- const truncated = middleTruncate(fullName, availableWidth, font);
- if (a.textContent !== truncated) a.textContent = truncated;
- if (!a.title) a.title = fullName;
- });
- }
-
- let truncateRAF = null;
- function scheduleTruncate() {
- if (truncateRAF) cancelAnimationFrame(truncateRAF);
- truncateRAF = requestAnimationFrame(() => {
- requestAnimationFrame(applyMiddleTruncation);
- });
- }
-
- let resizeTimer = null;
- window.addEventListener('resize', () => {
- clearTimeout(resizeTimer);
- resizeTimer = setTimeout(scheduleTruncate, 120);
- });
- document.addEventListener('turbo:load', scheduleTruncate);
- document.addEventListener('turbo:render', scheduleTruncate);
- document.addEventListener('input', (e) => {
- if (e.target.id === 'filter-input') scheduleTruncate();
- });
- if (document.fonts && document.fonts.ready) {
- document.fonts.ready.then(scheduleTruncate);
- } else {
- scheduleTruncate();
- }
-
- window.scheduleTruncate = scheduleTruncate;
-
- function applyResponsivePlaceholders() {
- const isMobile = window.innerWidth <= MOBILE_BREAKPOINT;
- const filterInput = document.getElementById('filter-input');
- if (filterInput) {
- filterInput.placeholder = isMobile
- ? 'Search this folder...'
- : 'Search this folder... (press / to focus)';
- }
- const searchInput = document.getElementById('search-input');
- if (searchInput) {
- searchInput.placeholder = isMobile
- ? 'Find files...'
- : 'Find a file anywhere in the CDN...';
- }
- }
- window.applyResponsivePlaceholders = applyResponsivePlaceholders;
- applyResponsivePlaceholders();
- window.addEventListener('resize', () => {
- clearTimeout(resizeTimer);
- resizeTimer = setTimeout(applyResponsivePlaceholders, 120);
- });
- document.addEventListener('turbo:load', applyResponsivePlaceholders);
- }
- </script>
+{% extends "base.html" %}
+{#- Folder listings, file pages, batches, uploads, history, search and the 404 page. Each lives in its own
+ _page_*.html partial; this file only picks one. /api/shell renders it with skeleton=True. -#}
+{% from "_listing.html" import visit_script %}
+
+{% if page == 'file' %}{% set page_title = filename %}
+{% elif page == 'upload' %}{% set page_title = 'New' %}
+{% elif page == 'search' %}{% set page_title = 'Find' %}
+{% elif page == 'history' %}{% set page_title = 'History' %}
+{% elif page == 'batch' %}{% set page_title = 'Batch' %}
+{% elif page == '404' %}{% set page_title = 'Not found' %}
+{% else %}{% set page_title = 'Listing' %}
+{% endif %}
+
+{% block content %}
+{% if page == 'listing' %}{% include "_page_listing.html" %}
+{% elif page == 'file' %}{% include "_page_file.html" %}
+{% elif page == 'batch' %}{% include "_page_batch.html" %}
+{% elif page == '404' %}{% include "_page_404.html" %}
+{% elif page == 'upload' %}{% include "_page_upload.html" %}
+{% elif page == 'history' %}{% include "_page_history.html" %}
+{% elif page == 'search' %}{% include "_page_search.html" %}
+{% endif %}
+{% endblock %}
+
+{% block scripts %}
<script>
// --- Visited history: silently logs file/folder pages viewed on this browser,
// shown on /history. localStorage only - there's no account system, so this is
@@ -1857,5 +43,4 @@
{{ visit_script(visit_fields) }}
{% endif %}
</script>
-</body>
-</html>
+{% endblock %}
diff --git a/app/templates/stats.html b/app/templates/stats.html
@@ -1,92 +1,18 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Stats · amit/cdn</title>
-<meta name="description" content="What's on the CDN: file types, sizes, folders and growth.">
-<link rel="icon" href="/favicon.ico" sizes="any">
-<link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
-<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
-<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
-<meta name="color-scheme" content="light dark">
-<style>
- *, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
- body { background: var(--ui-bg); color: var(--ui-text); margin: 0; }
- .wrap { max-width: var(--ui-width); margin: 0 auto; padding: 28px 16px 72px; }
- h1 { margin: 0 0 4px; }
- h2 { font-size: 1rem; margin: 0; font-weight: 600; }
- .muted { color: var(--ui-muted); font-size: .88rem; }
- .tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 20px 0 14px; }
- .tile, .card { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: 10px; }
- .tile { padding: 12px 14px; }
- .tile b { display: block; font-size: 1.45rem; line-height: 1.2; font-variant-numeric: tabular-nums; }
- .tile span { color: var(--ui-muted); font-size: .8rem; }
- .grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
- .card { padding: 14px 16px 16px; min-width: 0; }
- .card > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
- .seg { display: inline-flex; border: 1px solid var(--ui-border); border-radius: 8px; overflow: hidden; }
- .seg button { font: inherit; font-size: .8rem; padding: 6px 12px; min-height: 32px; border: 0; background: transparent; color: var(--ui-muted); cursor: pointer; }
- .seg button[aria-pressed="true"] { background: var(--ui-accent-solid); color: #fff; }
+{% extends "base.html" %}
+{% set page_title = 'Stats' %}
+{% set page_description = 'What is on the CDN: file counts, sizes and types.' %}
+{% block page_class %}page-stats{% endblock %}
- .donut-wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 22px; }
- .donut { width: 150px; height: 150px; flex: none; margin: 0 auto; position: relative; }
- .donut svg { width: 100%; height: 100%; transform: rotate(-90deg); }
- .donut circle { fill: none; stroke-width: 7; }
- .donut .hole { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; line-height: 1.2; }
- .donut .hole b { font-size: 1.15rem; font-variant-numeric: tabular-nums; }
- .legend { list-style: none; margin: 0; padding: 0; flex: 1 1 200px; display: grid; gap: 7px; font-size: .88rem; }
- .legend li { display: flex; align-items: center; gap: 8px; }
- .legend i { width: 11px; height: 11px; border-radius: 3px; flex: none; }
- .legend .n { margin-left: auto; color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
-
- .bars { display: grid; gap: 11px; }
- .bar .top { display: flex; justify-content: space-between; gap: 10px; font-size: .86rem; }
- .bar .top span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
- .bar .top span:last-child { color: var(--ui-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
- .track { height: 8px; border-radius: 4px; background: var(--ui-border); margin-top: 4px; overflow: hidden; }
- .track i { display: block; height: 100%; border-radius: 4px; background: var(--ui-accent); min-width: 2px; }
-
- .cols { display: flex; align-items: flex-end; gap: 6px; height: 170px; padding-top: 18px; }
- .col { flex: 1; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; }
- .col .v { font-size: .68rem; color: var(--ui-muted); font-variant-numeric: tabular-nums; }
- .col i { width: 100%; max-width: 44px; border-radius: 4px 4px 0 0; background: var(--ui-accent); min-height: 2px; }
- .labels { display: flex; gap: 6px; margin-top: 6px; }
- .labels span { flex: 1; min-width: 0; text-align: center; font-size: .66rem; color: var(--ui-muted); line-height: 1.25; overflow-wrap: anywhere; }
-
- .files { list-style: none; margin: 0; padding: 0; }
- .files li { display: flex; gap: 12px; justify-content: space-between; padding: 9px 0; border-top: 1px solid var(--ui-border); font-size: .88rem; }
- .files li:first-child { border-top: 0; padding-top: 0; }
- .files a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--ui-accent); text-decoration: none; }
- .files span { color: var(--ui-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
- .note { padding: 40px 0; text-align: center; color: var(--ui-muted); }
- .note button { font: inherit; margin-top: 10px; padding: 8px 16px; border-radius: 8px; border: 1px solid var(--ui-border); background: var(--ui-surface); color: var(--ui-text); cursor: pointer; }
- footer { max-width: var(--ui-width); margin: 0 auto; padding: 0 16px 32px; text-align: center; }
-
- @media (min-width: 760px) {
- .wrap { padding: 44px 20px 96px; }
- .tiles { grid-template-columns: repeat(4, 1fr); }
- .grid { grid-template-columns: 1fr 1fr; }
- .span2 { grid-column: 1 / -1; }
- .col .v { font-size: .75rem; } .labels span { font-size: .74rem; }
- }
- :root { --c0: #4c8dff; --c1: #3fb950; --c2: #d29922; --c3: #f85149; --c4: #a371f7; --c5: #39c5cf; --c6: #db61a2; --c7: #8b949e; }
-</style>
- <script src="/static/prefetch.js" defer></script>
- <script src="/static/fasttree.js" defer></script>
-</head>
-<body>
-{% include "_navbar.html" %}
-<main class="wrap">
+{% block content %}
<h1>CDN stats</h1>
<p class="muted" id="sub">Loading…</p>
<div id="out"></div>
-</main>
-<footer><p class="muted">© 2025 - 2026 notamitgamer. All rights reserved.</p></footer>
+{% endblock %}
+
+{% block scripts %}
<script>
(function () {
- var C = ['--c0','--c1','--c2','--c3','--c4','--c5','--c6'], OTHER = 'var(--c7)';
+ var C = ['--c0','--c1','--c2','--c3','--c4','--c5','--c6','--c8'], OTHER = 'var(--c7)';
var $ = function (id) { return document.getElementById(id); };
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); };
var nf = new Intl.NumberFormat();
@@ -106,7 +32,7 @@
return '<li><i style="background:' + color(i, c.key) + '"></i>' + esc(c.label) + '<span class="n">' + (mode === 'size' ? B(v) : nf.format(v)) + ' · ' + pct(v, total) + '%</span></li>';
}).join('');
return '<div class="donut-wrap"><div class="donut" role="img" aria-label="File types by ' + (mode === 'size' ? 'size' : 'count') + '"><svg viewBox="0 0 42 42">' +
- '<circle cx="21" cy="21" r="15.9155" stroke="var(--ui-border)"/>' + segs + '</svg><div class="hole"><b>' + (mode === 'size' ? B(total) : nf.format(total)) +
+ '<circle cx="21" cy="21" r="15.9155" stroke="var(--surface-3)"/>' + segs + '</svg><div class="hole"><b>' + (mode === 'size' ? B(total) : nf.format(total)) +
'</b><span class="muted">' + (mode === 'size' ? 'total' : 'files') + '</span></div></div><ul class="legend">' + legend + '</ul></div>';
}
function bars(rows, label, val, num, sub) {
@@ -150,5 +76,4 @@
.catch(function () { $('sub').textContent = ''; $('out').innerHTML = '<div class="note">Could not load the stats.<br><button onclick="location.reload()">Retry</button></div>'; });
})();
</script>
-</body>
-</html>
+{% endblock %}
diff --git a/app/templates/url_shortener.html b/app/templates/url_shortener.html
@@ -1,422 +1,41 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>URL Shortener — amit/cdn</title>
- <meta name="description" content="Shorten and mask any public URL.">
- <link rel="icon" href="/favicon.ico" sizes="any">
- <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
- <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
- <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
- <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
- <link rel="manifest" href="/static/manifest.json">
- <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
-<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
- <meta name="color-scheme" content="light dark">
- <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
- <script src="/static/prefetch.js" defer></script>
- <script src="/static/fasttree.js" defer></script>
- <style>
- :root {
- /* Legacy names mapped onto the shared --ui- tokens (_styles.html) */
- --bg: var(--ui-bg);
- --surface: var(--ui-surface);
- --surface-2: var(--ui-surface);
- --surface-3: var(--ui-border);
- --text-main: var(--ui-text);
- --text-dim: var(--ui-muted);
- --accent-primary: var(--ui-accent);
- --accent-primary-hover: var(--ui-accent);
- --accent-secondary: var(--ui-accent-solid);
- --success: var(--ui-success);
- --danger: var(--ui-danger);
- --border: var(--ui-border);
- --border-hover: var(--ui-muted);
- --font-heading: var(--ui-font);
- --font-body: var(--ui-font);
- --font-mono: var(--ui-mono);
- --radius-md: 6px;
- --radius-lg: 6px;
- --radius-xl: 6px;
- }
-
- * { -webkit-tap-highlight-color: transparent; }
- * { box-sizing: border-box; }
- body {
- font-family: var(--font-body);
- background: var(--bg);
- color: var(--text-main);
- margin: 0;
- padding: 0 0 4rem 0;
- line-height: 1.6;
- font-size: 1rem;
- -webkit-font-smoothing: antialiased;
- }
- @media (max-width: 480px) {
- body { font-size: 0.95rem; }
- }
- .container {
- width: 90%;
- max-width: 1000px;
- margin: 0 auto;
- }
- h1 {
- font-family: var(--font-heading);
- font-size: clamp(2.2rem, 5vw, 3rem);
- letter-spacing: -0.02em;
- color: var(--text-main);
- font-weight: 600;
- margin: 0 0 1.25rem 0;
- line-height: 1.1;
- max-width: 20ch;
- }
- blockquote {
- background: var(--surface);
- border: 1px solid var(--border);
- border-left: 3px solid var(--accent-primary);
- border-radius: var(--radius-md);
- margin: 0 0 2.25rem 0;
- padding: 1rem 1.25rem;
- color: var(--text-dim);
- max-width: 68ch;
- font-size: 0.95rem;
- }
- a { color: var(--accent-primary); text-decoration: none; transition: color 0.15s; }
- a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
- .input-group {
- position: relative;
- display: flex;
- align-items: center;
- width: 100%;
- }
- .url-input {
- width: 100%;
- padding: 0.85rem 2.5rem 0.85rem 1.25rem;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- color: var(--text-main);
- font-family: var(--font-mono);
- font-size: 0.9rem;
- font-variant-ligatures: none;
- font-feature-settings: "liga" 0, "calt" 0;
- }
- .url-input:focus {
- outline: none;
- background: var(--surface-2);
- border-color: var(--accent-primary);
- box-shadow:none;
- }
- .url-input::placeholder { color: var(--text-dim); }
- .input-clear-btn {
- position: absolute;
- right: 0.75rem;
- top: 50%;
- transform: translateY(-50%);
- background: transparent;
- border: none;
- color: var(--text-dim);
- cursor: pointer;
- padding: 0.25rem;
- display: none;
- align-items: center;
- justify-content: center;
- border-radius: 50%;
- transition: color 0.2s, background 0.2s;
- }
- .input-group[data-has-text="true"] .input-clear-btn { display: flex; }
- .input-clear-btn:hover { color: var(--text-main); background: var(--surface-3); }
- .btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- background: var(--surface-2);
- color: var(--text-main);
- padding: 0.65rem 1.25rem;
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- font-family: var(--font-body);
- font-size: 0.9rem;
- font-weight: 500;
- transition: all 0.2s ease, transform 0.1s ease;
- text-decoration: none;
- }
- .btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-hover); text-decoration: none; color: var(--text-main); }
- .btn:active:not(:disabled) { transform: scale(0.97); }
- .btn:disabled { opacity: 0.6; cursor: not-allowed; }
- .btn-primary { background: var(--ui-accent-solid) !important; color: #ffffff !important; border-color: var(--ui-accent-solid) !important; font-weight: 600; transition: none !important; }
- .btn-primary:hover:not(:disabled), .btn-primary:focus:not(:disabled), .btn-primary:active:not(:disabled) { background: var(--ui-accent-solid) !important; border-color: var(--ui-accent-solid) !important; color: #ffffff !important; }
- .btn-primary svg { stroke: #ffffff !important; }
- .icon-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- cursor: pointer;
- padding: 0.65rem;
- transition: all 0.2s ease;
- }
- .icon-btn:hover:not(:disabled) { background: var(--surface-2); border-color: var(--border-hover); }
- .icon-btn:hover:not(:disabled) svg { stroke: var(--accent-primary); }
- .icon-btn:active:not(:disabled) { transform: scale(0.95); }
- .result-box {
- margin-top: 2rem;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 1.5rem;
- }
- .result-box h3 {
- font-family: var(--font-heading);
- font-size: 1.2rem;
- margin: 0 0 1rem 0;
- color: var(--text-main);
- }
- .result-url {
- font-family: var(--font-mono);
- font-size: 0.95rem;
- word-break: break-all;
- color: var(--accent-primary);
- background: var(--surface-2);
- padding: 0.75rem 1rem;
- border-radius: var(--radius-md);
- border: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- flex-wrap: wrap;
- }
- .result-url a { color: var(--accent-primary); text-decoration: none; }
- .result-url a:hover { text-decoration: underline; }
- .error-box {
- display: none;
- color: var(--danger);
- margin-top: 1rem;
- padding: 1rem 1.25rem;
- border: 1px solid var(--ui-danger);
- background: color-mix(in srgb, var(--ui-danger) 10%, transparent);
- border-radius: var(--radius-md);
- }
- .info-text {
- color: var(--text-dim);
- font-size: 0.9rem;
- margin-top: 1rem;
- }
- .spin-anim { animation: spin 0.7s linear infinite; display: inline-flex; }
- @keyframes spin { 100% { transform: rotate(360deg); } }
- .btn-primary .btn-icon { display: inline-flex; }
- .btn-primary.is-loading .btn-icon.btn-icon-default { display: none; }
- .btn-primary .btn-icon-spinner { display: none; }
- .btn-primary.is-loading .btn-icon-spinner { display: inline-flex; }
- footer {
- width: 90%;
- max-width: 1000px;
- margin: 4rem auto 0;
- padding-top: 1.5rem;
- border-top: 1px solid var(--border);
- font-size: .85rem;
- color: var(--muted);
- }
- @media (max-width: 480px) {
- .container { width: 100%; padding: 0 1.25rem; }
- .result-url { flex-direction: column; align-items: flex-start; }
- }
-
- .rate-note { display:flex; gap:.65rem; align-items:flex-start; margin:0 0 1.25rem; padding:.75rem 1rem; border:1px solid rgba(245,158,11,.45); background:rgba(245,158,11,.1); border-radius:var(--radius-md); font-size:.88rem; line-height:1.5; color:var(--text-main); }
- .rate-note svg { flex:none; margin-top:.15rem; color:#d97706; }
- .rate-note a { color:var(--accent-primary); font-weight:500; white-space:nowrap; }
- </style>
-</head>
-<body>
- {% include "_navbar.html" %}
- <main class="container">
- <h1>URL Shortener</h1>
- <blockquote>
- Paste any public <code>https://</code> URL and get back a masked, shareable link.
- The destination is never exposed until someone visits the masked URL.
- </blockquote>
-
- <div class="rate-note" role="note"><svg 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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span><strong>Rate limit:</strong> each IP can create <strong>10 links per minute</strong> and <strong>50 per hour</strong>. Failed attempts count too. Only <code>https://</code> URLs up to 2048 characters are accepted. <a href="/documentation#rate-limits">How rate limits work</a></span></div>
-
- <div class="input-group" id="shorten-group">
- <input type="url" id="url-input" class="url-input" placeholder="Paste a URL to shorten (e.g. https://example.com)" autocomplete="off" spellcheck="false" required>
- <button type="button" class="input-clear-btn" id="url-clear" aria-label="Clear URL">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
- </button>
- </div>
-
- <div style="display:flex; gap:0.75rem; margin-top:1rem; align-items:center;">
- <button type="button" class="btn btn-primary" id="shorten-btn">
- <span class="btn-icon btn-icon-default">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
- </span>
- <span class="btn-icon btn-icon-spinner spin-anim">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
- </span>
- <span id="shorten-btn-label">Shorten URL</span>
- </button>
- </div>
-
- <div id="error-box" class="error-box" role="alert" aria-live="polite"></div>
-
- <div id="result" style="display:none;" class="result-box">
- <h3>Your masked URL</h3>
- <div class="result-url">
- <a href="#" id="masked-link" target="_blank" rel="noopener noreferrer"></a>
- <button type="button" class="btn" id="copy-btn">Copy</button>
- </div>
- <p class="info-text">This link will redirect to the destination URL when visited.</p>
- </div>
- </main>
-
- <footer>
- <p>© 2025 - 2026 notamitgamer. All rights reserved.</p>
- </footer>
-
- <script>
- (function() {
- 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;
-
- function isPrivateOrLocalHost(rawUrl) {
- try {
- const parsed = new URL(rawUrl);
- return PRIVATE_HOST_RE.test(parsed.hostname);
- } catch {
- return false;
- }
- }
-
- function setupShortener() {
- const urlInput = document.getElementById('url-input');
- const urlClear = document.getElementById('url-clear');
- const shortenBtn = document.getElementById('shorten-btn');
- const errorBox = document.getElementById('error-box');
- const resultDiv = document.getElementById('result');
- const maskedLink = document.getElementById('masked-link');
- const copyBtn = document.getElementById('copy-btn');
-
- if (!urlInput || !shortenBtn || shortenBtn.dataset.initialized) return;
- shortenBtn.dataset.initialized = 'true';
-
- function showError(msg) {
- errorBox.textContent = msg;
- errorBox.style.display = 'block';
- }
-
- function clearError() {
- errorBox.textContent = '';
- errorBox.style.display = 'none';
- }
-
- urlClear.addEventListener('click', () => {
- urlInput.value = '';
- urlInput.focus();
- urlClear.parentElement.removeAttribute('data-has-text');
- clearError();
- resultDiv.style.display = 'none';
- });
-
- urlInput.addEventListener('input', () => {
- if (urlInput.value.length > 0) {
- urlClear.parentElement.setAttribute('data-has-text', 'true');
- } else {
- urlClear.parentElement.removeAttribute('data-has-text');
- }
- });
-
- urlInput.addEventListener('keydown', (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- shortenBtn.click();
- }
- });
-
- shortenBtn.addEventListener('click', async () => {
- const url = urlInput.value.trim();
- clearError();
- resultDiv.style.display = 'none';
-
- if (!url) { showError('Please enter a URL.'); return; }
- if (!/^https:\/\//i.test(url)) { showError('Only https:// URLs are supported.'); return; }
- if (isPrivateOrLocalHost(url)) { showError('Localhost and private network URLs cannot be shortened.'); return; }
-
- shortenBtn.disabled = true;
- shortenBtn.classList.add('is-loading');
-
- try {
- const res = await fetch('/api/shorten', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url })
- });
-
- const data = await res.json().catch(() => ({}));
-
- if (!res.ok) {
- throw new Error(data.detail || 'Failed to shorten URL.');
- }
-
- resultDiv.style.display = 'block';
- maskedLink.href = '/mask/' + data.id;
- maskedLink.textContent = data.masked_url;
- copyBtn.setAttribute('data-url', data.masked_url);
- } catch (err) {
- showError(err.message || 'An unexpected error occurred.');
- } finally {
- shortenBtn.disabled = false;
- shortenBtn.classList.remove('is-loading');
- }
- });
-
- copyBtn.addEventListener('click', async () => {
- const url = copyBtn.getAttribute('data-url');
- if (!url) return;
-
- try {
- await navigator.clipboard.writeText(url);
- const originalText = copyBtn.textContent;
- const originalStyle = copyBtn.style.cssText;
-
- copyBtn.style.borderColor = 'var(--success)';
- copyBtn.style.color = 'var(--success)';
- copyBtn.textContent = 'Copied!';
-
- setTimeout(() => {
- copyBtn.style.cssText = originalStyle;
- copyBtn.textContent = originalText;
- }, 1000);
- } catch (err) {
- console.error('Failed to copy: ', err);
- }
- });
- }
-
- // Bind to Turbo and standard page loads
- document.addEventListener('turbo:load', setupShortener);
- document.addEventListener('DOMContentLoaded', setupShortener);
-
- // Global '/' focus shortcut
- if (!window.__shortenerKbdBound) {
- window.__shortenerKbdBound = true;
- document.addEventListener('keydown', (e) => {
- if (e.key !== '/') return;
- const tag = (document.activeElement && document.activeElement.tagName) || '';
- if (tag === 'INPUT' || tag === 'TEXTAREA') return;
-
- const currentUrlInput = document.getElementById('url-input');
- if (currentUrlInput) {
- e.preventDefault();
- currentUrlInput.focus();
- }
- });
- }
- })();
- </script>
-</body>
-</html>
-\ No newline at end of file
+{% extends "base.html" %}
+{% from "_icons.html" import icon %}
+{% set page_title = 'URL Shortener' %}
+{% set page_description = 'Shorten and mask any public URL.' %}
+{% block page_class %}shorten{% endblock %}
+
+{% block content %}
+<section class="page-head">
+ <h1>URL Shortener</h1>
+ <p class="lede">Paste any public <code>https://</code> URL and get back a masked, shareable link. The destination is never exposed until someone visits the masked URL.</p>
+</section>
+
+<p class="callout callout-warning" role="note">
+ {{ icon('clock', 18) }}
+ <span><strong>Rate limit:</strong> each IP can create <strong>10 links per minute</strong> and <strong>50 per hour</strong>. Failed attempts count too. Only <code>https://</code> URLs up to 2048 characters are accepted. <a href="/documentation#rate-limits">How rate limits work</a></span>
+</p>
+
+<div class="input-group" id="shorten-group">
+ <input type="url" id="url-input" class="url-input" placeholder="Paste a URL to shorten (e.g. https://example.com)" aria-label="URL to shorten" autocomplete="off" spellcheck="false" required>
+ <button type="button" class="input-clear-btn" id="url-clear" aria-label="Clear URL">{{ icon('close', 16, 2.5) }}</button>
+</div>
+
+<div class="actions">
+ <button type="button" class="btn btn-primary" id="shorten-btn">
+ <span class="btn-icon btn-icon-default">{{ icon('link') }}</span>
+ <span class="btn-icon btn-icon-spinner spin-anim">{{ icon('loader') }}</span>
+ <span id="shorten-btn-label">Shorten URL</span>
+ </button>
+</div>
+
+<div id="error-box" class="error-box" role="alert" aria-live="polite"></div>
+
+<div id="result" class="result-box" hidden>
+ <h2>Your masked URL</h2>
+ <div class="result-url">
+ <a href="#" id="masked-link" target="_blank" rel="noopener noreferrer"></a>
+ <button type="button" class="btn btn-sm" id="copy-btn" data-copy="">{{ icon('copy', 14) }}<span>Copy</span></button>
+ </div>
+ <p class="info-text">This link will redirect to the destination URL when visited.</p>
+</div>
+{% endblock %}
diff --git a/docs/UI_OVERHAUL.md b/docs/UI_OVERHAUL.md
@@ -0,0 +1,44 @@
+# UI overhaul
+
+Tracking document for the UI redesign. Scope is templates, CSS and JS; backend changes are limited to
+allow-listing the new static files in `app/main.py`.
+
+## Findings that motivated this
+
+- `app/templates/index.html` was an 84 KB monolith rendering seven page types with ~800 lines of inline CSS
+ and ~800 lines of inline JS, so every folder page shipped all of it.
+- Two CSS layers fought each other: legacy variables aliased onto `--ui-*`, overridden with `!important`.
+ Breadcrumb styles were duplicated, and four other templates re-declared their own `:root`.
+- The file page had no preview.
+- Copy feedback was a 0.8 s border flash; upload errors used `alert()`; dialogs lacked roles, focus
+ handling and Esc; no `<main>` or skip link.
+- `manifest.json` theme/background (`#000000`) disagreed with the page `theme-color`; `og:image` was relative.
+
+## Constraint: instant folders
+
+Folder pages are built both by Jinja and by `static/fasttree.js` from `_listing.html` macros.
+`tests/test_fast_tree.py` asserts they are identical. Listing markup changes go through the macros and the
+placeholder contract only.
+
+## Phases
+
+- [x] **1. Foundation** - shared `base.html`, CSS to `static/app.css`, JS to `static/app.js` (both cache-busted with a
+ content hash), one token set, legacy aliases and `!important` hacks removed, `index.html` split into
+ per-page `_page_*.html` partials, all five templates on one layout. The shared JS now loads on every page
+ (before, `/documentation`, `/stats`, etc. had no breadcrumb JS on a direct load).
+- [x] **2. Design system** - tokens for surfaces/text/brand/status/file-types, light + dark + system with a persistent
+ toggle (applied before first paint, no flash), brand blue accent with the logo's orange as a rare highlight.
+- [x] **3. Components** - navbar with a More menu, listing with colour-coded file-type icons and sorting, file page with
+ previews (image/video/audio/PDF/text) + labelled link rows + embed snippets, upload queue, toasts, native
+ `<dialog>` modals, tabs, empty/404 states.
+ (Upload stays a single request per drop so multi-file drops keep their shared batch page; the queue shows
+ per-file rows with one shared progress value.)
+- [x] **4. Polish** - axe-core audit clean (0 violations on 13 pages x light/dark x desktop/mobile), underlined in-text
+ links, focusable scroll regions, contrast fixes, h1 on every page, manifest colours match the page, absolute
+ `og:image`, README updated, `app.css`/`app.js` served gzipped (~22 KB total, cached).
+ Not done: SRI on the Turbo script (can't verify the live CDN bytes from here; vendoring Turbo is the safer
+ follow-up) and a configurable site name (needs a backend setting).
+
+## Safety rule for previews
+
+Uploaded HTML/SVG is never rendered inline on the main origin. It is shown as source text only.
diff --git a/tests/test_server_fastpaths.py b/tests/test_server_fastpaths.py
@@ -166,5 +166,12 @@ r = c.get("/no/such/folder", headers=gz)
check(r.status_code == 404 and "max-age" not in r.headers.get("cache-control", ""), "404 pages are not cached")
r = c.get("/static/sw.js", headers=gz)
check(r.headers["cache-control"].startswith("no-store") and "content-encoding" not in r.headers, "service worker is still no-store and untouched")
+for name in ("app.css", "app.js"):
+ r = c.get(f"/static/{name}", headers=gz)
+ plain = (m.STATIC_DIR / name).read_bytes()
+ check(r.headers.get("content-encoding") == "gzip" and r.content == plain and len(r.headers.get("content-length", "0")) > 0
+ and r.headers["vary"] == "Accept-Encoding", f"/static/{name} is gzipped and decodes to the original")
+ r = c.get(f"/static/{name}", headers={"accept-encoding": "identity"})
+ check("content-encoding" not in r.headers and r.content == plain, f"/static/{name} stays plain for clients without gzip")
print("\nall server fast-path checks passed")