// ==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 = /]*\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//chapter/ 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/ 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//chapter// // (chaptered.php?manga=&chapter= 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/ 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 " 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 || {}); } // 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() { 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 httpError("PUT /bookmarks", res.status); return res.json(); } async function apiDelete(key) { const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { method: "DELETE", headers: authHeaders(), }); // 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); } // ============================================================ // 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"; } // ============================================================ // 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); } } // 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 = 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 if (status === 401) { authFailed = true; } else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) { queueDrop(key); toast("Gave up syncing " + titleFor(key), true); return; } saveQueue(queue); } // ============================================================ // 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; } 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. upsertLocal(await apiPut(key, bm, { sendStatus: withStatus })); queueDrop(key); render(); return true; } catch (e) { queueEnqueue(key, "put", withStatus, e); return false; } } async function pushDelete(key) { try { await apiDelete(key); queueDrop(key); return true; } catch (e) { queueEnqueue(key, "delete", false, e); return false; } } 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(); if (await pushBookmark(bm.key, false)) toast(okMsg); else toast("Saved — will sync when online", 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(); if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", 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"; upsertLocal(Object.assign({}, existing, { status: next })); render(); // The only write with an opinion about the bucket, so the only one that // sends `status` at all. if (await pushBookmark(key, true)) { toast(next === "archived" ? "Archived" : "Back in your list"); } else { toast((next === "archived" ? "Archived" : "Restored") + " — will sync 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(); if (await pushDelete(key)) toast("Removed"); else toast("Removed — will sync when online", 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); })();