6af49e6790
Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.
Design: `docs/superpowers/specs/2026-07-27-status-buckets-design.md`
## Data model
One additive column through the existing `addedColumns` migration list:
```sql
ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'
```
The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite.
Rollback is safe: an old binary against the new database omits `status` from its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.
## The write rule
`PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, because the userscript must be able to archive and restore.
So an empty incoming status means **"no opinion"**, not a value, and resolves on the `VALUES` side of the upsert:
```sql
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')
```
with `DO UPDATE SET status = excluded.status`.
It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in the conflict clause would see `'reading'` rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.
`TestUpsertEmptyStatusPreservesStored` is the guard on this.
`updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, so archiving, finishing, restoring, and favouriting never reorder the list.
## Validation
`PUT /bookmarks/{key}` returns 400 for any status outside `{"", "reading", "archived", "finished"}`, and for `"finished"` specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The `/ui/*` endpoints have their own session-guarded route and are unaffected.
## Visibility
| Surface | All | Updated | Favourites | Archived | Finished |
|---|---|---|---|---|---|
| Web | reading | reading | reading | archived | finished |
| Userscript | reading | — | reading | archived | not shown |
Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".
## Backend
- **`store.go`** — `Bookmark.Status`, the column in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all.
- **`store.go`** — `DueForLatestCheck` gains `AND status IS NOT 'finished'`. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge. `IS NOT` is null-safe, so a hand-edited NULL still qualifies.
- **`handlers.go`** — status validation on `PUT`, before any write.
- **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place.
- **`templates/`** — two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.
- **`static/style.css`** — five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.
## Userscript (1.3.0)
- Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere.
- Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row.
- Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`.
- `apiPut` now omits `status` unless the caller opts in — see below.
Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only.
## One bug worth calling out
The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable:
1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"status":"reading"` and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".
Fixed at the single choke point: `apiPut(key, obj, { sendStatus = false })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the *invalid* values would not have been enough — a stale cached `"reading"` still clobbers a remote archive.
Also: the userscript's `backgroundRefreshLatest` now skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.
## Known limitations, deliberate
Both are marked in-code with `ponytail:` comments naming the ceiling and the upgrade path:
- The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment.
- A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.
## Testing
`go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean.
- **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`.
- **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket *and* applies the chapter progress in the same request.
- **`web_test.go`** — each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not move `updated_at`; the card renders the right controls per bucket.
Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries `"status":"archived"` while a favourite toggle on that same archived series carries no `status` key at all.
Reviewed-on: #4
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
1077 lines
38 KiB
JavaScript
1077 lines
38 KiB
JavaScript
// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @namespace mangabm
|
|
// @version 1.3.0
|
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
|
// @author you
|
|
// @match https://asuracomic.net/*
|
|
// @match https://asurascans.com/*
|
|
// @match https://demonicscans.org/*
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ============================================================
|
|
// CONFIG — fill these in before installing.
|
|
// ============================================================
|
|
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
|
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
|
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
|
|
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
|
const CACHE_KEY = "mangabm:cache";
|
|
|
|
// Per-device record of when each series was last checked for new chapters.
|
|
// Deliberately not synced: each device does its own checking.
|
|
const LASTCHECKED_KEY = "mangabm:lastchecked";
|
|
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
|
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
|
|
|
|
// ============================================================
|
|
// Site adapters
|
|
//
|
|
// Page type + IDs come from URL regex (most stable); title/cover come from
|
|
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
|
|
// demonicscans.org — see README "Adapter reference".
|
|
// ============================================================
|
|
|
|
function meta(prop) {
|
|
const el = document.querySelector('meta[property="' + prop + '"]');
|
|
return el ? el.getAttribute("content") : null;
|
|
}
|
|
|
|
// Chapter lists are read from two places: the page we are standing on, and
|
|
// series pages fetched in the background. Both are reduced to {href, text}
|
|
// pairs so each adapter needs only one rule for picking the latest chapter.
|
|
|
|
function anchorsFromDocument(doc) {
|
|
return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({
|
|
href: a.getAttribute("href") || "",
|
|
text: a.textContent || "",
|
|
}));
|
|
}
|
|
|
|
// A fetched page is raw text; DOMParser would run against markup we do not
|
|
// control, so anchors are pulled out directly instead.
|
|
function anchorsFromHTML(html) {
|
|
const re = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
const out = [];
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const asura = {
|
|
site: "asura",
|
|
// asuracomic.net deep links 301 to the asurascans.com *root*, dropping the
|
|
// path, and that happens at the edge before this script gets a document —
|
|
// so those URLs cannot be handled here at all (checked 2026-07-25). It stays
|
|
// matched in case the redirect starts preserving paths again; until then,
|
|
// reach series through asurascans.com.
|
|
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// /comics/<slug-hash>/chapter/<n>
|
|
let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /comics/<slug-hash>
|
|
m = path.match(/^\/comics\/([^/?#]+)\/?$/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Strips "Chapter N - Read Online" and the " | Asura Scans" suffix.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t
|
|
.replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ")
|
|
.replace(/\s*\|\s*Asura Scans\s*$/i, "")
|
|
.trim();
|
|
}
|
|
},
|
|
// Series pages link every chapter. The link text check skips the
|
|
// "First Chapter" shortcut, which points at a chapter but is not one
|
|
// of the list entries.
|
|
latestChapterFromAnchors(anchors) {
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
const m = a.href.match(/\/chapter\/([\d.]+)$/);
|
|
if (!m || !/Chapter\s+[\d.]+/i.test(a.text)) continue;
|
|
const num = parseFloat(m[1]);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
|
}
|
|
return best;
|
|
},
|
|
};
|
|
|
|
const demonic = {
|
|
site: "demonic",
|
|
matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// Canonical reader: /title/<slug>/chapter/<n>/<page>
|
|
// (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.)
|
|
let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// Series: /manga/<slug>
|
|
m = path.match(/^\/manga\/([^/?#]+)/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Demonic chapter og:title is "<Title> Chapter N"; series is clean.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
|
}
|
|
},
|
|
// Series pages still link chapters through the pre-redirect chaptered.php
|
|
// form. Their order is not dependable, so take the highest number.
|
|
latestChapterFromAnchors(anchors) {
|
|
let best = null;
|
|
for (const a of anchors) {
|
|
const m = a.href.match(/chaptered\.php\?manga=\d+&(?:amp;)?chapter=([\d.]+)/);
|
|
if (!m) continue;
|
|
const num = parseFloat(m[1]);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
|
}
|
|
return best;
|
|
},
|
|
};
|
|
|
|
const ADAPTERS = [asura, demonic];
|
|
|
|
function detect() {
|
|
const loc = window.location;
|
|
for (const a of ADAPTERS) {
|
|
if (a.matches(loc)) return a.detect(loc);
|
|
}
|
|
return { type: "other" };
|
|
}
|
|
|
|
function keyOf(page) {
|
|
return page.site + ":" + page.seriesId;
|
|
}
|
|
|
|
function adapterFor(site) {
|
|
return ADAPTERS.find((a) => a.site === site) || null;
|
|
}
|
|
|
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
|
function computeLatestChapter(site, anchors) {
|
|
const a = adapterFor(site);
|
|
return a ? a.latestChapterFromAnchors(anchors) : null;
|
|
}
|
|
|
|
function currentSite() {
|
|
for (const a of ADAPTERS) if (a.matches(window.location)) return a.site;
|
|
return null;
|
|
}
|
|
|
|
// ============================================================
|
|
// API client + local cache
|
|
// ============================================================
|
|
|
|
function loadCache() {
|
|
try {
|
|
const raw = localStorage.getItem(CACHE_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveCache(list) {
|
|
try {
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify(list));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function loadLastChecked() {
|
|
try {
|
|
const raw = localStorage.getItem(LASTCHECKED_KEY);
|
|
return raw ? JSON.parse(raw) : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveLastChecked(map) {
|
|
try {
|
|
localStorage.setItem(LASTCHECKED_KEY, JSON.stringify(map));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function authHeaders(extra) {
|
|
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
|
}
|
|
|
|
async function apiGet() {
|
|
const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() });
|
|
if (!res.ok) throw new Error("GET /bookmarks " + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
// Only an explicit archive/restore has an opinion about the bucket. Every
|
|
// other write omits `status`, so the server keeps the stored one — otherwise
|
|
// a cached value would resend "finished" (which the API rejects with 400) or
|
|
// silently un-archive a series archived on another device.
|
|
async function apiPut(key, obj, { sendStatus = false } = {}) {
|
|
const body = Object.assign({}, obj);
|
|
if (!sendStatus) delete body.status;
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "PUT",
|
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDelete(key) {
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "DELETE",
|
|
headers: authHeaders(),
|
|
});
|
|
if (!res.ok) throw new Error("DELETE /bookmarks " + res.status);
|
|
}
|
|
|
|
// ============================================================
|
|
// State
|
|
// ============================================================
|
|
|
|
const state = {
|
|
list: loadCache(), // last-known bookmarks (array)
|
|
byKey: {},
|
|
page: { type: "other" },
|
|
};
|
|
reindex();
|
|
|
|
function reindex() {
|
|
state.byKey = {};
|
|
for (const b of state.list) state.byKey[b.key] = b;
|
|
state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0));
|
|
}
|
|
|
|
function setList(list) {
|
|
state.list = Array.isArray(list) ? list : [];
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
|
|
function upsertLocal(bm) {
|
|
const i = state.list.findIndex((b) => b.key === bm.key);
|
|
if (i >= 0) state.list[i] = bm;
|
|
else state.list.push(bm);
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
|
|
function removeLocal(key) {
|
|
state.list = state.list.filter((b) => b.key !== key);
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
|
|
// A list cached by an older version has no status field, and a row the
|
|
// server defaulted has "reading" — both mean the same thing here.
|
|
function statusOf(b) {
|
|
return b.status || "reading";
|
|
}
|
|
|
|
// ============================================================
|
|
// Mutations (optimistic: update UI/cache first, then sync)
|
|
// ============================================================
|
|
|
|
async function bookmarkCurrent() {
|
|
const p = state.page;
|
|
if (p.type !== "series" && p.type !== "chapter") return;
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key];
|
|
const bm = {
|
|
key: key,
|
|
site: p.site,
|
|
series_id: p.seriesId,
|
|
title: p.title || (existing && existing.title) || p.seriesId,
|
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
|
cover: p.cover || (existing && existing.cover) || "",
|
|
last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "",
|
|
last_chapter_num:
|
|
p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null,
|
|
last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "",
|
|
updated_at: Date.now(),
|
|
};
|
|
await syncUpsert(bm, "Bookmarked");
|
|
}
|
|
|
|
async function updateToCurrentChapter() {
|
|
cancelDwell(); // manual button + timer-fire both clear any pending dwell
|
|
const p = state.page;
|
|
if (p.type !== "chapter") return;
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key] || {};
|
|
const bm = Object.assign({}, existing, {
|
|
key: key,
|
|
site: p.site,
|
|
series_id: p.seriesId,
|
|
title: existing.title || p.title || p.seriesId,
|
|
series_url: existing.series_url || p.seriesUrl || "",
|
|
cover: existing.cover || p.cover || "",
|
|
last_chapter: p.chapterLabel || "",
|
|
last_chapter_num: p.chapterNum,
|
|
last_chapter_url: p.chapterUrl || "",
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress updated");
|
|
}
|
|
|
|
// Manual override from the panel input — forces any value.
|
|
async function setChapterManual(key, label, num, url) {
|
|
const existing = state.byKey[key];
|
|
if (!existing) return;
|
|
const bm = Object.assign({}, existing, {
|
|
last_chapter: label,
|
|
last_chapter_num: isNaN(num) ? existing.last_chapter_num : num,
|
|
last_chapter_url: url || existing.last_chapter_url,
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress set");
|
|
}
|
|
|
|
async function syncUpsert(bm, okMsg) {
|
|
upsertLocal(bm); // optimistic
|
|
render();
|
|
try {
|
|
const saved = await apiPut(bm.key, bm);
|
|
upsertLocal(saved); // adopt server updated_at
|
|
render();
|
|
toast(okMsg);
|
|
} catch (e) {
|
|
toast("Offline — saved locally, will retry", true);
|
|
}
|
|
}
|
|
|
|
async function toggleFavorite(key) {
|
|
const existing = state.byKey[key];
|
|
if (!existing) return;
|
|
const bm = Object.assign({}, existing, {
|
|
favorite: !existing.favorite,
|
|
updated_at: Date.now(), // candidate only; the server keeps the stored one
|
|
});
|
|
upsertLocal(bm); // optimistic
|
|
render();
|
|
try {
|
|
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
|
|
// polling it for new chapters. "finished" is deliberately not reachable from
|
|
// here — the API rejects that value, it is a web-UI decision.
|
|
async function toggleArchive(key) {
|
|
const existing = state.byKey[key];
|
|
if (!existing) return;
|
|
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
|
const bm = Object.assign({}, existing, { status: next });
|
|
upsertLocal(bm);
|
|
render();
|
|
try {
|
|
const saved = await apiPut(bm.key, bm, { sendStatus: true });
|
|
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
|
render();
|
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
|
} catch (e) {
|
|
toast("Archive failed — retry when online", true);
|
|
}
|
|
}
|
|
|
|
// Records the newest chapter a site has published. Silent: this fires from
|
|
// page visits and background checks the user did not ask for, and it never
|
|
// reorders the list — updated_at is a candidate the server discards unless
|
|
// reading progress moved.
|
|
async function applyLatestChapterIfChanged(existing, latest) {
|
|
if (!existing || !latest) return;
|
|
if (existing.latest_chapter_num === latest.num) return;
|
|
const bm = Object.assign({}, existing, {
|
|
latest_chapter: latest.label,
|
|
latest_chapter_num: latest.num,
|
|
updated_at: Date.now(),
|
|
});
|
|
upsertLocal(bm);
|
|
render();
|
|
try {
|
|
const saved = await apiPut(bm.key, bm);
|
|
upsertLocal(saved);
|
|
render();
|
|
} catch (e) {
|
|
/* offline — the local cache still shows it, retried on a later visit */
|
|
}
|
|
}
|
|
|
|
// Standing on a bookmarked series page: its chapter list is already here.
|
|
function maybeCaptureLatestOnSeriesPage() {
|
|
const p = state.page;
|
|
if (p.type !== "series") return;
|
|
const existing = state.byKey[keyOf(p)];
|
|
if (!existing) return;
|
|
applyLatestChapterIfChanged(
|
|
existing,
|
|
computeLatestChapter(p.site, anchorsFromDocument(document))
|
|
);
|
|
}
|
|
|
|
// Everything else is only learned by fetching a series page. Same-origin
|
|
// only: these requests carry the session that gets us past the site's bot
|
|
// checks, which a request to the other site (or from a server) would not.
|
|
// One series per navigation keeps it indistinguishable from browsing.
|
|
async function backgroundRefreshLatest() {
|
|
const site = currentSite();
|
|
if (!site) return;
|
|
|
|
const checked = loadLastChecked();
|
|
const now = Date.now();
|
|
const due = state.list
|
|
.filter((b) => b.site === site && b.series_url)
|
|
.filter((b) => statusOf(b) !== "finished")
|
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
|
.slice(0, LATEST_CHECK_BATCH);
|
|
if (due.length === 0) return;
|
|
|
|
for (const bm of due) {
|
|
// Recorded even when the fetch fails, so a broken series is retried on
|
|
// the next throttle window rather than on every single page load.
|
|
checked[bm.key] = Date.now();
|
|
try {
|
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
|
if (!res.ok) continue;
|
|
const html = await res.text();
|
|
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html));
|
|
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
|
} catch (e) {
|
|
/* offline or blocked — try again after the throttle window */
|
|
}
|
|
}
|
|
saveLastChecked(checked);
|
|
}
|
|
|
|
async function removeBookmark(key) {
|
|
removeLocal(key); // optimistic
|
|
render();
|
|
try {
|
|
await apiDelete(key);
|
|
toast("Removed");
|
|
} catch (e) {
|
|
toast("Offline — remove will retry", true);
|
|
}
|
|
}
|
|
|
|
// Dwell timer: don't advance progress the instant a newer chapter opens (guards
|
|
// against a misclick on "latest chapter"). Arm a 25s timer instead; the countdown
|
|
// ring around the FAB shows it running. The manual "Update to X" button still fires
|
|
// immediately. Timer is keyed to the chapter, not the URL, so turning pages within
|
|
// the same chapter (Demonic /chapter/N/<page>) keeps it counting rather than resetting.
|
|
const DWELL_MS = 25000;
|
|
let dwell = null; // { id, timerId }
|
|
|
|
function chapterIdentity(p) {
|
|
return p && p.type === "chapter" ? p.site + ":" + p.seriesId + "@" + p.chapterNum : null;
|
|
}
|
|
|
|
function armDwell(id) {
|
|
dwell = {
|
|
id,
|
|
timerId: setTimeout(() => {
|
|
dwell = null;
|
|
stopRing();
|
|
updateToCurrentChapter();
|
|
}, DWELL_MS),
|
|
};
|
|
startRing(DWELL_MS);
|
|
}
|
|
|
|
function cancelDwell() {
|
|
if (dwell) clearTimeout(dwell.timerId);
|
|
dwell = null;
|
|
stopRing();
|
|
}
|
|
|
|
// Auto-record on opening a chapter of a bookmarked series (after the dwell delay).
|
|
function maybeAutoUpdate() {
|
|
const p = state.page;
|
|
const id = chapterIdentity(p);
|
|
if (dwell && id === dwell.id) return; // same chapter, page turn — keep timer running
|
|
cancelDwell(); // navigated elsewhere — drop any stale dwell
|
|
if (p.type !== "chapter") return;
|
|
const existing = state.byKey[keyOf(p)];
|
|
if (!existing) return; // only track series the user bookmarked
|
|
|
|
const cur = p.chapterNum;
|
|
const prev = existing.last_chapter_num;
|
|
// Advance when current >= stored, or when either is unparseable (set current).
|
|
const advance = cur == null || prev == null || cur >= prev;
|
|
if (!advance) return;
|
|
if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) {
|
|
return; // already recorded
|
|
}
|
|
armDwell(id);
|
|
}
|
|
|
|
// ============================================================
|
|
// UI — Shadow DOM (isolated from site CSS)
|
|
// ============================================================
|
|
|
|
let root, panelOpen = false;
|
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
|
|
|
function buildUI() {
|
|
const host = document.createElement("div");
|
|
host.id = "mangabm-host";
|
|
host.style.cssText = "all: initial;";
|
|
(document.body || document.documentElement).appendChild(host);
|
|
root = host.attachShadow({ mode: "open" });
|
|
|
|
const style = document.createElement("style");
|
|
style.textContent = CSS;
|
|
root.appendChild(style);
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.id = "wrap";
|
|
wrap.innerHTML = TEMPLATE;
|
|
root.appendChild(wrap);
|
|
|
|
const fab = root.getElementById("fab");
|
|
fab.addEventListener("click", () => {
|
|
if (fab.dataset.dragged) {
|
|
delete fab.dataset.dragged; // this click is the tail of a drag — swallow it
|
|
return;
|
|
}
|
|
togglePanel();
|
|
});
|
|
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
|
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
|
|
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
|
root.getElementById(id).addEventListener("click", () => {
|
|
activeTab = tab;
|
|
render();
|
|
});
|
|
}
|
|
|
|
placeFab(fab);
|
|
makeDraggable(fab);
|
|
window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation
|
|
}
|
|
|
|
// ============================================================
|
|
// FAB placement + dragging (snaps to nearest left/right edge)
|
|
// ============================================================
|
|
|
|
const FAB_KEY = "mangabm:fabpos";
|
|
const FAB_MARGIN = 12;
|
|
|
|
function clamp(v, lo, hi) {
|
|
return Math.max(lo, Math.min(hi, v));
|
|
}
|
|
|
|
function applyFabPos(fab, side, y) {
|
|
fab.style.top = y + "px";
|
|
fab.style.bottom = "auto";
|
|
if (side === "left") {
|
|
fab.style.left = FAB_MARGIN + "px";
|
|
fab.style.right = "auto";
|
|
} else {
|
|
fab.style.right = FAB_MARGIN + "px";
|
|
fab.style.left = "auto";
|
|
}
|
|
}
|
|
|
|
function loadFabPos() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(FAB_KEY));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveFabPos(side, yRatio) {
|
|
try {
|
|
localStorage.setItem(FAB_KEY, JSON.stringify({ side, yRatio }));
|
|
} catch (e) {
|
|
/* quota / private mode — ignore */
|
|
}
|
|
}
|
|
|
|
function placeFab(fab) {
|
|
const s = fab.offsetWidth || 52;
|
|
const p = loadFabPos();
|
|
const maxY = window.innerHeight - s - FAB_MARGIN;
|
|
const side = p && p.side === "left" ? "left" : "right";
|
|
const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8;
|
|
applyFabPos(fab, side, y);
|
|
}
|
|
|
|
function makeDraggable(fab) {
|
|
let down = false, moved = false, sx = 0, sy = 0, offX = 0, offY = 0;
|
|
fab.addEventListener("pointerdown", (e) => {
|
|
down = true;
|
|
moved = false;
|
|
sx = e.clientX;
|
|
sy = e.clientY;
|
|
const r = fab.getBoundingClientRect();
|
|
offX = e.clientX - r.left;
|
|
offY = e.clientY - r.top;
|
|
fab.setPointerCapture(e.pointerId);
|
|
fab.style.transition = "none";
|
|
});
|
|
fab.addEventListener("pointermove", (e) => {
|
|
if (!down) return;
|
|
if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true;
|
|
if (!moved) return;
|
|
e.preventDefault();
|
|
const s = fab.offsetWidth;
|
|
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px";
|
|
fab.style.right = "auto";
|
|
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - s) + "px";
|
|
fab.style.bottom = "auto";
|
|
});
|
|
fab.addEventListener("pointerup", (e) => {
|
|
if (!down) return;
|
|
down = false;
|
|
fab.style.transition = "";
|
|
if (!moved) return; // tap — let the click handler toggle the panel
|
|
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
|
const s = fab.offsetWidth;
|
|
const r = fab.getBoundingClientRect();
|
|
const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right";
|
|
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN);
|
|
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
|
applyFabPos(fab, side, y);
|
|
saveFabPos(side, y / window.innerHeight);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Countdown ring around the FAB (dwell-timer progress)
|
|
// ============================================================
|
|
|
|
const RING_C = 144.5; // circumference: 2π·23
|
|
|
|
function startRing(ms) {
|
|
if (!root) return;
|
|
const ring = root.getElementById("ring");
|
|
const prog = ring.firstElementChild;
|
|
ring.classList.add("active");
|
|
prog.style.transition = "none";
|
|
prog.style.strokeDashoffset = RING_C; // empty
|
|
prog.getBoundingClientRect(); // reflow so the next assignment animates
|
|
prog.style.transition = "stroke-dashoffset " + ms + "ms linear";
|
|
prog.style.strokeDashoffset = "0"; // fills over ms
|
|
}
|
|
|
|
function stopRing() {
|
|
if (!root) return;
|
|
const ring = root.getElementById("ring");
|
|
if (!ring) return;
|
|
const prog = ring.firstElementChild;
|
|
ring.classList.remove("active");
|
|
prog.style.transition = "none";
|
|
prog.style.strokeDashoffset = RING_C;
|
|
}
|
|
|
|
function togglePanel() {
|
|
panelOpen = !panelOpen;
|
|
render();
|
|
if (panelOpen) refresh(); // pull latest from server on open
|
|
}
|
|
|
|
function el(tag, props, children) {
|
|
const e = document.createElement(tag);
|
|
if (props) for (const k in props) {
|
|
if (k === "text") e.textContent = props[k];
|
|
else if (k === "html") e.innerHTML = props[k];
|
|
else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
else e.setAttribute(k, props[k]);
|
|
}
|
|
if (children) for (const c of children) if (c) e.appendChild(c);
|
|
return e;
|
|
}
|
|
|
|
function render() {
|
|
if (!root) return;
|
|
root.getElementById("panel").classList.toggle("open", panelOpen);
|
|
root.getElementById("backdrop").classList.toggle("open", panelOpen);
|
|
|
|
// Context header for the current page.
|
|
const ctx = root.getElementById("context");
|
|
ctx.innerHTML = "";
|
|
const p = state.page;
|
|
if (p.type === "series" || p.type === "chapter") {
|
|
const key = keyOf(p);
|
|
const existing = state.byKey[key];
|
|
ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId }));
|
|
if (!existing) {
|
|
ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() }));
|
|
} else {
|
|
const cur = existing.last_chapter || "—";
|
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur }));
|
|
if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) {
|
|
ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() }));
|
|
}
|
|
}
|
|
} else {
|
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
|
}
|
|
|
|
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
|
// Archived rows are hidden from All and Favourites, and finished ones —
|
|
// which only the web UI can set — are hidden from every tab.
|
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
|
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
|
}
|
|
|
|
const listEl = root.getElementById("list");
|
|
listEl.innerHTML = "";
|
|
const items = state.list.filter((b) => {
|
|
const s = statusOf(b);
|
|
if (activeTab === "archived") return s === "archived";
|
|
if (s !== "reading") return false;
|
|
return activeTab !== "favorites" || b.favorite;
|
|
});
|
|
if (items.length === 0) {
|
|
const empty = {
|
|
favorites: "No favourites yet.",
|
|
archived: "Nothing archived.",
|
|
all: "No bookmarks yet.",
|
|
}[activeTab];
|
|
listEl.appendChild(el("div", { class: "empty", text: empty }));
|
|
}
|
|
for (const b of items) {
|
|
listEl.appendChild(renderItem(b));
|
|
}
|
|
}
|
|
|
|
function renderItem(b) {
|
|
const cont = b.last_chapter_url || b.series_url || "#";
|
|
// Only worth naming the latest chapter while it is ahead of the read one.
|
|
const behind =
|
|
b.latest_chapter &&
|
|
b.latest_chapter_num != null &&
|
|
(b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num);
|
|
const sub = behind
|
|
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
|
: (b.last_chapter || "—") + " · " + b.site;
|
|
const row = el("div", { class: "item" }, [
|
|
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("a", { class: "btn small", href: cont, text: "Continue" }),
|
|
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
|
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
|
]),
|
|
]),
|
|
]);
|
|
return row;
|
|
}
|
|
|
|
function editChapter(b) {
|
|
const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || "");
|
|
if (raw == null) return;
|
|
const label = raw.trim();
|
|
const num = parseFloat((label.match(/[\d.]+/) || [])[0]);
|
|
setChapterManual(b.key, label, num, b.last_chapter_url);
|
|
}
|
|
|
|
// Toast inside the shadow root.
|
|
function toast(msg, isErr) {
|
|
if (!root) return;
|
|
const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg });
|
|
root.getElementById("toasts").appendChild(t);
|
|
setTimeout(() => t.classList.add("show"), 10);
|
|
setTimeout(() => {
|
|
t.classList.remove("show");
|
|
setTimeout(() => t.remove(), 300);
|
|
}, 2600);
|
|
}
|
|
|
|
// ============================================================
|
|
// Refresh + navigation
|
|
// ============================================================
|
|
|
|
async function refresh() {
|
|
try {
|
|
const list = await apiGet();
|
|
setList(list);
|
|
render();
|
|
} catch (e) {
|
|
render(); // fall back to cache
|
|
}
|
|
}
|
|
|
|
let lastUrl = location.href;
|
|
function onNavigate() {
|
|
state.page = detect();
|
|
render();
|
|
maybeAutoUpdate();
|
|
maybeCaptureLatestOnSeriesPage();
|
|
// Asura is client-routed, so full loads are rare — checking here too is
|
|
// what keeps its bookmarks current. The throttle still caps the rate.
|
|
backgroundRefreshLatest();
|
|
}
|
|
|
|
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
|
// so it works whether the site is client-routed (Asura) or reloads (Demonic).
|
|
function installNavWatcher() {
|
|
const fire = () => {
|
|
if (location.href !== lastUrl) {
|
|
lastUrl = location.href;
|
|
// Meta tags update slightly after the URL on client routes.
|
|
setTimeout(onNavigate, 300);
|
|
}
|
|
};
|
|
const wrap = (name) => {
|
|
const orig = history[name];
|
|
history[name] = function () {
|
|
const r = orig.apply(this, arguments);
|
|
fire();
|
|
return r;
|
|
};
|
|
};
|
|
wrap("pushState");
|
|
wrap("replaceState");
|
|
window.addEventListener("popstate", fire);
|
|
setInterval(fire, 1500); // catch routes that bypass history
|
|
}
|
|
|
|
// ============================================================
|
|
// Boot
|
|
// ============================================================
|
|
|
|
function init() {
|
|
buildUI();
|
|
state.page = detect();
|
|
render();
|
|
installNavWatcher();
|
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
|
// know which series are bookmarked and how fresh they are.
|
|
refresh().then(() => {
|
|
maybeAutoUpdate();
|
|
maybeCaptureLatestOnSeriesPage();
|
|
backgroundRefreshLatest();
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Markup + styles (kept at bottom for readability)
|
|
// ============================================================
|
|
|
|
const TEMPLATE = `
|
|
<button id="fab" aria-label="Manga bookmarks">
|
|
<svg id="ring" viewBox="0 0 52 52" aria-hidden="true"><circle class="prog" cx="26" cy="26" r="23"></circle></svg>
|
|
<span class="fab-icon">📑</span>
|
|
</button>
|
|
<div id="backdrop"></div>
|
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
|
<header>
|
|
<span>Manga Bookmarks</span>
|
|
<button id="closeBtn" aria-label="Close">✕</button>
|
|
</header>
|
|
<div id="nav">
|
|
<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://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
|
</div>
|
|
<section id="context"></section>
|
|
<div id="tabs" role="tablist">
|
|
<button id="tabAll" class="tab active" role="tab">All</button>
|
|
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
|
<button id="tabArc" class="tab" role="tab">Archived</button>
|
|
</div>
|
|
<div id="list"></div>
|
|
</aside>
|
|
<div id="toasts"></div>
|
|
`;
|
|
|
|
const CSS = `
|
|
:host { all: initial; }
|
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
|
#fab {
|
|
position: fixed;
|
|
width: 52px; height: 52px; border-radius: 50%; border: none;
|
|
background: #6d28d9; color: #fff; font-size: 22px;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
|
touch-action: none; user-select: none;
|
|
display: flex; align-items: center; justify-content: center; padding: 0;
|
|
}
|
|
#fab:active { transform: scale(.94); }
|
|
#ring {
|
|
position: absolute; inset: 0; transform: rotate(-90deg);
|
|
opacity: 0; transition: opacity .15s; pointer-events: none;
|
|
}
|
|
#ring.active { opacity: 1; }
|
|
#ring .prog {
|
|
fill: none; stroke: #c4b5fd; stroke-width: 3; stroke-linecap: round;
|
|
stroke-dasharray: 144.5; stroke-dashoffset: 144.5;
|
|
}
|
|
.fab-icon { position: relative; z-index: 1; line-height: 1; }
|
|
#backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
|
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646;
|
|
}
|
|
#backdrop.open { opacity: 1; pointer-events: auto; }
|
|
#panel {
|
|
position: fixed; top: 0; right: 0; height: 100%;
|
|
width: min(400px, 92vw); background: #1b1b22; color: #eee;
|
|
transform: translateX(105%); transition: transform .25s ease;
|
|
z-index: 2147483647; display: flex; flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
box-shadow: -4px 0 20px rgba(0,0,0,.5);
|
|
}
|
|
#panel.open { transform: translateX(0); }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
|
}
|
|
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
|
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
|
.chip {
|
|
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
|
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.chip:active { opacity: .8; }
|
|
#context {
|
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
.ctx-title { font-weight: 600; font-size: 15px; }
|
|
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
|
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
|
|
.tab {
|
|
background: none; border: none; color: #9a9aa5; cursor: pointer;
|
|
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
}
|
|
.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; }
|
|
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
|
|
.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; }
|
|
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
|
.c.behind { color: #c4b5fd; }
|
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.btn {
|
|
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
|
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
|
|
}
|
|
.btn:active { opacity: .8; }
|
|
.btn.primary { background: #6d28d9; color: #fff; }
|
|
.btn.small { padding: 6px 10px; font-size: 12px; }
|
|
.btn.danger { background: #7f1d1d; }
|
|
.btn.star { line-height: 1; }
|
|
.btn.star.on { background: #6d28d9; color: #fff; }
|
|
#toasts {
|
|
position: fixed; left: 50%; transform: translateX(-50%);
|
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center;
|
|
}
|
|
.toast {
|
|
background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
|
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.err { background: #7f1d1d; }
|
|
`;
|
|
|
|
// ============================================================
|
|
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
|
|
// which are const (TDZ) and only initialized above. At
|
|
// @run-at document-idle document.body already exists, so init()
|
|
// fires synchronously here, after the consts are set.
|
|
// ============================================================
|
|
if (document.body) init();
|
|
else document.addEventListener("DOMContentLoaded", init);
|
|
})();
|