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
This commit is contained in:
jozamudi committed 2026-08-30 20:53:55 +00:00
1 parent 8f519b0251
commit c2461f8fba
3 files changed
+131 -25

No files matched your search

+123 -22
View File
@@ -32,6 +32,20 @@ function el(tag, cls, text) {
function fail(err) { function fail(err) {
main.innerHTML = ""; main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message)); 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) { function skel(n = 8) {
@@ -41,10 +55,16 @@ function skel(n = 8) {
main.append(g); main.append(g);
} }
function toast(msg) { function toast(msg, action) {
const t = el("div", "toast", msg); 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); document.getElementById("toasts").append(t);
setTimeout(() => t.remove(), 2600); setTimeout(() => t.remove(), action ? 6000 : 2600);
} }
function showStreamError(msg) { function showStreamError(msg) {
@@ -123,20 +143,29 @@ const showSec = (title, items, extra) => {
main.append(head, grid); main.append(head, grid);
}; };
const link = (text, href) => Object.assign(el("a", null, text), {href: BASE + href}); 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 showHome() { function recentChips(placeholder) {
main.innerHTML = ""; const p = el("p", "muted", "loading…");
if (!history.length) { placeholder.after(p);
main.append(el("p", "muted empty", "Search for an anime to get started."));
api("/api/searches").then(rs => { api("/api/searches").then(rs => {
if (!rs.length || !main.querySelector("p.empty")) return; if (!rs.length) { p.remove(); return; }
const d = el("div", null); const d = el("div", null);
d.append(el("div", "muted", "recent searches")); d.append(el("div", "muted", "recent searches"));
const chips = el("div", "chips"); const chips = el("div", "chips");
for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q))); for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q)));
d.append(chips); d.append(chips);
main.append(d); p.replaceWith(d);
}).catch(() => {}); }).catch(() => p.remove());
}
function showHome() {
document.title = "ani-web";
main.innerHTML = "";
if (!history.length) {
const p = el("p", "muted empty", "Search for an anime to get started.");
main.append(p);
recentChips(p);
focusMain();
return; return;
} }
const hero = history[0]; const hero = history[0];
@@ -179,6 +208,7 @@ function showHome() {
link("history \u2192", "?history")); link("history \u2192", "?history"));
const sl = el("div", "muted", "loading\u2026"); const sl = el("div", "muted", "loading\u2026");
main.append(sh, sl); main.append(sh, sl);
focusMain();
api("/api/stats?days=30").then(d => { api("/api/stats?days=30").then(d => {
sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` + sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` +
` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` + ` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` +
@@ -194,10 +224,17 @@ function saveSearch(q) {
async function showSearch(q) { async function showSearch(q) {
saveSearch(q); saveSearch(q);
document.title = q + " \u2014 ani-web";
skel(8); skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`); const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = ""; 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" : ""}`)); main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid"); const grid = el("div", "grid");
for (const r of results) { for (const r of results) {
@@ -209,6 +246,7 @@ async function showSearch(q) {
grid.append(card); grid.append(card);
} }
main.append(grid); main.append(grid);
focusMain();
} }
// ---------- recent searches dropdown ---------- // ---------- recent searches dropdown ----------
@@ -243,6 +281,7 @@ async function showAnime(slug_id, opts = {}) {
lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn"; lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn";
seasonId = opts.season || detail.id; seasonId = opts.season || detail.id;
detail.slug_id = slug_id; detail.slug_id = slug_id;
document.title = detail.name + " \u2014 ani-web";
main.innerHTML = ""; main.innerHTML = "";
const head = el("section", "detail-head"); const head = el("section", "detail-head");
@@ -290,8 +329,36 @@ async function showAnime(slug_id, opts = {}) {
fillB.classList.toggle("on", hideFiller); fillB.classList.toggle("on", hideFiller);
renderEps(); renderEps();
}; };
controls.append(langBtn, spd, prevB, nextB, fillB, const jump = el("input", "spd");
el("span", "muted", " — click an episode to play")); 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); main.append(controls);
const tabs = el("div", "tabs"); 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)); if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
focusMain();
} }
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } 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" const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching"); : (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
async function showHistory() { async function showHistory() {
document.title = "History \u2014 ani-web";
skel(10); skel(10);
const items = await api("/api/history?all=1"); const items = await api("/api/history?all=1");
main.innerHTML = ""; main.innerHTML = "";
main.append(el("h2", null, "History")); 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)); main.append(...histBoard(items));
focusMain();
} }
function histCard(x) { function histCard(x) {
@@ -366,8 +436,10 @@ function histCard(x) {
const i = el("img"); i.src = x.poster; i.alt = ""; const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i); c.append(i);
} }
const nm = el("a", "card-name", x.title);
nm.href = histHref(x);
const info = el("div", "mini-info"); 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)}` + el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
(hold ? " \u00b7 on hold" : ""))); (hold ? " \u00b7 on hold" : "")));
const pb = bar(x, "small"); const pb = bar(x, "small");
@@ -381,8 +453,11 @@ function histCard(x) {
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) + await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id), "&ep_id=" + encodeURIComponent(x.ep_id),
{method: "DELETE", headers: {"X-Aniweb": "1"}}); {method: "DELETE", headers: {"X-Aniweb": "1"}});
toast("Removed from history"); c.remove();
await showHistory(); 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); c.append(d);
return c; return c;
@@ -549,12 +624,19 @@ function heatSection() {
return sec; return sec;
} }
async function showStats() { async function renderStats(days) {
document.title = "Stats \u2014 ani-web";
skel(5); skel(5);
const d = await api("/api/stats?days=30"); const d = await api(`/api/stats?days=${days}`);
main.innerHTML = ""; main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days")); main.append(el("h2", null, `Stats \u2014 last ${days === 365 ? "year" : days + " days"}`));
main.append(statsCards(d)); 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()); main.append(heatSection());
const ta = topAnime(d); const ta = topAnime(d);
if (ta) main.append(ta); if (ta) main.append(ta);
@@ -563,6 +645,11 @@ async function showStats() {
el("h2", null, "Top searches"), el("h2", null, "Top searches"),
el("div", "muted", d.top_searches.join(" \u00b7 ")))); 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) { function markActive(b) {
@@ -571,6 +658,7 @@ function markActive(b) {
} }
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1"; let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
let autoNext = localStorage.getItem("aniweb_auto") !== "0";
let watchDone = new Set(), watchNext = null, epProg = new Map(); let watchDone = new Set(), watchNext = null, epProg = new Map();
function renderEps() { function renderEps() {
@@ -652,6 +740,15 @@ function onEnded() {
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id)); const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const nxt = vis[i + 1]; const nxt = vis[i + 1];
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; } 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); nextCountdown(nxt);
} }
@@ -788,6 +885,10 @@ window.addEventListener("pagehide", savePos);
}; };
const mb = document.getElementById("menubox"); const mb = document.getElementById("menubox");
mb.style.display = ""; 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 => { document.getElementById("menu").onclick = e => {
e.stopPropagation(); mb.classList.toggle("open"); e.stopPropagation(); mb.classList.toggle("open");
}; };
+2 -2
View File
@@ -12,7 +12,7 @@
<header> <header>
<a class="logo" href="/">ani-web</a> <a class="logo" href="/">ani-web</a>
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false"> <form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" autocomplete="off"> <input id="q" placeholder="search anime…" autocomplete="off" autocapitalize="off" spellcheck="false">
</form> </form>
<span id="me" class="muted"></span> <span id="me" class="muted"></span>
<div id="menubox" style="display:none"> <div id="menubox" style="display:none">
@@ -25,7 +25,7 @@
</div> </div>
</header> </header>
<main id="main"><p class="muted">Search for an anime to get started.</p></main> <main id="main"><p class="muted">Search for an anime to get started.</p></main>
<div id="toasts"></div> <div id="toasts" aria-live="polite"></div>
<script src="hls.min.js"></script> <script src="hls.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+6 -1
View File
@@ -16,6 +16,8 @@ button.tiny.danger { border-color: #7f1d1d; color: #f87171; }
background: var(--card); border: 1px solid var(--bd); border-radius: 8px; background: var(--card); border: 1px solid var(--bd); border-radius: 8px;
padding: .35rem; min-width: 160px; z-index: 2; } padding: .35rem; min-width: 160px; z-index: 2; }
#menubox.open #menu-items { display: block; } #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; } #menu-items button, #menu-items a { display: block; width: 100%; text-align: left; margin-top: .15rem; }
header #me { margin-left: auto; } header #me { margin-left: auto; }
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd); #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; } .detail-prog { margin-top: .1rem; font-size: .85rem; }
.mini-info { display: flex; flex-direction: column; padding: .5rem; } .mini-info { display: flex; flex-direction: column; padding: .5rem; }
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; } .mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
@media (prefers-reduced-motion: reduce) { .skel, .toast { animation: none; } }
@media (max-width: 640px) { @media (max-width: 640px) {
.hero-sec { grid-template-columns: 100px 1fr; grid-template-areas: "img right"; gap: .8rem; padding: .8rem; } .hero-sec { grid-template-columns: 100px 1fr; grid-template-areas: "img right"; gap: .8rem; padding: .8rem; }
.hero img { width: 100px; } .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; } display: flex; flex-direction: column; gap: .4rem; z-index: 9; }
@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } @keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } }
.toast { background: var(--card); border: 1px solid var(--acc); color: var(--fg); .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 { display: inline-block; font-size: .85rem; color: var(--muted); margin-bottom: .2rem; }
.crumb:hover { color: var(--acc); } .crumb:hover { color: var(--acc); }
.sec-head { display: flex; align-items: baseline; gap: .8rem; } .sec-head { display: flex; align-items: baseline; gap: .8rem; }