Offline retry queue for userscript writes #5
@@ -78,11 +78,24 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes.
|
||||||
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback.
|
||||||
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value.
|
||||||
4. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so a
|
||||||
|
failed mutation is parked in `localStorage` (`mangabm:queue`) and replayed on
|
||||||
|
the next navigation, reconnect, or `refresh()`. Entries are markers
|
||||||
|
(`{key, op, sendStatus, attempts}`), never payloads — the body is read from
|
||||||
|
the cache at send time, so one entry per key gives ordering and coalescing for
|
||||||
|
free. `sendStatus` is **sticky**: while an archive is pending, later writes to
|
||||||
|
that key keep carrying the bucket, which is what stops a successful
|
||||||
|
in-between write from silently un-archiving the series. `refresh()` drains
|
||||||
|
before it fetches and overlays anything still pending, so the list never
|
||||||
|
flaps. A 400 drops the entry, a 401 aborts the pass and keeps the queue, and
|
||||||
|
transient failures retry to a cap of 10. Latest-chapter writes deliberately
|
||||||
|
stay out of the queue. See
|
||||||
|
`docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`.
|
||||||
|
5. **UI** — rendered inside a **Shadow DOM** root to isolate from site CSS
|
||||||
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
(critical on mobile). Three tabs (All / Favourites / Archived) and a row of
|
||||||
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
link chips to the web UI and both manga sites; `WEB_BASE` sits in the CONFIG
|
||||||
block next to `API_BASE`.
|
block next to `API_BASE`.
|
||||||
5. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
6. **SPA navigation** — Asura is Astro, client-routed on the comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic uses classic reloads (initial `document-idle` run suffices).
|
||||||
|
|
||||||
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.3.0
|
// @version 1.4.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
@@ -265,6 +265,14 @@
|
|||||||
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The retry queue treats a 400 differently from a dropped connection, so the
|
||||||
|
// status has to survive the throw. A network failure leaves it undefined.
|
||||||
|
function httpError(what, status) {
|
||||||
|
const e = new Error(what + " " + status);
|
||||||
|
e.status = status;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
async function apiGet() {
|
async function apiGet() {
|
||||||
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
||||||
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
||||||
@@ -283,7 +291,7 @@
|
|||||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
if (!res.ok) throw httpError("PUT /bookmarks", res.status);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +300,8 @@
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
|
// 404 means the row is already gone, which is what the caller wanted.
|
||||||
|
if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -338,10 +347,250 @@
|
|||||||
return b.status || "reading";
|
return b.status || "reading";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Retry queue
|
||||||
|
//
|
||||||
|
// A failed write is not rolled back and not lost: the key is parked here and
|
||||||
|
// replayed when the backend is next reachable. Entries carry no payload — the
|
||||||
|
// body is read from state.byKey at send time, because state.list *is* the
|
||||||
|
// desired state and is already persisted in mangabm:cache. One entry per key,
|
||||||
|
// so two writes to the same series cannot replay out of order, an
|
||||||
|
// archive-then-unarchive collapses to whatever the cache now says, and a
|
||||||
|
// queued DELETE replaces a queued PUT rather than racing it.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const QUEUE_KEY = "mangabm:queue";
|
||||||
|
const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count
|
||||||
|
const QUEUE_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
let authFailed = false; // a 401 was seen; retrying cannot help until the token changes
|
||||||
|
|
||||||
|
function loadQueue() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(QUEUE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQueue(q) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(QUEUE_KEY, JSON.stringify(q));
|
||||||
|
} catch (e) {
|
||||||
|
/* quota / private mode — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = loadQueue();
|
||||||
|
|
||||||
|
function queueCount() {
|
||||||
|
return queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueGet(key) {
|
||||||
|
return queue.find((e) => e.key === key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFor(key) {
|
||||||
|
const b = state.byKey[key];
|
||||||
|
return (b && (b.title || b.series_id)) || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest goes first when the cap is hit: the newest entry is the action the
|
||||||
|
// user just took, and dropping that is the bug this queue exists to fix.
|
||||||
|
function queuePush(entry) {
|
||||||
|
if (queue.length >= QUEUE_MAX) {
|
||||||
|
const evicted = queue.shift();
|
||||||
|
toast("Sync queue full — dropped " + titleFor(evicted.key), true);
|
||||||
|
}
|
||||||
|
queue.push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueDrop(key) {
|
||||||
|
const i = queue.findIndex((e) => e.key === key);
|
||||||
|
if (i >= 0) {
|
||||||
|
queue.splice(i, 1);
|
||||||
|
saveQueue(queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parks a write without counting it as a failure. Used when the key is already
|
||||||
|
// on the wire: the write is owed, but nothing went wrong, so it must not spend
|
||||||
|
// one of the ten attempts.
|
||||||
|
function queueDefer(key, op, sendStatus) {
|
||||||
|
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
|
||||||
|
e.op = op; // a delete replaces a put, and a put replaces a delete
|
||||||
|
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
|
||||||
|
saveQueue(queue);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records a failed write and classifies why it failed. A 400 is a payload the
|
||||||
|
// server will never accept, so it is dropped now instead of being retried ten
|
||||||
|
// times; a 401 is the wrong token, so the entry is kept untouched and the
|
||||||
|
// drain gives up until the token changes.
|
||||||
|
function queueEnqueue(key, op, sendStatus, err) {
|
||||||
|
const status = err && err.status;
|
||||||
|
if (status === 400) {
|
||||||
|
queueDrop(key);
|
||||||
|
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const e = queueDefer(key, op, sendStatus);
|
||||||
|
if (status === 401) {
|
||||||
|
authFailed = true;
|
||||||
|
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
|
||||||
|
queueDrop(key);
|
||||||
|
toast("Gave up syncing " + titleFor(key), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveQueue(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys with a request on the wire right now, mapped to "another write arrived
|
||||||
|
// while this one was flying". A second write to the same key is never sent
|
||||||
|
// concurrently — the two responses would race to own the row — it is parked in
|
||||||
|
// the queue instead and a later drain replays it. The flight already in the
|
||||||
|
// air must then leave that entry alone and not adopt its own now-stale
|
||||||
|
// response, or it would undo the write the user just made.
|
||||||
|
const inFlight = new Map();
|
||||||
|
|
||||||
|
let draining = null; // the pass in progress, so a second caller awaits it
|
||||||
|
|
||||||
|
// Replays everything owed. Cheap in the normal case — it returns on the first
|
||||||
|
// line when the queue is empty, which is why it can hang off navigation. A
|
||||||
|
// 401 stops the whole pass: the token is wrong, so the next entry would fail
|
||||||
|
// the same way, and the queue is left intact so fixing the token fixes it.
|
||||||
|
//
|
||||||
|
// Returns the in-flight pass when one is already running, so refresh()'s
|
||||||
|
// `await drain()` really does wait for what we owe instead of racing a drain
|
||||||
|
// that onNavigate or the online listener started unawaited.
|
||||||
|
function drain() {
|
||||||
|
if (queue.length === 0) return Promise.resolve();
|
||||||
|
if (draining) return draining;
|
||||||
|
const pass = (async () => {
|
||||||
|
authFailed = false;
|
||||||
|
try {
|
||||||
|
for (const e of queue.slice()) {
|
||||||
|
if (e.op === "delete") await pushDelete(e.key);
|
||||||
|
else await pushBookmark(e.key, e.sendStatus);
|
||||||
|
if (authFailed) {
|
||||||
|
toast("Sync auth failed — check the token", true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Swallowed, not surfaced: drain is called unawaited from onNavigate and the
|
||||||
|
// online listener, and an uncaught rejection on a page we do not control is
|
||||||
|
// a console error nobody can act on. Every real sync failure is already
|
||||||
|
// toasted and queued by pushBookmark/pushDelete.
|
||||||
|
draining = pass
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
draining = null;
|
||||||
|
});
|
||||||
|
return draining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys the server has not heard about yet must survive a fetched list, or the
|
||||||
|
// card the user just changed silently flaps back — the exact bug this queue
|
||||||
|
// exists to fix. Reads state.byKey, so it must run *before* setList replaces
|
||||||
|
// it.
|
||||||
|
function overlayPending(list) {
|
||||||
|
if (queue.length === 0) return list;
|
||||||
|
const out = list.filter((b) => {
|
||||||
|
const e = queueGet(b.key);
|
||||||
|
return !(e && e.op === "delete");
|
||||||
|
});
|
||||||
|
for (const e of queue) {
|
||||||
|
if (e.op !== "put") continue;
|
||||||
|
const local = state.byKey[e.key];
|
||||||
|
if (!local) continue;
|
||||||
|
const i = out.findIndex((b) => b.key === e.key);
|
||||||
|
if (i >= 0) out[i] = local;
|
||||||
|
else out.push(local);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Mutations (optimistic: update UI/cache first, then sync)
|
// Mutations (optimistic: update UI/cache first, then sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
// Every write goes through here, queued or not, so there is one place that
|
||||||
|
// talks to the API and one place that decides `sendStatus`. That flag is
|
||||||
|
// sticky: while an archive is pending for a key, a later progress write to the
|
||||||
|
// same key still carries the bucket — otherwise the server would answer with
|
||||||
|
// the old status and the adopted row would silently un-archive the series.
|
||||||
|
async function pushBookmark(key, sendStatus) {
|
||||||
|
const pending = queueGet(key);
|
||||||
|
const withStatus = sendStatus || (pending ? pending.sendStatus : false);
|
||||||
|
const bm = state.byKey[key];
|
||||||
|
if (!bm) {
|
||||||
|
queueDrop(key); // removed locally in the meantime — nothing left to send
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "put", withStatus); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
// ponytail: last-write-wins, so a replay can overwrite a newer server
|
||||||
|
// value (a poller-written latest_chapter, or progress from another
|
||||||
|
// device). Single user, self-healing on the next poll — revisit only if
|
||||||
|
// this ever runs multi-user.
|
||||||
|
const saved = await apiPut(key, bm, { sendStatus: withStatus });
|
||||||
|
if (!inFlight.get(key)) {
|
||||||
|
upsertLocal(saved);
|
||||||
|
queueDrop(key);
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Same guard as the success path: a newer write is parked for this key, so
|
||||||
|
// classifying *this* failure would clobber its op or, on a 400, drop it
|
||||||
|
// outright. Its own drain reports its own outcome. The one thing that must
|
||||||
|
// still carry across is sendStatus — this flight may have been the archive
|
||||||
|
// replay, and losing its flag here would narrow the parked write into a
|
||||||
|
// silent un-archive.
|
||||||
|
const parked = inFlight.get(key) ? queueGet(key) : null;
|
||||||
|
if (parked) queueDefer(key, parked.op, withStatus);
|
||||||
|
else queueEnqueue(key, "put", withStatus, e);
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
// Outside the try: a throw in render() is a UI bug, not a write failure, and
|
||||||
|
// must not re-queue a write that already landed.
|
||||||
|
if (ok) render();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDelete(key) {
|
||||||
|
if (inFlight.has(key)) {
|
||||||
|
queueDefer(key, "delete", false); // see inFlight — parked, not sent
|
||||||
|
inFlight.set(key, true); // supersedes the flight already in the air
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
inFlight.set(key, false);
|
||||||
|
try {
|
||||||
|
await apiDelete(key);
|
||||||
|
if (!inFlight.get(key)) queueDrop(key);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bookmarkCurrent() {
|
async function bookmarkCurrent() {
|
||||||
const p = state.page;
|
const p = state.page;
|
||||||
if (p.type !== "series" && p.type !== "chapter") return;
|
if (p.type !== "series" && p.type !== "chapter") return;
|
||||||
@@ -400,14 +649,8 @@
|
|||||||
async function syncUpsert(bm, okMsg) {
|
async function syncUpsert(bm, okMsg) {
|
||||||
upsertLocal(bm); // optimistic
|
upsertLocal(bm); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (await pushBookmark(bm.key, false)) toast(okMsg);
|
||||||
const saved = await apiPut(bm.key, bm);
|
else toast("Saved — will sync when online", true);
|
||||||
upsertLocal(saved); // adopt server updated_at
|
|
||||||
render();
|
|
||||||
toast(okMsg);
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — saved locally, will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFavorite(key) {
|
async function toggleFavorite(key) {
|
||||||
@@ -419,13 +662,7 @@
|
|||||||
});
|
});
|
||||||
upsertLocal(bm); // optimistic
|
upsertLocal(bm); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true);
|
||||||
const saved = await apiPut(bm.key, bm);
|
|
||||||
upsertLocal(saved);
|
|
||||||
render();
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — saved locally, will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive parks a series: it leaves All and Favourites but the server keeps
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
@@ -435,16 +672,14 @@
|
|||||||
const existing = state.byKey[key];
|
const existing = state.byKey[key];
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
||||||
const bm = Object.assign({}, existing, { status: next });
|
upsertLocal(Object.assign({}, existing, { status: next }));
|
||||||
upsertLocal(bm);
|
|
||||||
render();
|
render();
|
||||||
try {
|
// The only write with an opinion about the bucket, so the only one that
|
||||||
const saved = await apiPut(bm.key, bm, { sendStatus: true });
|
// sends `status` at all.
|
||||||
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
if (await pushBookmark(key, true)) {
|
||||||
render();
|
|
||||||
toast(next === "archived" ? "Archived" : "Back in your list");
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||||
} catch (e) {
|
} else {
|
||||||
toast("Archive failed — retry when online", true);
|
toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +697,9 @@
|
|||||||
});
|
});
|
||||||
upsertLocal(bm);
|
upsertLocal(bm);
|
||||||
render();
|
render();
|
||||||
|
// A queued write owns this row; the drain sends latest_chapter
|
||||||
|
// with it, carrying the correct bucket.
|
||||||
|
if (queueGet(bm.key)) return;
|
||||||
try {
|
try {
|
||||||
const saved = await apiPut(bm.key, bm);
|
const saved = await apiPut(bm.key, bm);
|
||||||
upsertLocal(saved);
|
upsertLocal(saved);
|
||||||
@@ -521,12 +759,8 @@
|
|||||||
async function removeBookmark(key) {
|
async function removeBookmark(key) {
|
||||||
removeLocal(key); // optimistic
|
removeLocal(key); // optimistic
|
||||||
render();
|
render();
|
||||||
try {
|
if (await pushDelete(key)) toast("Removed");
|
||||||
await apiDelete(key);
|
else toast("Removed — will sync when online", true);
|
||||||
toast("Removed");
|
|
||||||
} catch (e) {
|
|
||||||
toast("Offline — remove will retry", true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
||||||
@@ -613,6 +847,7 @@
|
|||||||
});
|
});
|
||||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||||
|
root.getElementById("pending").addEventListener("click", () => drain());
|
||||||
|
|
||||||
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).addEventListener("click", () => {
|
root.getElementById(id).addEventListener("click", () => {
|
||||||
@@ -765,6 +1000,11 @@
|
|||||||
root.getElementById("panel").classList.toggle("open", panelOpen);
|
root.getElementById("panel").classList.toggle("open", panelOpen);
|
||||||
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
||||||
|
|
||||||
|
// Silent while everything is synced; honest the moment something is stuck.
|
||||||
|
const pending = root.getElementById("pending");
|
||||||
|
pending.hidden = queueCount() === 0;
|
||||||
|
pending.textContent = "⟳ " + queueCount() + " pending";
|
||||||
|
|
||||||
// Context header for the current page.
|
// Context header for the current page.
|
||||||
const ctx = root.getElementById("context");
|
const ctx = root.getElementById("context");
|
||||||
ctx.innerHTML = "";
|
ctx.innerHTML = "";
|
||||||
@@ -877,9 +1117,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
await drain(); // push what we owe before adopting the server's view of it
|
||||||
try {
|
try {
|
||||||
const list = await apiGet();
|
const list = await apiGet();
|
||||||
setList(list);
|
setList(overlayPending(list));
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
render(); // fall back to cache
|
render(); // fall back to cache
|
||||||
@@ -895,6 +1136,9 @@
|
|||||||
// Asura is client-routed, so full loads are rare — checking here too is
|
// Asura is client-routed, so full loads are rare — checking here too is
|
||||||
// what keeps its bookmarks current. The throttle still caps the rate.
|
// what keeps its bookmarks current. The throttle still caps the rate.
|
||||||
backgroundRefreshLatest();
|
backgroundRefreshLatest();
|
||||||
|
// Drain only, not a full refresh: Asura may go a long time without a
|
||||||
|
// reload, and an extra GET per client-side route change is not wanted.
|
||||||
|
drain();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
||||||
@@ -930,6 +1174,7 @@
|
|||||||
state.page = detect();
|
state.page = detect();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||||
// know which series are bookmarked and how fresh they are.
|
// know which series are bookmarked and how fresh they are.
|
||||||
refresh().then(() => {
|
refresh().then(() => {
|
||||||
@@ -958,6 +1203,7 @@
|
|||||||
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||||
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||||
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||||
|
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
||||||
</div>
|
</div>
|
||||||
<section id="context"></section>
|
<section id="context"></section>
|
||||||
<div id="tabs" role="tablist">
|
<div id="tabs" role="tablist">
|
||||||
@@ -1018,6 +1264,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.chip:active { opacity: .8; }
|
.chip:active { opacity: .8; }
|
||||||
|
.chip.pending { background: #4c1d95; border: none; cursor: pointer; }
|
||||||
|
.chip.pending[hidden] { display: none; }
|
||||||
#context {
|
#context {
|
||||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user