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) {
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});
function showHome() {
main.innerHTML = "";
if (!history.length) {
main.append(el("p", "muted empty", "Search for an anime to get started."));
// 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 || !main.querySelector("p.empty")) return;
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);
main.append(d);
}).catch(() => {});
p.replaceWith(d);
}).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;
}
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");
};
+2 -2
View File
@@ -12,7 +12,7 @@
<header>
<a class="logo" href="/">ani-web</a>
<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>
<span id="me" class="muted"></span>
<div id="menubox" style="display:none">
@@ -25,7 +25,7 @@
</div>
</header>
<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="app.js"></script>
</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;
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; }