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:
1 parent
8f519b0251
commit
c2461f8fba
3 files changed
+131
-25
No files matched your search
+123
-22
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user