ui: loading states, player failure overlay + retry, toasts, back nav, full history page with delete
Bundle 1-4+7 from PLAN.md: skeleton loaders, player Loading/Buffering/fatal overlay with Retry, action toasts, back-to-home crumb + logo fix, ?history full-history view with per-entry delete (GET /api/history?all=1, DELETE /api/history). 14/14 tests green.
This commit is contained in:
1 parent
19e878800c
commit
2ec0f8a034
10 files changed
+414
-20
No files matched your search
+105
-8
@@ -34,6 +34,19 @@ function fail(err) {
|
||||
main.append(el("p", "error", "Error: " + err.message));
|
||||
}
|
||||
|
||||
function skel(n = 8) {
|
||||
main.innerHTML = "";
|
||||
const g = el("div", "grid");
|
||||
for (let i = 0; i < n; i++) g.append(el("div", "card skel"));
|
||||
main.append(g);
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const t = el("div", "toast", msg);
|
||||
document.getElementById("toasts").append(t);
|
||||
setTimeout(() => t.remove(), 2600);
|
||||
}
|
||||
|
||||
function showStreamError(msg) {
|
||||
const p = el("p", "error", "Stream error: " + msg);
|
||||
const player = document.getElementById("player");
|
||||
@@ -94,7 +107,14 @@ function showHome() {
|
||||
main.append(sec);
|
||||
// 2-4. the rest as mini poster cards with progress + time-ago
|
||||
if (history.length > 1) {
|
||||
main.append(el("h2", null, "Recently played"));
|
||||
const head = el("div", "sec-head");
|
||||
head.append(el("h2", null, "Recently played"));
|
||||
if (history.length >= 5) {
|
||||
const va = el("a", null, "view all \u2192");
|
||||
va.href = BASE + "history";
|
||||
head.append(va);
|
||||
}
|
||||
main.append(head);
|
||||
const grid = el("div", "grid mini");
|
||||
for (const x of history.slice(1, 6)) {
|
||||
const c = el("a", "card");
|
||||
@@ -119,6 +139,7 @@ function showHome() {
|
||||
|
||||
// ---------- search ----------
|
||||
async function showSearch(q) {
|
||||
skel(8);
|
||||
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
||||
main.innerHTML = "";
|
||||
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
|
||||
@@ -136,6 +157,7 @@ async function showSearch(q) {
|
||||
|
||||
// ---------- detail + player ----------
|
||||
async function showAnime(slug_id, opts = {}) {
|
||||
skel();
|
||||
detail = await api(`/api/anime/${slug_id}`);
|
||||
lang = opts.lang || "eng";
|
||||
seasonId = opts.season || detail.id;
|
||||
@@ -143,7 +165,9 @@ async function showAnime(slug_id, opts = {}) {
|
||||
main.innerHTML = "";
|
||||
|
||||
const head = el("section", "detail-head");
|
||||
head.append(el("h2", null, detail.name));
|
||||
const crumb = el("a", "crumb", "\u2190 home");
|
||||
crumb.href = BASE;
|
||||
head.append(crumb, el("h2", null, detail.name));
|
||||
main.append(head);
|
||||
|
||||
const player = el("div", "player");
|
||||
@@ -176,9 +200,11 @@ async function showAnime(slug_id, opts = {}) {
|
||||
|
||||
const eps = el("div", "eps");
|
||||
eps.id = "eps";
|
||||
eps.append(el("p", "muted", "loading episodes\u2026"));
|
||||
main.append(eps);
|
||||
|
||||
const list = await api(`/api/anime/${seasonId}/episodes`);
|
||||
eps.innerHTML = "";
|
||||
for (const e of list) {
|
||||
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
||||
b.textContent = "E" + e.number;
|
||||
@@ -205,6 +231,45 @@ async function showAnime(slug_id, opts = {}) {
|
||||
|
||||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||||
|
||||
// ---------- history page (full list + delete) ----------
|
||||
async function showHistory() {
|
||||
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; }
|
||||
const grid = el("div", "grid");
|
||||
for (const x of items) grid.append(histCard(x));
|
||||
main.append(grid);
|
||||
}
|
||||
|
||||
function histCard(x) {
|
||||
const c = el("div", "card hist");
|
||||
c.onclick = () => location.href = histHref(x);
|
||||
if (x.poster) {
|
||||
const i = el("img"); i.src = x.poster; i.alt = "";
|
||||
c.append(i);
|
||||
}
|
||||
const info = el("div", "mini-info");
|
||||
info.append(el("div", "card-name", x.title),
|
||||
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}`));
|
||||
const pb = bar(x, "small");
|
||||
if (pb) info.append(pb);
|
||||
c.append(info);
|
||||
const d = el("button", "del", "\u00d7");
|
||||
d.title = "remove from history";
|
||||
d.onclick = async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
|
||||
"&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE"});
|
||||
toast("Removed from history");
|
||||
await showHistory();
|
||||
};
|
||||
c.append(d);
|
||||
return c;
|
||||
}
|
||||
|
||||
function markActive(b) {
|
||||
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
|
||||
b.classList.add("active");
|
||||
@@ -218,21 +283,44 @@ function videoEl() {
|
||||
|
||||
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
||||
|
||||
let currentEp = null, lastSavedPos = 0;
|
||||
let currentEp = null, lastSavedPos = 0, playerBroken = false;
|
||||
|
||||
function playerOverlay(text, onRetry) {
|
||||
const p = document.getElementById("player");
|
||||
let o = p.querySelector(".pl-overlay");
|
||||
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
|
||||
o.replaceChildren(el("span", null, text));
|
||||
if (onRetry) {
|
||||
const b = el("button", "btn", "Retry");
|
||||
b.onclick = onRetry;
|
||||
o.append(b);
|
||||
}
|
||||
}
|
||||
function playerHideOverlay() {
|
||||
const o = document.querySelector(".pl-overlay");
|
||||
if (o) o.remove();
|
||||
}
|
||||
function playerFail(msg) {
|
||||
if (!currentEp) return;
|
||||
playerBroken = true;
|
||||
const { ep_id, ep_no } = currentEp;
|
||||
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
||||
}
|
||||
|
||||
async function play(ep_id, ep_no) {
|
||||
playerBroken = false;
|
||||
try {
|
||||
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
||||
stopHls();
|
||||
const v = videoEl();
|
||||
document.getElementById("player").replaceChildren(v);
|
||||
playerOverlay("Loading\u2026");
|
||||
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
||||
hls = new Hls();
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
showStreamError(`${data.type}: ${data.details}` +
|
||||
(data.response ? ` (HTTP ${data.response.code})` : ""));
|
||||
stopHls();
|
||||
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
|
||||
}
|
||||
});
|
||||
hls.loadSource("/stream/" + s.token);
|
||||
@@ -245,7 +333,11 @@ async function play(ep_id, ep_no) {
|
||||
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
|
||||
}, {once: true});
|
||||
}
|
||||
v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
|
||||
v.onerror = () => playerFail("media error");
|
||||
v.addEventListener("playing", playerHideOverlay);
|
||||
v.addEventListener("waiting", () => {
|
||||
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
|
||||
});
|
||||
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
||||
watchPos(v);
|
||||
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||||
@@ -273,6 +365,8 @@ function watchPos(v) {
|
||||
}
|
||||
|
||||
function showPlayOverlay(v) {
|
||||
if (playerBroken) return; // failure overlay (with Retry) takes precedence
|
||||
playerHideOverlay();
|
||||
const o = el("button", "play-overlay", "▶ click to play");
|
||||
o.onclick = () => { v.play(); o.remove(); };
|
||||
v.parentElement.append(o);
|
||||
@@ -280,12 +374,13 @@ function showPlayOverlay(v) {
|
||||
|
||||
// ---------- boot ----------
|
||||
(async () => {
|
||||
document.querySelector(".logo").href = BASE;
|
||||
try {
|
||||
const me = await api("/api/me");
|
||||
document.getElementById("me").textContent = "hi, " + me.user;
|
||||
const lo = document.getElementById("logout");
|
||||
lo.onclick = async () => {
|
||||
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login";
|
||||
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1";
|
||||
};
|
||||
const mb = document.getElementById("menubox");
|
||||
mb.style.display = "";
|
||||
@@ -293,10 +388,11 @@ function showPlayOverlay(v) {
|
||||
e.stopPropagation(); mb.classList.toggle("open");
|
||||
};
|
||||
document.addEventListener("click", () => mb.classList.remove("open"));
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); });
|
||||
const da = document.getElementById("delacct");
|
||||
da.onclick = async () => {
|
||||
if (!confirm("Delete your account and watch history?")) return;
|
||||
await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login";
|
||||
await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login?deleted=1";
|
||||
};
|
||||
} catch { /* not logged in: api() already redirected to /login */ }
|
||||
await refreshHistory();
|
||||
@@ -308,6 +404,7 @@ function showPlayOverlay(v) {
|
||||
ep: p.get("ep") ? +p.get("ep") : null,
|
||||
lang: p.get("lang") || "eng",
|
||||
});
|
||||
else if (p.has("history")) await showHistory();
|
||||
else showHome();
|
||||
} catch (e) { fail(e); }
|
||||
})();
|
||||
+2
-1
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="logo" href="/" onclick="location.replace('/');return false">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">
|
||||
<input id="q" placeholder="search anime…" autocomplete="off">
|
||||
</form>
|
||||
@@ -22,6 +22,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
|
||||
<div id="toasts"></div>
|
||||
<script src="hls.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -16,9 +16,13 @@ input { width:100%; box-sizing:border-box; padding:.55rem .8rem; border-radius:8
|
||||
button { width:100%; margin-top:.4rem; padding:.55rem; border:0; border-radius:8px;
|
||||
background:var(--acc); color:#000; font-weight:600; cursor:pointer; }
|
||||
.err { color:#f87171; font-size:.85rem; margin:.6rem 0 0; min-height:1.2em; }
|
||||
.banner { position:fixed; top:1rem; left:50%; transform:translateX(-50%); background:var(--card);
|
||||
border:1px solid var(--bd); border-radius:8px; padding:.4rem .9rem; color:var(--muted);
|
||||
font-size:.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="banner" id="banner" hidden></p>
|
||||
<form onsubmit="event.preventDefault(); login()">
|
||||
<h1>ani-web</h1>
|
||||
<input id="user" placeholder="username" autocomplete="username">
|
||||
@@ -35,6 +39,11 @@ const BASE = (() => {
|
||||
})();
|
||||
const $ = id => document.getElementById(id);
|
||||
const next = new URLSearchParams(location.search).get("next");
|
||||
(() => { // one-shot confirmations for logout / account delete
|
||||
const s = new URLSearchParams(location.search);
|
||||
const note = s.get("signedout") ? "Signed out." : s.get("deleted") ? "Account deleted." : null;
|
||||
if (note) { const b = $("banner"); b.textContent = note; b.hidden = false; }
|
||||
})();
|
||||
async function login() {
|
||||
$("err").textContent = "";
|
||||
const r = await fetch(BASE + "login", {method: "POST",
|
||||
|
||||
@@ -69,6 +69,28 @@ button:hover { border-color: var(--acc); }
|
||||
.tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; }
|
||||
.tab.active { border-color: var(--acc); color: var(--acc); }
|
||||
|
||||
/* loading + feedback */
|
||||
@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
|
||||
.skel { aspect-ratio: 2/3; border: 1px solid var(--bd); border-radius: 10px;
|
||||
background: linear-gradient(90deg, var(--card) 25%, #1f2030 37%, var(--card) 63%);
|
||||
background-size: 400% 100%; animation: shimmer 1.4s ease infinite; }
|
||||
.pl-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: .6rem;
|
||||
align-items: center; justify-content: center; background: rgba(0, 0, 0, .72);
|
||||
border-radius: 10px; color: var(--fg); font-size: .95rem; z-index: 1; }
|
||||
#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
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; }
|
||||
.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; }
|
||||
.card.hist { position: relative; cursor: pointer; }
|
||||
.del { position: absolute; top: .35rem; right: .35rem; width: 1.5rem; height: 1.5rem;
|
||||
border-radius: 50%; border: 1px solid var(--bd); background: rgba(14, 15, 19, .8);
|
||||
color: var(--muted); font-size: .9rem; line-height: 1; padding: 0; cursor: pointer; }
|
||||
.del:hover { border-color: #f87171; color: #f87171; }
|
||||
|
||||
.eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; }
|
||||
.ep { position: relative; font-size: 0.9rem; }
|
||||
.ep.active { border-color: var(--acc); color: var(--acc); }
|
||||
|
||||
Reference in new issue
Block a user