e72765df85
comix.to is an SPA whose client router rewrites document.title but never
touches the server-rendered og:title. The adapter read og:title, so a
bookmark taken after a cold load got the homepage's title ("Comix - Read
Comics online for free") and one taken after an in-page hop got the
previous series' title. Titles now come from document.title, with the
chapter page's " - Ch.<n>" tail stripped.
comix also serves no og:image at all, which is why every comix bookmark
fell back to the monogram placeholder. The cover is now the img whose alt
matches the cleaned title.
Both fixes need the page to have finished its client-side route change,
and comix fills document.title a beat after the URL changes - later than
the nav watcher's 300ms snapshot. The watcher therefore also re-detects
when the detect() signature changes, not only when the URL does.
kagane reader URLs carry no chapter number, so it comes out of og:title.
Volume-numbered series render "<Series> - Volume <v> Chapter <n>" with no
episode name, a shape the suffix regex did not match. One unmatched title
caused both reported symptoms: the volume tail stayed in the stored title
("SP Baby - Volume 1 Chapter 1"), and chapterNum came back null so no
chapter was ever recorded for the series. The regex now takes an optional
"Volume <v> " segment.
All three page shapes were captured live on 2026-08-08 and are pinned as
regression tests in userscript/test/logic.test.js.
1854 lines
70 KiB
JavaScript
1854 lines
70 KiB
JavaScript
// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @namespace mangabm
|
|
// @version 1.6.0
|
|
// @description Track read progress on Asura, Demonic, Comix & Kagane and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
|
// @author you
|
|
// @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js
|
|
// @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/manga-bookmark.user.js
|
|
// @match https://asuracomic.net/*
|
|
// @match https://asurascans.com/*
|
|
// @match https://demonicscans.org/*
|
|
// @match https://comix.to/*
|
|
// @match https://kagane.to/*
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ============================================================
|
|
// CONFIG — fill these in before installing.
|
|
// ============================================================
|
|
const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
|
const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24)
|
|
const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
|
|
|
// This script owns the manga library; the novel script is a separate install
|
|
// with its own prefix, so the two never share a cache, a queue or a panel.
|
|
const STORE_PREFIX = "bmgr:manga:";
|
|
|
|
// Which library this script's rows belong to. The novel script is a separate
|
|
// install that declares "novel"; the backend keeps whichever it is told.
|
|
const LIBRARY = "manga";
|
|
|
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
|
const CACHE_KEY = STORE_PREFIX + "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 = STORE_PREFIX + "lastchecked";
|
|
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
|
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
|
|
|
|
// One-time carry-over from the pre-rebrand key names. The cache would rebuild
|
|
// itself from the server, but the retry queue would not: dropping it loses
|
|
// writes made while offline.
|
|
for (const name of ["cache", "lastchecked", "queue", "fabpos"]) {
|
|
const old = localStorage.getItem("mangabm:" + name);
|
|
if (old !== null && localStorage.getItem(STORE_PREFIX + name) === null) {
|
|
localStorage.setItem(STORE_PREFIX + name, old);
|
|
}
|
|
localStorage.removeItem("mangabm:" + name);
|
|
}
|
|
|
|
// ============================================================
|
|
// 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;
|
|
}
|
|
|
|
// Asura appends a site-wide build hash to every slug
|
|
// ("...-059befe1"); it rotates on each redeploy, so it must not be part
|
|
// of the series identity — old keys can never match pages after a
|
|
// redeploy. Only the ID strips it; seriesUrl keeps the full slug because
|
|
// navigation needs the current hash (stale ones 302). Must stay in sync
|
|
// with asuraBuildHash in backend/store.go.
|
|
// ponytail: a real title ending in exactly 8 hex chars would lose its
|
|
// last segment; none known.
|
|
function stripBuildHash(slug) {
|
|
return slug.replace(/-[0-9a-f]{8}$/, "");
|
|
}
|
|
|
|
// comix path segments are "<id>-<slug>", where the slug is a rendering of the
|
|
// current title and changes when a series is renamed. Only the id is the
|
|
// identity; seriesUrl keeps the full segment because navigation needs it.
|
|
function comixSeriesId(segment) {
|
|
const i = segment.indexOf("-");
|
|
return i === -1 ? segment : segment.slice(0, i);
|
|
}
|
|
|
|
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: stripBuildHash(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: stripBuildHash(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 comix = {
|
|
site: "comix",
|
|
matches: (loc) => /(^|\.)comix\.to$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// comix client-routes without ever rewriting og:title — the head keeps
|
|
// whatever the first server-rendered document carried, so a bookmark
|
|
// taken after a client route got the homepage's title, then the
|
|
// previous series'. document.title is the one thing its router does
|
|
// update. Verified live 2026-08-08; do not "restore" meta("og:title").
|
|
const pageTitle = cleanTitle(document.title);
|
|
// /title/<id>-<slug>/<uploadId>-chapter-<n>. Several uploads (different
|
|
// groups or languages) share one chapter number; the number is the
|
|
// progress identity, the upload id is not.
|
|
let m = path.match(/^\/title\/([^/]+)\/[^/]*-chapter-([\d.]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: comixSeriesId(m[1]),
|
|
title: pageTitle,
|
|
cover: coverFromPage(pageTitle),
|
|
seriesUrl: loc.origin + "/title/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /title/<id>-<slug>
|
|
m = path.match(/^\/title\/([^/?#]+)\/?$/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: comixSeriesId(m[1]),
|
|
title: pageTitle,
|
|
cover: coverFromPage(pageTitle),
|
|
seriesUrl: loc.origin + "/title/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// comix chapter document.title is "<Title> · Ch.<n>"; series is clean.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t.replace(/\s*·\s*Ch\.[\d.]+\s*$/i, "").trim();
|
|
}
|
|
|
|
// comix serves no og:image, so this is the one adapter that has to read
|
|
// the DOM for a cover. Matching on alt rather than a class keeps it off
|
|
// the site's styling: the cover is the image whose alt is the title.
|
|
// Do not "simplify" this into meta("og:image") — that returns null.
|
|
function coverFromPage(title) {
|
|
if (!title || !document.querySelectorAll) return "";
|
|
for (const img of document.querySelectorAll("img[alt]")) {
|
|
if (img.getAttribute("alt") === title) return img.getAttribute("src") || "";
|
|
}
|
|
return "";
|
|
}
|
|
},
|
|
// Scoped to this series' own id prefix so a recommendation strip's links
|
|
// cannot win the maximum. seriesId is passed in because the anchors alone
|
|
// do not say which series the page belongs to.
|
|
latestChapterFromAnchors(anchors, seriesId) {
|
|
let best = null;
|
|
const re = new RegExp("^/title/" + seriesId + "-[^/]*/[^/]*-chapter-([\\d.]+)");
|
|
for (const a of anchors) {
|
|
const m = a.href.match(re);
|
|
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;
|
|
},
|
|
};
|
|
|
|
// Kagane builds the reader og:title suffix out of the book's metadata, so
|
|
// every combination occurs: the volume part appears only when the book has a
|
|
// volume_no, the episode part only when it has a non-empty title. All four
|
|
// shapes captured live 2026-08-08 — "SP Baby - Volume 1 Chapter 1" is the one
|
|
// the old trailing-space regex missed, which left both the number and the
|
|
// series title wrong.
|
|
const KAGANE_CHAPTER_SUFFIX = /\s-\s(?:Volume\s[\d.]+\s)?Chapter\s([\d.]+)(?:\s-\s.*)?$/i;
|
|
|
|
const kagane = {
|
|
site: "kagane",
|
|
matches: (loc) => /(^|\.)kagane\.to$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
// /series/<uuid>/reader/<bookUuid> — no chapter number anywhere in the
|
|
// URL, so it comes out of og:title instead.
|
|
let m = path.match(new RegExp("^/series/(" + UUID + ")/reader/" + UUID));
|
|
if (m) {
|
|
const num = chapterNumFromTitle(meta("og:title"));
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/series/" + m[1],
|
|
chapterLabel: num === null ? null : "Chapter " + num,
|
|
chapterNum: num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /series/<uuid>
|
|
m = path.match(new RegExp("^/series/(" + UUID + ")/?$"));
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/series/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
function chapterNumFromTitle(t) {
|
|
const m = t && t.match(KAGANE_CHAPTER_SUFFIX);
|
|
if (!m) return null;
|
|
const num = parseFloat(m[1]);
|
|
return isNaN(num) ? null : num;
|
|
}
|
|
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t.replace(KAGANE_CHAPTER_SUFFIX, "").trim();
|
|
}
|
|
},
|
|
// Reader hrefs are uuids with no number in them, so no maximum can be taken
|
|
// from anchors at all. latestChapterFromApi replaces this path entirely.
|
|
latestChapterFromAnchors() {
|
|
return null;
|
|
},
|
|
// Same-origin only: the request needs the Cloudflare clearance cookie that
|
|
// this browser already holds for kagane.to. Called cross-origin it would be
|
|
// challenged and return nothing.
|
|
async latestChapterFromApi(seriesId) {
|
|
try {
|
|
const res = await fetch("/api/v2/series/" + seriesId);
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
let best = null;
|
|
for (const b of (data && data.series_books) || []) {
|
|
const num = parseFloat(b.chapter_no);
|
|
if (isNaN(num)) continue;
|
|
if (!best || num > best.num) best = { num, label: "Chapter " + b.chapter_no };
|
|
}
|
|
return best;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
|
|
const ADAPTERS = [asura, demonic, comix, kagane];
|
|
|
|
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.
|
|
// seriesId is only consulted by adapters whose pages carry other series'
|
|
// chapter links; the rest ignore it.
|
|
function computeLatestChapter(site, anchors, seriesId) {
|
|
const a = adapterFor(site);
|
|
return a ? a.latestChapterFromAnchors(anchors, seriesId) : 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) {
|
|
// GET /bookmarks answers with every library; this panel owns one of them,
|
|
// and the cache must not hold rows it can never show.
|
|
state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY);
|
|
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";
|
|
}
|
|
|
|
// A list cached by an older version has no kind field, and a row the server
|
|
// defaulted has "manga" — both mean the same thing here.
|
|
function kindOf(b) {
|
|
return b.kind || "manga";
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 under CACHE_KEY. 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 = STORE_PREFIX + "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();
|
|
|
|
// Keys cached (or parked in the retry queue) by versions before the
|
|
// Asura build-hash strip still carry the hash. Align them with the
|
|
// server-side migration so a queued PUT cannot resurrect an orphaned
|
|
// row and the cache matches what the server now returns. Duplicate
|
|
// cache entries collapsing onto one key are left as-is — the next
|
|
// refresh() replaces the whole list from the already-merged server.
|
|
(function migrateLocalKeys() {
|
|
let dirty = false;
|
|
for (let i = 0; i < state.list.length; i++) {
|
|
const b = state.list[i];
|
|
if (b.site !== "asura" || !b.series_id) continue;
|
|
const id = stripBuildHash(b.series_id);
|
|
if (id === b.series_id) continue;
|
|
state.list[i] = Object.assign({}, b, { series_id: id, key: "asura:" + id });
|
|
dirty = true;
|
|
}
|
|
if (dirty) {
|
|
reindex();
|
|
saveCache(state.list);
|
|
}
|
|
dirty = false;
|
|
for (const e of queue) {
|
|
if (!e.key.startsWith("asura:")) continue;
|
|
const k = "asura:" + stripBuildHash(e.key.slice("asura:".length));
|
|
if (k !== e.key) {
|
|
e.key = k;
|
|
dirty = true;
|
|
}
|
|
}
|
|
if (dirty) saveQueue(queue);
|
|
})();
|
|
|
|
function queueCount() {
|
|
return queue.length;
|
|
}
|
|
|
|
function queueGet(key) {
|
|
return queue.find((e) => e.key === key) || null;
|
|
}
|
|
|
|
function titleFor(key) {
|
|
const b = state.byKey[key];
|
|
return (b && (b.title || b.series_id)) || key;
|
|
}
|
|
|
|
// Oldest goes first when the cap is hit: the newest entry is the action the
|
|
// user just took, and dropping that is the bug this queue exists to fix.
|
|
function queuePush(entry) {
|
|
if (queue.length >= QUEUE_MAX) {
|
|
const evicted = queue.shift();
|
|
toast("Sync queue full — dropped " + titleFor(evicted.key), true);
|
|
}
|
|
queue.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
function queueDrop(key) {
|
|
const i = queue.findIndex((e) => e.key === key);
|
|
if (i >= 0) {
|
|
queue.splice(i, 1);
|
|
saveQueue(queue);
|
|
}
|
|
}
|
|
|
|
// Parks a write without counting it as a failure. Used when the key is already
|
|
// on the wire: the write is owed, but nothing went wrong, so it must not spend
|
|
// one of the ten attempts.
|
|
function queueDefer(key, op, sendStatus) {
|
|
const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 });
|
|
e.op = op; // a delete replaces a put, and a put replaces a delete
|
|
e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped
|
|
saveQueue(queue);
|
|
return e;
|
|
}
|
|
|
|
// Records a failed write and classifies why it failed. A 400 is a payload the
|
|
// server will never accept, so it is dropped now instead of being retried ten
|
|
// times; a 401 is the wrong token, so the entry is kept untouched and the
|
|
// drain gives up until the token changes.
|
|
function queueEnqueue(key, op, sendStatus, err) {
|
|
const status = err && err.status;
|
|
if (status === 400) {
|
|
queueDrop(key);
|
|
toast("Couldn't sync " + titleFor(key) + " — change lost", true);
|
|
return;
|
|
}
|
|
const e = queueDefer(key, op, sendStatus);
|
|
if (status === 401) {
|
|
authFailed = true;
|
|
} else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) {
|
|
queueDrop(key);
|
|
toast("Gave up syncing " + titleFor(key), true);
|
|
return;
|
|
}
|
|
saveQueue(queue);
|
|
}
|
|
|
|
// Keys with a request on the wire right now, mapped to "another write arrived
|
|
// while this one was flying". A second write to the same key is never sent
|
|
// concurrently — the two responses would race to own the row — it is parked in
|
|
// the queue instead and a later drain replays it. The flight already in the
|
|
// air must then leave that entry alone and not adopt its own now-stale
|
|
// response, or it would undo the write the user just made.
|
|
const inFlight = new Map();
|
|
|
|
let draining = null; // the pass in progress, so a second caller awaits it
|
|
|
|
// Replays everything owed. Cheap in the normal case — it returns on the first
|
|
// line when the queue is empty, which is why it can hang off navigation. A
|
|
// 401 stops the whole pass: the token is wrong, so the next entry would fail
|
|
// the same way, and the queue is left intact so fixing the token fixes it.
|
|
//
|
|
// Returns the in-flight pass when one is already running, so refresh()'s
|
|
// `await drain()` really does wait for what we owe instead of racing a drain
|
|
// that onNavigate or the online listener started unawaited.
|
|
function drain() {
|
|
if (queue.length === 0) return Promise.resolve();
|
|
if (draining) return draining;
|
|
const pass = (async () => {
|
|
authFailed = false;
|
|
try {
|
|
for (const e of queue.slice()) {
|
|
if (e.op === "delete") await pushDelete(e.key);
|
|
else await pushBookmark(e.key, e.sendStatus);
|
|
if (authFailed) {
|
|
toast("Sync auth failed — check the token", true);
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
render();
|
|
}
|
|
})();
|
|
// Swallowed, not surfaced: drain is called unawaited from onNavigate and the
|
|
// online listener, and an uncaught rejection on a page we do not control is
|
|
// a console error nobody can act on. Every real sync failure is already
|
|
// toasted and queued by pushBookmark/pushDelete.
|
|
draining = pass
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
draining = null;
|
|
});
|
|
return draining;
|
|
}
|
|
|
|
// Keys the server has not heard about yet must survive a fetched list, or the
|
|
// card the user just changed silently flaps back — the exact bug this queue
|
|
// exists to fix. Reads state.byKey, so it must run *before* setList replaces
|
|
// it.
|
|
function overlayPending(list) {
|
|
if (queue.length === 0) return list;
|
|
const out = list.filter((b) => {
|
|
const e = queueGet(b.key);
|
|
return !(e && e.op === "delete");
|
|
});
|
|
for (const e of queue) {
|
|
if (e.op !== "put") continue;
|
|
const local = state.byKey[e.key];
|
|
if (!local) continue;
|
|
const i = out.findIndex((b) => b.key === e.key);
|
|
if (i >= 0) out[i] = local;
|
|
else out.push(local);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ============================================================
|
|
// Mutations (optimistic: update UI/cache first, then sync)
|
|
// ============================================================
|
|
|
|
// Every write goes through here, queued or not, so there is one place that
|
|
// talks to the API and one place that decides `sendStatus`. That flag is
|
|
// sticky: while an archive is pending for a key, a later progress write to the
|
|
// same key still carries the bucket — otherwise the server would answer with
|
|
// the old status and the adopted row would silently un-archive the series.
|
|
async function pushBookmark(key, sendStatus) {
|
|
const pending = queueGet(key);
|
|
const withStatus = sendStatus || (pending ? pending.sendStatus : false);
|
|
const bm = state.byKey[key];
|
|
if (!bm) {
|
|
queueDrop(key); // removed locally in the meantime — nothing left to send
|
|
return true;
|
|
}
|
|
if (inFlight.has(key)) {
|
|
queueDefer(key, "put", withStatus); // see inFlight — parked, not sent
|
|
inFlight.set(key, true); // supersedes the flight already in the air
|
|
return false;
|
|
}
|
|
inFlight.set(key, false);
|
|
let ok = false;
|
|
try {
|
|
// ponytail: last-write-wins, so a replay can overwrite a newer server
|
|
// value (a poller-written latest_chapter, or progress from another
|
|
// device). Single user, self-healing on the next poll — revisit only if
|
|
// this ever runs multi-user.
|
|
const saved = await apiPut(key, bm, { sendStatus: withStatus });
|
|
if (!inFlight.get(key)) {
|
|
upsertLocal(saved);
|
|
queueDrop(key);
|
|
}
|
|
ok = true;
|
|
} catch (e) {
|
|
// Same guard as the success path: a newer write is parked for this key, so
|
|
// classifying *this* failure would clobber its op or, on a 400, drop it
|
|
// outright. Its own drain reports its own outcome. The one thing that must
|
|
// still carry across is sendStatus — this flight may have been the archive
|
|
// replay, and losing its flag here would narrow the parked write into a
|
|
// silent un-archive.
|
|
const parked = inFlight.get(key) ? queueGet(key) : null;
|
|
if (parked) queueDefer(key, parked.op, withStatus);
|
|
else queueEnqueue(key, "put", withStatus, e);
|
|
} finally {
|
|
inFlight.delete(key);
|
|
}
|
|
// Outside the try: a throw in render() is a UI bug, not a write failure, and
|
|
// must not re-queue a write that already landed.
|
|
if (ok) render();
|
|
return ok;
|
|
}
|
|
|
|
async function pushDelete(key) {
|
|
if (inFlight.has(key)) {
|
|
queueDefer(key, "delete", false); // see inFlight — parked, not sent
|
|
inFlight.set(key, true); // supersedes the flight already in the air
|
|
return false;
|
|
}
|
|
inFlight.set(key, false);
|
|
try {
|
|
await apiDelete(key);
|
|
if (!inFlight.get(key)) queueDrop(key);
|
|
return true;
|
|
} catch (e) {
|
|
if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark
|
|
return false;
|
|
} finally {
|
|
inFlight.delete(key);
|
|
}
|
|
}
|
|
|
|
async function bookmarkCurrent() {
|
|
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,
|
|
kind: LIBRARY,
|
|
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,
|
|
kind: LIBRARY,
|
|
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 || existing.last_chapter || "",
|
|
last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num,
|
|
last_chapter_url: p.chapterUrl || "",
|
|
updated_at: Date.now(),
|
|
});
|
|
await syncUpsert(bm, "Progress updated");
|
|
}
|
|
|
|
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();
|
|
// A queued write owns this row; the drain sends latest_chapter
|
|
// with it, carrying the correct bucket.
|
|
if (queueGet(bm.key)) return;
|
|
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), p.seriesId)
|
|
);
|
|
}
|
|
|
|
// Everything else is only learned by fetching a series page — or, where the
|
|
// site offers one, its JSON API. 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. A few 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;
|
|
|
|
const adapter = adapterFor(site);
|
|
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 {
|
|
let latest;
|
|
if (adapter && adapter.latestChapterFromApi) {
|
|
latest = await adapter.latestChapterFromApi(bm.series_id);
|
|
} else {
|
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
|
if (!res.ok) continue;
|
|
const html = await res.text();
|
|
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
|
|
}
|
|
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 tab fills
|
|
// bottom-up to show it counting down. 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;
|
|
// True while refresh() is waiting on the backend. Only changes what the list
|
|
// draws when there is nothing cached to draw instead.
|
|
let loading = false;
|
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
|
|
|
function buildUI() {
|
|
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);
|
|
root.getElementById("pending").addEventListener("click", () => drain());
|
|
|
|
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 = STORE_PREFIX + "fabpos";
|
|
const FAB_MARGIN = 12; // vertical breathing room at the top and bottom
|
|
const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side
|
|
const ARM_MS = 400; // hold this long on the invisible strip to arm a drag
|
|
|
|
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";
|
|
fab.classList.toggle("left", side === "left");
|
|
if (side === "left") {
|
|
fab.style.left = FAB_EDGE + "px";
|
|
fab.style.right = "auto";
|
|
} else {
|
|
fab.style.right = FAB_EDGE + "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 h = fab.offsetHeight || 44;
|
|
const p = loadFabPos();
|
|
const maxY = window.innerHeight - h - 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);
|
|
}
|
|
|
|
// A gesture that starts on the visible sliver drags immediately, as it always
|
|
// has. A gesture that starts on the invisible #hit strip scrolls the page
|
|
// instead — unless it is held for ARM_MS first, which arms a reposition drag.
|
|
// ponytail: the scroll is hand-rolled (no momentum/fling) and assumes the
|
|
// document is the scroller. That is the price of touch-action:none, which we
|
|
// need so a hold can claim pixels a swipe would otherwise scroll. If the
|
|
// stiffness annoys, give #hit touch-action:pan-y and accept that vertical
|
|
// repositioning must start on the 7px sliver.
|
|
function makeDraggable(fab) {
|
|
let down = false, moved = false, armed = false, scrolling = false, fromHit = false;
|
|
let sx = 0, sy = 0, offX = 0, offY = 0, lastY = 0, armTimer = 0;
|
|
|
|
function reset() {
|
|
down = moved = armed = scrolling = fromHit = false;
|
|
clearTimeout(armTimer);
|
|
fab.classList.remove("armed");
|
|
fab.style.transition = "";
|
|
}
|
|
|
|
function scrollPage(e) {
|
|
e.preventDefault();
|
|
window.scrollBy(0, lastY - e.clientY);
|
|
lastY = e.clientY;
|
|
}
|
|
|
|
fab.addEventListener("pointerdown", (e) => {
|
|
delete fab.dataset.dragged; // stale flag would swallow this gesture's click
|
|
down = true;
|
|
moved = false;
|
|
armed = false;
|
|
scrolling = false;
|
|
fromHit = e.target.id === "hit";
|
|
sx = e.clientX;
|
|
sy = e.clientY;
|
|
lastY = e.clientY;
|
|
const r = fab.getBoundingClientRect();
|
|
offX = e.clientX - r.left;
|
|
offY = e.clientY - r.top;
|
|
fab.setPointerCapture(e.pointerId);
|
|
fab.style.transition = "none";
|
|
clearTimeout(armTimer);
|
|
armTimer = setTimeout(() => {
|
|
armed = true;
|
|
fab.classList.add("armed"); // visible confirmation the hold took
|
|
}, ARM_MS);
|
|
});
|
|
|
|
fab.addEventListener("pointermove", (e) => {
|
|
if (!down) return;
|
|
if (scrolling) {
|
|
scrollPage(e);
|
|
return;
|
|
}
|
|
if (!moved) {
|
|
if (Math.abs(e.clientX - sx) <= 6 && Math.abs(e.clientY - sy) <= 6) return;
|
|
clearTimeout(armTimer);
|
|
if (fromHit && !armed) {
|
|
scrolling = true;
|
|
scrollPage(e);
|
|
return;
|
|
}
|
|
moved = true;
|
|
}
|
|
e.preventDefault();
|
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
|
fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px";
|
|
fab.style.right = "auto";
|
|
fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px";
|
|
fab.style.bottom = "auto";
|
|
});
|
|
|
|
function snapToEdge() {
|
|
const w = fab.offsetWidth, h = fab.offsetHeight;
|
|
const r = fab.getBoundingClientRect();
|
|
const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right";
|
|
const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN);
|
|
fab.style.transition = "left .2s ease, right .2s ease, top .2s ease";
|
|
applyFabPos(fab, side, y);
|
|
saveFabPos(side, y / window.innerHeight);
|
|
}
|
|
|
|
fab.addEventListener("pointerup", (e) => {
|
|
if (!down) return;
|
|
const didDrag = moved, didScroll = scrolling;
|
|
reset();
|
|
if (didScroll) {
|
|
fab.dataset.dragged = "1"; // suppress any click trailing the scroll
|
|
return;
|
|
}
|
|
if (!didDrag) return; // tap — let the click handler toggle the panel
|
|
fab.dataset.dragged = "1"; // suppress the click that trails this drag
|
|
snapToEdge();
|
|
});
|
|
|
|
fab.addEventListener("pointercancel", () => {
|
|
if (!down) return;
|
|
const didDrag = moved;
|
|
reset();
|
|
if (didDrag) snapToEdge(); // OS claimed the gesture mid-drag — don't strand the FAB
|
|
});
|
|
|
|
// touch-action: none stops panning and zooming but NOT Chromium's long-press
|
|
// gesture, which fires at ~500ms — just after ARM_MS — raises the context
|
|
// menu, and cancels the pointer stream. The hold armed the drag and the OS
|
|
// stole it back a frame later. Suppressing the menu keeps the pointer alive.
|
|
fab.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
}
|
|
|
|
// ============================================================
|
|
// Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room
|
|
// for the ring this replaced.
|
|
// ============================================================
|
|
|
|
function startRing(ms) {
|
|
if (!root) return;
|
|
const fill = root.getElementById("fill");
|
|
fill.style.transition = "none";
|
|
fill.style.height = "0";
|
|
fill.getBoundingClientRect(); // reflow so the next assignment animates
|
|
fill.style.transition = "height " + ms + "ms linear";
|
|
fill.style.height = "100%";
|
|
}
|
|
|
|
function stopRing() {
|
|
if (!root) return;
|
|
const fill = root.getElementById("fill");
|
|
if (!fill) return;
|
|
fill.style.transition = "none";
|
|
fill.style.height = "0";
|
|
}
|
|
|
|
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);
|
|
|
|
// Silent while everything is synced; honest the moment something is stuck.
|
|
const pending = root.getElementById("pending");
|
|
pending.hidden = queueCount() === 0;
|
|
pending.textContent = "⟳ " + queueCount() + " pending";
|
|
|
|
// Context header for the current page.
|
|
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 && loading) {
|
|
// Nothing cached and the fetch is still out — without this the panel
|
|
// looks frozen on the first open after a cold start.
|
|
listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })]));
|
|
} else if (items.length === 0) {
|
|
const empty = {
|
|
favorites: "No favourites yet.",
|
|
archived: "Nothing archived.",
|
|
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;
|
|
// Cover and title are the only continue links. The subtitle and the action
|
|
// row live in the same text column but are not navigation targets — a thumb
|
|
// that misses ★ must not land on one, and Remove must never be one.
|
|
// hot = a chapter is out that has not been read (crimson title, ember rule);
|
|
// dim = archived (italic grey). Same two states the web UI paints.
|
|
const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : "");
|
|
return el("div", { class: "item" + heat }, [
|
|
el("a", { class: "go", href: cont }, [
|
|
b.cover
|
|
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
|
: el("div", { class: "cover ph" }),
|
|
]),
|
|
el("div", { class: "meta" }, [
|
|
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
|
|
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
|
el("div", { class: "actions" }, [
|
|
el("button", {
|
|
class: "btn small star" + (b.favorite ? " on" : ""),
|
|
text: b.favorite ? "★" : "☆",
|
|
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
|
onclick: () => toggleFavorite(b.key),
|
|
}),
|
|
el("button", {
|
|
class: "btn small",
|
|
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
|
onclick: () => toggleArchive(b.key),
|
|
}),
|
|
el("button", {
|
|
class: "btn small danger",
|
|
text: "Remove",
|
|
onclick: () => confirmRemove(b),
|
|
}),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// Removal syncs to every device, so it asks first. confirm() is the page's
|
|
// own dialog, not a shadow-root one — modal, and the site cannot style it.
|
|
function confirmRemove(b) {
|
|
if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) {
|
|
removeBookmark(b.key);
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
await drain(); // push what we owe before adopting the server's view of it
|
|
loading = true;
|
|
render();
|
|
try {
|
|
const list = await apiGet();
|
|
setList(overlayPending(list));
|
|
} catch (e) {
|
|
// Offline or backend down: keep whatever the cache holds.
|
|
} finally {
|
|
loading = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
let lastUrl = location.href;
|
|
let lastPageSig = "";
|
|
|
|
function setPage() {
|
|
state.page = detect();
|
|
lastPageSig = JSON.stringify(state.page);
|
|
}
|
|
|
|
function onNavigate() {
|
|
setPage();
|
|
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();
|
|
// Drain only, not a full refresh: Asura may go a long time without a
|
|
// reload, and an extra GET per client-side route change is not wanted.
|
|
drain();
|
|
}
|
|
|
|
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
|
// 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);
|
|
// Also catches routes that bypass history — and comix, which fills
|
|
// document.title a beat after the route changes, so the 300ms snapshot
|
|
// above can still hold the previous page's title. Re-detect whenever what
|
|
// we would read has changed, not only when the URL has.
|
|
setInterval(() => {
|
|
fire();
|
|
if (JSON.stringify(detect()) !== lastPageSig) onNavigate();
|
|
}, 1500);
|
|
}
|
|
|
|
// ============================================================
|
|
// Two-finger long-press — a way in that does not depend on finding a 7px tab
|
|
//
|
|
// Deliberately two fingers, not one: Chromium claims the one-finger long
|
|
// press for image context menus, text selection, and link previews, and on a
|
|
// manga reader the whole viewport is images. It assigns no two-finger long
|
|
// press, so these listeners stay passive and never preventDefault.
|
|
// ============================================================
|
|
|
|
const LONGPRESS_MS = 500;
|
|
const LONGPRESS_SLOP = 15; // px either finger may drift before it counts as a drag
|
|
|
|
function installLongPress() {
|
|
let timer = null;
|
|
let start = null; // [{x, y}, {x, y}] — where each finger landed
|
|
|
|
const cancel = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = null;
|
|
start = null;
|
|
};
|
|
|
|
const opts = { passive: true, capture: true };
|
|
|
|
// Fires once per finger, so the first touch cancels and the second arms.
|
|
// A third finger cancels again.
|
|
document.addEventListener(
|
|
"touchstart",
|
|
(e) => {
|
|
cancel();
|
|
if (e.touches.length !== 2) return;
|
|
start = [
|
|
{ x: e.touches[0].clientX, y: e.touches[0].clientY },
|
|
{ x: e.touches[1].clientX, y: e.touches[1].clientY },
|
|
];
|
|
timer = setTimeout(() => {
|
|
cancel();
|
|
togglePanel();
|
|
}, LONGPRESS_MS);
|
|
},
|
|
opts
|
|
);
|
|
|
|
// A pinch-zoom is two fingers held down, so the slop check is what keeps a
|
|
// normal-speed one from opening the panel mid-gesture. Verified 2026-07-28:
|
|
// a pinch that reaches 240px in ~240ms cancels well before the timer. A
|
|
// deliberately slow pinch that stays inside the slop for the full 500ms
|
|
// does open the panel — accepted, since an absolute per-finger drift
|
|
// threshold cannot tell that apart from two fingers resting, which is the
|
|
// gesture this is for.
|
|
document.addEventListener(
|
|
"touchmove",
|
|
(e) => {
|
|
if (!start || e.touches.length !== 2) return cancel();
|
|
for (let i = 0; i < 2; i++) {
|
|
if (
|
|
Math.abs(e.touches[i].clientX - start[i].x) > LONGPRESS_SLOP ||
|
|
Math.abs(e.touches[i].clientY - start[i].y) > LONGPRESS_SLOP
|
|
) {
|
|
return cancel();
|
|
}
|
|
}
|
|
},
|
|
opts
|
|
);
|
|
|
|
document.addEventListener("touchend", cancel, opts);
|
|
document.addEventListener("touchcancel", cancel, opts);
|
|
}
|
|
|
|
// ============================================================
|
|
// Boot
|
|
// ============================================================
|
|
|
|
function init() {
|
|
buildUI();
|
|
setPage();
|
|
render();
|
|
installNavWatcher();
|
|
installLongPress();
|
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
|
// 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"><span id="fill"></span><span id="hit"></span></button>
|
|
<div id="backdrop"></div>
|
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
|
<header>
|
|
<span class="brand">
|
|
<svg class="mark" viewBox="0 0 200 172" aria-hidden="true">
|
|
<g fill="var(--ink)" stroke="currentColor" stroke-width="6.5" stroke-linejoin="round" stroke-linecap="round">
|
|
<path fill="none" d="M28 36H4v114h192V36h-24"></path>
|
|
<path fill="none" d="M28 23H17v127h166V23h-11"></path>
|
|
<g id="mb-half">
|
|
<path d="M28 7 88 55v97L28 138z"></path>
|
|
<g fill="currentColor" stroke="none">
|
|
<path d="M37 25 55 39v41L37 66z"></path>
|
|
<path d="M60 42 79 57v42L60 84z"></path>
|
|
<path d="M37 75 79 108v13L37 88z"></path>
|
|
<path d="M37 98 79 129v11L37 131z"></path>
|
|
</g>
|
|
</g>
|
|
<use href="#mb-half" transform="matrix(-1 0 0 1 200 0)"></use>
|
|
<g stroke="var(--ember)">
|
|
<path d="M100 4l9 5v11l-9 5-9-5V9z"></path>
|
|
<path d="M94 24h12v24H94z"></path>
|
|
<path d="M70 47h60v14H70z"></path>
|
|
<path d="M91 61h18v87l-9 20-9-20z"></path>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
<span>Bookmark<em>Manager</em></span>
|
|
</span>
|
|
<button id="closeBtn" aria-label="Close">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>
|
|
<a class="chip" href="https://comix.to" target="_blank" rel="noopener">Comix</a>
|
|
<a class="chip" href="https://kagane.to" target="_blank" rel="noopener">Kagane</a>
|
|
<button id="pending" class="chip pending" hidden>⟳ 0 pending</button>
|
|
</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 = `
|
|
/* Cinder, ported to the panel: same tokens and the same rule that heat is
|
|
typographic. Fonts are system stacks, not the web UI's Google fonts — an
|
|
@import here would be at the mercy of the host page's CSP.
|
|
See docs/design-system.md. */
|
|
:host {
|
|
all: initial;
|
|
--ink: #100f0e; --ash: #161413; --dim: #0d0c0b;
|
|
--rule: #221f1d; --field-line: #2c2926; --hover: #1a1816;
|
|
--paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb;
|
|
--mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b;
|
|
--ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907;
|
|
--ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450;
|
|
--font-display: Georgia, "Times New Roman", serif;
|
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
--font-body: system-ui, -apple-system, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; font-family: var(--font-body); }
|
|
#fab {
|
|
position: fixed;
|
|
width: 7px; height: 44px; border: none; padding: 0;
|
|
background: var(--ember); opacity: .5;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer;
|
|
touch-action: none; user-select: none; -webkit-touch-callout: none;
|
|
}
|
|
#fab:active, #fab.armed { opacity: 1; }
|
|
#fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); }
|
|
#fill {
|
|
position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
|
background: var(--paper); pointer-events: none;
|
|
}
|
|
/* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72.
|
|
It grows inward (toward the screen centre) because the tab is docked flush
|
|
against an edge, so outward is off-screen. */
|
|
#hit {
|
|
position: absolute; top: -14px; bottom: -14px;
|
|
left: -21px; right: 0;
|
|
}
|
|
#fab.left #hit { left: 0; right: -21px; }
|
|
#backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
|
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: var(--ink); color: var(--paper-dim);
|
|
transform: translateX(105%); transition: transform .25s ease;
|
|
z-index: 2147483647; display: flex; flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
border-left: 1px solid var(--rule);
|
|
box-shadow: -4px 0 20px rgba(0,0,0,.6);
|
|
}
|
|
#panel.open { transform: translateX(0); }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px; border-bottom: 1px solid var(--rule);
|
|
font: 400 22px/1 var(--font-display); color: var(--paper);
|
|
}
|
|
header em { color: var(--ember); font-style: italic; }
|
|
.brand { display: flex; align-items: center; gap: 9px; }
|
|
/* 5px stroke on a 200-unit grid thins out at this size: the markup carries
|
|
6.5 instead. overflow visible keeps the topmost blade pip from clipping. */
|
|
.mark { width: 26px; height: 22px; flex: none; overflow: visible; }
|
|
#closeBtn {
|
|
background: none; border: none; color: var(--mute); cursor: pointer;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
border-bottom: 1px solid var(--field-line); padding: 0 0 2px;
|
|
}
|
|
#closeBtn:active { color: var(--paper); }
|
|
#nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
|
|
/* Chips are set as small caps rules, not pills — the sheet has no corners. */
|
|
.chip {
|
|
background: none; border: none; padding: 0 0 2px;
|
|
border-bottom: 1px solid var(--field-line);
|
|
color: var(--mute); text-decoration: none; cursor: pointer;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
.chip:active { color: var(--paper); border-bottom-color: var(--paper); }
|
|
.chip.pending { color: var(--ember); border-bottom-color: var(--ember); }
|
|
.chip.pending[hidden] { display: none; }
|
|
#context {
|
|
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
|
display: flex; flex-direction: column; gap: 9px; align-items: flex-start;
|
|
}
|
|
.ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); }
|
|
.ctx-sub {
|
|
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em;
|
|
text-transform: uppercase; color: var(--mute);
|
|
}
|
|
#tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); }
|
|
.tab {
|
|
background: none; border: none; cursor: pointer; color: var(--mute);
|
|
font: 400 16px var(--font-display); padding: 8px 0 12px;
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
}
|
|
.tab.active { color: var(--paper); border-bottom-color: var(--paper); }
|
|
#list { overflow-y: auto; flex: 1; }
|
|
.empty {
|
|
color: var(--mute); padding: 34px 16px;
|
|
font: 400 15px/1.6 var(--font-body);
|
|
}
|
|
/* Loading is the web UI's burning hairline, laid flat across the list. */
|
|
.spinner {
|
|
width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative;
|
|
}
|
|
.spinner::after {
|
|
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
|
|
background: var(--ember); animation: barSlide 1.15s linear infinite;
|
|
}
|
|
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
|
|
.item {
|
|
display: flex; flex-direction: row; gap: 12px;
|
|
padding: 14px 16px; border-bottom: 1px solid var(--rule);
|
|
}
|
|
.item.dim { background: var(--dim); }
|
|
.go { display: block; flex: none; }
|
|
.go:active, .go-t:active { opacity: .7; }
|
|
.go-t { display: block; text-decoration: none; color: inherit; }
|
|
.cover {
|
|
width: 52px; height: 70px; object-fit: cover; flex: none;
|
|
background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
|
|
}
|
|
.cover.ph { display: block; }
|
|
.item.dim .cover { filter: grayscale(1); opacity: .85; }
|
|
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
|
|
.t {
|
|
font: 400 17px/1.2 var(--font-display); color: var(--paper-dim);
|
|
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; }
|
|
.item.dim .t { font-style: italic; color: var(--mute); }
|
|
.c {
|
|
margin: 0; color: var(--mute);
|
|
font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
}
|
|
.c.behind { color: var(--ember); }
|
|
.actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; }
|
|
.btn {
|
|
background: none; color: var(--mute); border: 1px solid var(--rule);
|
|
margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px;
|
|
cursor: pointer; text-decoration: none; display: inline-block;
|
|
font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase;
|
|
}
|
|
.btn:active { color: var(--paper); background: var(--hover); }
|
|
.btn.primary {
|
|
border-color: var(--paper); background: var(--paper); color: var(--ink);
|
|
font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none;
|
|
height: 44px; line-height: 42px; padding: 0 18px;
|
|
}
|
|
.btn.small { height: 34px; line-height: 32px; padding: 0 10px; }
|
|
.btn.danger { color: var(--trash); }
|
|
.btn.danger:active { color: var(--ember); background: var(--ember-wash); }
|
|
.btn.star { font-size: 13px; letter-spacing: 0; }
|
|
.btn.star.on { color: var(--brass); border-color: #332b14; }
|
|
#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: var(--ash); color: var(--paper); padding: 12px 16px;
|
|
border-left: 2px solid var(--paper);
|
|
font: 400 16px var(--font-display);
|
|
opacity: 0; transform: translateY(10px); transition: all .3s;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); }
|
|
`;
|
|
|
|
// ============================================================
|
|
// Test hook
|
|
//
|
|
// Fires only in Node, where window is undefined. The typeof window check keeps it
|
|
// inert on pages whose own scripts define a UMD module shim — under @grant none
|
|
// the userscript shares page globals, so we must not clobber the page's exports.
|
|
// Exposes pure logic only — see userscript/test/logic.test.js.
|
|
// ============================================================
|
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
|
module.exports = { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf };
|
|
}
|
|
|
|
// ============================================================
|
|
// 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);
|
|
})();
|