Serve the userscript from the backend; card + loading fixes (#8)
Serves the userscript from the backend so Violentmonkey auto-updates it, plus two panel fixes.
## Backend: `GET /u/{token}/manga-bookmark.user.js`
The script is read off disk per request from `USERSCRIPT_PATH` and streamed back with its `@version` line rewritten.
- **Token in the path, not a header.** Violentmonkey's update poll sends no `Authorization` header, and the script embeds `API_TOKEN` in plain text — an open URL would hand that token to anyone who guessed it. Compare is constant-time.
- **404, never 401**, for both a wrong token and a missing file: a prober learns nothing about whether the route exists.
- Registered outside `withAuth` and outside the `WEB_PASSWORD` gate, so the script is installable on a deployment that never enabled the web UI.
- Stdlib only (`crypto/subtle`, `os`, `regexp`) — no new Go dependencies.
**The served `@version` is derived from the file's mtime** (`YYYY.MM.DD.HHMM`, UTC), discarding whatever the file body says. Violentmonkey only updates when the served version sorts higher than the installed one, so a body-derived version means one typo or accidental downgrade freezes updates forever. An mtime-derived version is monotonic by construction. A file with no `@version` line is served byte-identical. `os.Stat` runs before `os.ReadFile`, so a concurrent edit can only serve new content under an old stamp — which self-heals on the next poll — never the reverse.
## Bindmount
`./userscript` is bindmounted read-only at `/userscript`. The script is deliberately **not** copied into the image: the build context stays `./backend`, and widening it would churn every `COPY` path for a file the mount always supplies. Editing the file on the VPS is live on the next poll — no rebuild, no restart. `git pull` restores the committed version, so a redeploy always ships the repo's script; checkout sets mtime to now, so even a rollback serves a *higher* version and is adopted. Without the mount the endpoint 404s and logs it; bookmark sync is unaffected.
`@downloadURL` / `@updateURL` are literal URLs in the metadata block — it is parsed before any JS runs, so `API_BASE`/`API_TOKEN` cannot be interpolated. The token was already committed in this file, so this adds no new exposure.
## Userscript UI
- **Card actions moved under the subtitle.** Only the cover and the title continue reading now; the subtitle and the action row are inert siblings in the text column. A thumb that misses ★ lands on nothing, and Remove is never inside a link.
- **Loading spinner** while the first fetch is in flight — the panel used to read as frozen on the first open after a cold start. It draws only when there is nothing cached to draw instead, so a populated list never flaps.
## Verification
- `go test -count=1 ./...` — ok, 7.070s
- `node --check` clean; `node --test userscript/test/logic.test.js` — 14/14
- Live `docker compose` smoke: `/healthz` 200, wrong token 404, script served with a stamped `@version 2026.07.28.1057` and both metadata URLs present; `touch`ing the file advanced the served version to `2026.07.28.1100` with no restart.
Layout and spinner are verified on-device — there is deliberately no DOM test harness.
Reviewed-on: #8
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #8.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
// @version 1.5.0
|
||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||
// @author you
|
||||
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||
// @match https://asuracomic.net/*
|
||||
// @match https://asurascans.com/*
|
||||
// @match https://demonicscans.org/*
|
||||
@@ -850,6 +852,9 @@
|
||||
// ============================================================
|
||||
|
||||
let root, panelOpen = false;
|
||||
// True while refresh() is waiting on the backend. Only changes what the list
|
||||
// draws when there is nothing cached to draw instead.
|
||||
let loading = false;
|
||||
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
||||
|
||||
function buildUI() {
|
||||
@@ -1069,7 +1074,11 @@
|
||||
if (s !== "reading") return false;
|
||||
return activeTab !== "favorites" || b.favorite;
|
||||
});
|
||||
if (items.length === 0) {
|
||||
if (items.length === 0 && loading) {
|
||||
// Nothing cached and the fetch is still out — without this the panel
|
||||
// looks frozen on the first open after a cold start.
|
||||
listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })]));
|
||||
} else if (items.length === 0) {
|
||||
const empty = {
|
||||
favorites: "No favourites yet.",
|
||||
archived: "Nothing archived.",
|
||||
@@ -1092,37 +1101,36 @@
|
||||
const sub = behind
|
||||
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
||||
: (b.last_chapter || "—") + " · " + b.site;
|
||||
// Cover and text are one link: the card body *is* the continue button, so
|
||||
// there is no separate one. The action row is a sibling of that link, never
|
||||
// a child — a thumb that misses ★ must not land on a navigation target, and
|
||||
// Remove must never be one.
|
||||
// Cover and title are the only continue links. The subtitle and the action
|
||||
// row live in the same text column but are not navigation targets — a thumb
|
||||
// that misses ★ must not land on one, and Remove must never be one.
|
||||
return el("div", { class: "item" }, [
|
||||
el("a", { class: "go", href: cont }, [
|
||||
b.cover
|
||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||
: el("div", { class: "cover ph" }),
|
||||
el("div", { class: "meta" }, [
|
||||
el("div", { class: "t", text: b.title || b.series_id }),
|
||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||
]),
|
||||
]),
|
||||
el("div", { class: "actions" }, [
|
||||
el("button", {
|
||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
||||
text: b.favorite ? "★" : "☆",
|
||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
||||
onclick: () => toggleFavorite(b.key),
|
||||
}),
|
||||
el("button", {
|
||||
class: "btn small",
|
||||
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
||||
onclick: () => toggleArchive(b.key),
|
||||
}),
|
||||
el("button", {
|
||||
class: "btn small danger",
|
||||
text: "Remove",
|
||||
onclick: () => confirmRemove(b),
|
||||
}),
|
||||
el("div", { class: "meta" }, [
|
||||
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
|
||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||
el("div", { class: "actions" }, [
|
||||
el("button", {
|
||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
||||
text: b.favorite ? "★" : "☆",
|
||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
||||
onclick: () => toggleFavorite(b.key),
|
||||
}),
|
||||
el("button", {
|
||||
class: "btn small",
|
||||
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
||||
onclick: () => toggleArchive(b.key),
|
||||
}),
|
||||
el("button", {
|
||||
class: "btn small danger",
|
||||
text: "Remove",
|
||||
onclick: () => confirmRemove(b),
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -1153,12 +1161,16 @@
|
||||
|
||||
async function refresh() {
|
||||
await drain(); // push what we owe before adopting the server's view of it
|
||||
loading = true;
|
||||
render();
|
||||
try {
|
||||
const list = await apiGet();
|
||||
setList(overlayPending(list));
|
||||
render();
|
||||
} catch (e) {
|
||||
render(); // fall back to cache
|
||||
// Offline or backend down: keep whatever the cache holds.
|
||||
} finally {
|
||||
loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,17 +1391,24 @@
|
||||
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||
.spinner {
|
||||
width: 26px; height: 26px; margin: 0 auto;
|
||||
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.item {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
display: flex; flex-direction: row; gap: 10px;
|
||||
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
||||
}
|
||||
.go { display: flex; gap: 10px; text-decoration: none; color: inherit; }
|
||||
.go:active { opacity: .7; }
|
||||
.go { display: block; flex: none; }
|
||||
.go:active, .go-t:active { opacity: .7; }
|
||||
.go-t { display: block; text-decoration: none; color: inherit; }
|
||||
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
||||
.cover.ph { display: block; }
|
||||
.meta { min-width: 0; flex: 1; }
|
||||
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; }
|
||||
.c { color: #9a9aa5; font-size: 12px; margin: 0; }
|
||||
.c.behind { color: #c4b5fd; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
|
||||
Reference in New Issue
Block a user