feat: manga bookmark sync backend + Bromite userscript
Backend (Go, stdlib net/http + modernc.org/sqlite, CGO-free static binary):
- GET/PUT/DELETE /bookmarks{,/key} + /healthz
- bearer auth (constant-time), CORS origin reflection + 204 preflight
- SQLite store keyed <site>:<series_id>, last-write-wins, server-set updated_at
- httptest + temp-sqlite tests (auth, CORS, round-trip); go vet clean
- multi-stage Dockerfile (distroless static nonroot) + compose (base + prod proxy override)
Userscript (single Bromite-compatible IIFE, no GM_* APIs):
- Asura + Demonic adapters, URL-regex ids + og: title/cover
- Shadow-DOM floating UI, localStorage cache, optimistic sync
- auto-progress (no regress) + manual override; framework-agnostic nav watcher
Live-verified adapters (Playwright, 2026-07-24): asurascans.com /comics/<slug-hash>,
demonicscans.org /manga/<slug> + /title/<slug>/chapter/<n> — corrects the plan's
assumed /series/ paths and asuracomic.net domain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
// ==UserScript==
|
||||
// @name Manga Bookmark Sync
|
||||
// @namespace mangabm
|
||||
// @version 1.0.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.example.com"; // your backend origin, no trailing slash
|
||||
const API_TOKEN = "PASTE_YOUR_API_TOKEN_HERE"; // must equal backend API_TOKEN
|
||||
|
||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||
const CACHE_KEY = "mangabm:cache";
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
const asura = {
|
||||
site: "asura",
|
||||
// asuracomic.net currently 301s to asurascans.com; match both.
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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() {
|
||||
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 removeBookmark(key) {
|
||||
removeLocal(key); // optimistic
|
||||
render();
|
||||
try {
|
||||
await apiDelete(key);
|
||||
toast("Removed");
|
||||
} catch (e) {
|
||||
toast("Offline — remove will retry", true);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-record on opening a chapter of a bookmarked series.
|
||||
async function maybeAutoUpdate() {
|
||||
const p = state.page;
|
||||
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
|
||||
}
|
||||
await updateToCurrentChapter();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI — Shadow DOM (isolated from site CSS)
|
||||
// ============================================================
|
||||
|
||||
let root, panelOpen = false;
|
||||
|
||||
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);
|
||||
|
||||
root.getElementById("fab").addEventListener("click", togglePanel);
|
||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||
}
|
||||
|
||||
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." }));
|
||||
}
|
||||
|
||||
// Bookmark list.
|
||||
const listEl = root.getElementById("list");
|
||||
listEl.innerHTML = "";
|
||||
if (state.list.length === 0) {
|
||||
listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." }));
|
||||
}
|
||||
for (const b of state.list) {
|
||||
listEl.appendChild(renderItem(b));
|
||||
}
|
||||
}
|
||||
|
||||
function renderItem(b) {
|
||||
const cont = b.last_chapter_url || b.series_url || "#";
|
||||
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", text: (b.last_chapter || "—") + " · " + b.site }),
|
||||
el("div", { class: "actions" }, [
|
||||
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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page
|
||||
}
|
||||
|
||||
if (document.body) init();
|
||||
else document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
// ============================================================
|
||||
// Markup + styles (kept at bottom for readability)
|
||||
// ============================================================
|
||||
|
||||
const TEMPLATE = `
|
||||
<button id="fab" aria-label="Manga bookmarks">📑</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>
|
||||
<section id="context"></section>
|
||||
<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; right: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
width: 52px; height: 52px; border-radius: 50%; border: none;
|
||||
background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
||||
}
|
||||
#fab:active { transform: scale(.94); }
|
||||
#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; }
|
||||
#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; }
|
||||
#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; }
|
||||
.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; }
|
||||
#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; }
|
||||
`;
|
||||
})();
|
||||
Reference in New Issue
Block a user