From c2461f8fba3219fb3f85b4fb5ca5339e84eeead6 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Sun, 30 Aug 2026 20:53:55 +0000 Subject: [PATCH] UI/UX polish: a11y, player controls, undo, stats ranges - toasts: aria-live region + action button (used for history delete Undo) - history cards: keyboard-reachable title link; delete = remove + Undo (re-POSTs the entry; no full list re-render) - fail(): home/retry affordances instead of a dead-end error line - per-view document.title + move focus to the new view on render - player controls: episode jump (E#), auto-next toggle (persisted), picture-in-picture (when supported) - auto-next off: 'Next: En' toast with Play instead of the countdown - no-results search + empty home: recent-search chips (shared helper) - stats: 30d/90d/1y period chips (api already supported days=N) - search input: autocapitalize/spellcheck off; reduced-motion disables shimmer/toast animation; username in the menu on narrow screens --- static/app.js | 149 ++++++++++++++++++++++++++++++++++++++-------- static/index.html | 4 +- static/style.css | 7 ++- 3 files changed, 133 insertions(+), 27 deletions(-) diff --git a/static/app.js b/static/app.js index 9c2b47e..5c13bda 100644 --- a/static/app.js +++ b/static/app.js @@ -32,6 +32,20 @@ function el(tag, cls, text) { function fail(err) { main.innerHTML = ""; main.append(el("p", "error", "Error: " + err.message)); + const row = el("div", "chips"); + const home = el("button", "tiny", "home"); + home.onclick = () => location.href = BASE; + const retry = el("button", "tiny", "retry"); + retry.onclick = () => location.reload(); + row.append(home, retry); + main.append(row); + focusMain(); +} + +function focusMain() { + const t = main.querySelector("h2") || main; + t.tabIndex = -1; + t.focus({preventScroll: true}); } function skel(n = 8) { @@ -41,10 +55,16 @@ function skel(n = 8) { main.append(g); } -function toast(msg) { - const t = el("div", "toast", msg); +function toast(msg, action) { + const t = el("div", "toast"); + t.append(el("span", null, msg)); + if (action) { + const b = el("button", "tiny", action.label); + b.onclick = () => { t.remove(); action.fn(); }; + t.append(b); + } document.getElementById("toasts").append(t); - setTimeout(() => t.remove(), 2600); + setTimeout(() => t.remove(), action ? 6000 : 2600); } function showStreamError(msg) { @@ -123,20 +143,29 @@ const showSec = (title, items, extra) => { main.append(head, grid); }; const link = (text, href) => Object.assign(el("a", null, text), {href: BASE + href}); +// recent-search chips under a "no results" / empty placeholder (shared by home + search) +function recentChips(placeholder) { + const p = el("p", "muted", "loading…"); + placeholder.after(p); + api("/api/searches").then(rs => { + if (!rs.length) { p.remove(); return; } + const d = el("div", null); + d.append(el("div", "muted", "recent searches")); + const chips = el("div", "chips"); + for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q))); + d.append(chips); + p.replaceWith(d); + }).catch(() => p.remove()); +} function showHome() { + document.title = "ani-web"; main.innerHTML = ""; if (!history.length) { - main.append(el("p", "muted empty", "Search for an anime to get started.")); - api("/api/searches").then(rs => { - if (!rs.length || !main.querySelector("p.empty")) return; - const d = el("div", null); - d.append(el("div", "muted", "recent searches")); - const chips = el("div", "chips"); - for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q))); - d.append(chips); - main.append(d); - }).catch(() => {}); + const p = el("p", "muted empty", "Search for an anime to get started."); + main.append(p); + recentChips(p); + focusMain(); return; } const hero = history[0]; @@ -179,6 +208,7 @@ function showHome() { link("history \u2192", "?history")); const sl = el("div", "muted", "loading\u2026"); main.append(sh, sl); + focusMain(); api("/api/stats?days=30").then(d => { sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` + ` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` + @@ -194,10 +224,17 @@ function saveSearch(q) { async function showSearch(q) { saveSearch(q); + document.title = q + " \u2014 ani-web"; skel(8); const results = await api(`/api/search?q=${encodeURIComponent(q)}`); main.innerHTML = ""; - if (!results.length) { main.append(el("p", "muted", "No results.")); return; } + if (!results.length) { + const p = el("p", "muted", "No results."); + main.append(p); + recentChips(p); + focusMain(); + return; + } main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`)); const grid = el("div", "grid"); for (const r of results) { @@ -209,6 +246,7 @@ async function showSearch(q) { grid.append(card); } main.append(grid); + focusMain(); } // ---------- recent searches dropdown ---------- @@ -243,6 +281,7 @@ async function showAnime(slug_id, opts = {}) { lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn"; seasonId = opts.season || detail.id; detail.slug_id = slug_id; + document.title = detail.name + " \u2014 ani-web"; main.innerHTML = ""; const head = el("section", "detail-head"); @@ -290,8 +329,36 @@ async function showAnime(slug_id, opts = {}) { fillB.classList.toggle("on", hideFiller); renderEps(); }; - controls.append(langBtn, spd, prevB, nextB, fillB, - el("span", "muted", " — click an episode to play")); + const jump = el("input", "spd"); + jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric"; + jump.placeholder = "E#"; jump.title = "jump to episode"; + jump.style.width = "4.5rem"; + jump.onchange = () => { + const t = epsList.find(e => e.number == +jump.value); + if (!t) { jump.value = ""; return; } + const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === t.id); + if (b) markActive(b); + play(t.id, t.number); + jump.value = ""; + }; + const anB = el("button", "tiny" + (autoNext ? " on" : ""), "auto-next"); + anB.title = "auto-play the next episode when one ends"; + anB.onclick = () => { + autoNext = !autoNext; + localStorage.setItem("aniweb_auto", autoNext ? "1" : "0"); + anB.classList.toggle("on", autoNext); + }; + const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB]; + if (document.pictureInPictureEnabled) { + const pipB = el("button", "tiny", "\u29c9 PiP"); + pipB.title = "picture-in-picture"; + pipB.onclick = () => { + const v = document.querySelector("#player video"); + if (v && v.requestPictureInPicture) v.requestPictureInPicture().catch(() => {}); + }; + ctl.push(pipB); + } + controls.append(...ctl, el("span", "muted", " — click an episode to play")); main.append(controls); const tabs = el("div", "tabs"); @@ -342,6 +409,7 @@ async function showAnime(slug_id, opts = {}) { } if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis)); + focusMain(); } function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } @@ -350,12 +418,14 @@ function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done" : (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching"); async function showHistory() { + document.title = "History \u2014 ani-web"; skel(10); const items = await api("/api/history?all=1"); main.innerHTML = ""; main.append(el("h2", null, "History")); - if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; } + if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; } main.append(...histBoard(items)); + focusMain(); } function histCard(x) { @@ -366,8 +436,10 @@ function histCard(x) { const i = el("img"); i.src = x.poster; i.alt = ""; c.append(i); } + const nm = el("a", "card-name", x.title); + nm.href = histHref(x); const info = el("div", "mini-info"); - info.append(el("div", "card-name", x.title), + info.append(nm, el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` + (hold ? " \u00b7 on hold" : ""))); const pb = bar(x, "small"); @@ -381,8 +453,11 @@ function histCard(x) { await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) + "&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE", headers: {"X-Aniweb": "1"}}); - toast("Removed from history"); - await showHistory(); + c.remove(); + const i = history.indexOf(x); + if (i >= 0) history.splice(i, 1); + // undo = re-POST the same entry (the endpoint upserts on (user, slug, ep)) + toast("Removed from history", {label: "Undo", fn: () => postHistory(x)}); }; c.append(d); return c; @@ -549,12 +624,19 @@ function heatSection() { return sec; } -async function showStats() { +async function renderStats(days) { + document.title = "Stats \u2014 ani-web"; skel(5); - const d = await api("/api/stats?days=30"); + const d = await api(`/api/stats?days=${days}`); main.innerHTML = ""; - main.append(el("h2", null, "Stats \u2014 last 30 days")); - main.append(statsCards(d)); + main.append(el("h2", null, `Stats \u2014 last ${days === 365 ? "year" : days + " days"}`)); + const chips = el("div", "chips"); + for (const dd of [30, 90, 365]) { + const b = el("button", "chip" + (dd === days ? " on" : ""), dd === 365 ? "1y" : dd + "d"); + b.onclick = () => renderStats(dd); + chips.append(b); + } + main.append(chips, statsCards(d)); main.append(heatSection()); const ta = topAnime(d); if (ta) main.append(ta); @@ -563,6 +645,11 @@ async function showStats() { el("h2", null, "Top searches"), el("div", "muted", d.top_searches.join(" \u00b7 ")))); } + focusMain(); +} +function showStats() { + const p = +new URLSearchParams(location.search).get("days") || 0; + return renderStats([30, 90, 365].includes(p) ? p : 30); } function markActive(b) { @@ -571,6 +658,7 @@ function markActive(b) { } let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1"; +let autoNext = localStorage.getItem("aniweb_auto") !== "0"; let watchDone = new Set(), watchNext = null, epProg = new Map(); function renderEps() { @@ -652,6 +740,15 @@ function onEnded() { const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id)); const nxt = vis[i + 1]; if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; } + if (!autoNext) { + playerHideOverlay(); + toast("Next: E" + nxt.number, {label: "\u25b6 Play", fn: () => { + const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id); + if (b) markActive(b); + play(nxt.id, nxt.number); + }}); + return; + } nextCountdown(nxt); } @@ -788,6 +885,10 @@ window.addEventListener("pagehide", savePos); }; const mb = document.getElementById("menubox"); mb.style.display = ""; + // #me is hidden on narrow screens: surface the username in the menu there + if (matchMedia("(max-width: 640px)").matches) { + document.getElementById("menu-items").prepend(el("div", "menu-user", "hi, " + me.user)); + } document.getElementById("menu").onclick = e => { e.stopPropagation(); mb.classList.toggle("open"); }; diff --git a/static/index.html b/static/index.html index 9dd38e9..3edccf1 100644 --- a/static/index.html +++ b/static/index.html @@ -12,7 +12,7 @@
- +

Search for an anime to get started.

-
+
diff --git a/static/style.css b/static/style.css index 5855116..edf06f6 100644 --- a/static/style.css +++ b/static/style.css @@ -16,6 +16,8 @@ button.tiny.danger { border-color: #7f1d1d; color: #f87171; } background: var(--card); border: 1px solid var(--bd); border-radius: 8px; padding: .35rem; min-width: 160px; z-index: 2; } #menubox.open #menu-items { display: block; } +.menu-user { padding: .45rem .6rem .3rem; font-size: .8rem; color: var(--muted); + border-bottom: 1px solid var(--bd); margin-bottom: .25rem; } #menu-items button, #menu-items a { display: block; width: 100%; text-align: left; margin-top: .15rem; } header #me { margin-left: auto; } #q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd); @@ -93,6 +95,8 @@ h2 { margin: 0.4rem 0; } .detail-prog { margin-top: .1rem; font-size: .85rem; } .mini-info { display: flex; flex-direction: column; padding: .5rem; } .mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; } +@media (prefers-reduced-motion: reduce) { .skel, .toast { animation: none; } } + @media (max-width: 640px) { .hero-sec { grid-template-columns: 100px 1fr; grid-template-areas: "img right"; gap: .8rem; padding: .8rem; } .hero img { width: 100px; } @@ -142,7 +146,8 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); } display: flex; flex-direction: column; gap: .4rem; z-index: 9; } @keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } .toast { background: var(--card); border: 1px solid var(--acc); color: var(--fg); - padding: .45rem .9rem; border-radius: 8px; font-size: .85rem; animation: toast-in .2s ease; } + padding: .45rem .9rem; border-radius: 8px; font-size: .85rem; animation: toast-in .2s ease; + display: flex; align-items: center; gap: .55rem; } .crumb { display: inline-block; font-size: .85rem; color: var(--muted); margin-bottom: .2rem; } .crumb:hover { color: var(--acc); } .sec-head { display: flex; align-items: baseline; gap: .8rem; }