1070 lines
38 KiB
JavaScript
1070 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();
|
|
}
|
|
|
|
async function apiPut(key, obj) {
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "PUT",
|
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify(obj),
|
|
});
|
|
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);
|
|
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) => 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);
|
|
})();
|