feat(userscript): latest-chapter tracking, favourites, All/Favourites tabs
Bookmarks now show the newest chapter a site has published alongside the one the user has read. A series page carries its whole chapter list, so standing on one records it directly; everything else is learned by fetching series pages in the background, one per navigation and at most every four hours per series. Those fetches are same-origin on purpose — they ride the browsing session that gets past the sites' bot checks, which a request from the backend could not. Freshness is tracked per device in localStorage rather than synced, since each device checks independently. Favourites are a synced flag with a star toggle and a second tab. Filtering happens at render time, so a favourited series still appears under All. Neither favouriting nor recording a new chapter reorders the list: both send updated_at only as a candidate, and the server keeps the stored value unless reading progress moved. Also corrects the asuracomic.net comment — those deep links now 301 to the asurascans.com root, dropping the path, before any script runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Manga Bookmark Sync
|
// @name Manga Bookmark Sync
|
||||||
// @namespace mangabm
|
// @namespace mangabm
|
||||||
// @version 1.1.0
|
// @version 1.2.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
@@ -23,6 +23,12 @@
|
|||||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||||
const CACHE_KEY = "mangabm:cache";
|
const CACHE_KEY = "mangabm:cache";
|
||||||
|
|
||||||
|
// Per-device record of when each series was last checked for new chapters.
|
||||||
|
// Deliberately not synced: each device does its own checking.
|
||||||
|
const LASTCHECKED_KEY = "mangabm:lastchecked";
|
||||||
|
const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000;
|
||||||
|
const LATEST_CHECK_BATCH = 1; // series fetched per navigation
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Site adapters
|
// Site adapters
|
||||||
//
|
//
|
||||||
@@ -36,9 +42,36 @@
|
|||||||
return el ? el.getAttribute("content") : null;
|
return el ? el.getAttribute("content") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chapter lists are read from two places: the page we are standing on, and
|
||||||
|
// series pages fetched in the background. Both are reduced to {href, text}
|
||||||
|
// pairs so each adapter needs only one rule for picking the latest chapter.
|
||||||
|
|
||||||
|
function anchorsFromDocument(doc) {
|
||||||
|
return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({
|
||||||
|
href: a.getAttribute("href") || "",
|
||||||
|
text: a.textContent || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fetched page is raw text; DOMParser would run against markup we do not
|
||||||
|
// control, so anchors are pulled out directly instead.
|
||||||
|
function anchorsFromHTML(html) {
|
||||||
|
const re = /<a\b[^>]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||||
|
const out = [];
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html))) {
|
||||||
|
out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const asura = {
|
const asura = {
|
||||||
site: "asura",
|
site: "asura",
|
||||||
// asuracomic.net currently 301s to asurascans.com; match both.
|
// 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),
|
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
|
||||||
detect(loc) {
|
detect(loc) {
|
||||||
const path = loc.pathname;
|
const path = loc.pathname;
|
||||||
@@ -84,6 +117,20 @@
|
|||||||
.trim();
|
.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 = {
|
const demonic = {
|
||||||
@@ -131,6 +178,19 @@
|
|||||||
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Series pages still link chapters through the pre-redirect chaptered.php
|
||||||
|
// form. Their order is not dependable, so take the highest number.
|
||||||
|
latestChapterFromAnchors(anchors) {
|
||||||
|
let best = null;
|
||||||
|
for (const a of anchors) {
|
||||||
|
const m = a.href.match(/chaptered\.php\?manga=\d+&(?:amp;)?chapter=([\d.]+)/);
|
||||||
|
if (!m) continue;
|
||||||
|
const num = parseFloat(m[1]);
|
||||||
|
if (isNaN(num)) continue;
|
||||||
|
if (!best || num > best.num) best = { num, label: "Chapter " + m[1] };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADAPTERS = [asura, demonic];
|
const ADAPTERS = [asura, demonic];
|
||||||
@@ -147,6 +207,21 @@
|
|||||||
return page.site + ":" + page.seriesId;
|
return page.site + ":" + page.seriesId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adapterFor(site) {
|
||||||
|
return ADAPTERS.find((a) => a.site === site) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||||
|
function computeLatestChapter(site, anchors) {
|
||||||
|
const a = adapterFor(site);
|
||||||
|
return a ? a.latestChapterFromAnchors(anchors) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSite() {
|
||||||
|
for (const a of ADAPTERS) if (a.matches(window.location)) return a.site;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// API client + local cache
|
// API client + local cache
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -168,6 +243,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function authHeaders(extra) {
|
||||||
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
||||||
}
|
}
|
||||||
@@ -305,6 +397,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(key) {
|
||||||
|
const existing = state.byKey[key];
|
||||||
|
if (!existing) return;
|
||||||
|
const bm = Object.assign({}, existing, {
|
||||||
|
favorite: !existing.favorite,
|
||||||
|
updated_at: Date.now(), // candidate only; the server keeps the stored one
|
||||||
|
});
|
||||||
|
upsertLocal(bm); // optimistic
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
const saved = await apiPut(bm.key, bm);
|
||||||
|
upsertLocal(saved);
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
toast("Offline — saved locally, will retry", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records the newest chapter a site has published. Silent: this fires from
|
||||||
|
// page visits and background checks the user did not ask for, and it never
|
||||||
|
// reorders the list — updated_at is a candidate the server discards unless
|
||||||
|
// reading progress moved.
|
||||||
|
async function applyLatestChapterIfChanged(existing, latest) {
|
||||||
|
if (!existing || !latest) return;
|
||||||
|
if (existing.latest_chapter_num === latest.num) return;
|
||||||
|
const bm = Object.assign({}, existing, {
|
||||||
|
latest_chapter: latest.label,
|
||||||
|
latest_chapter_num: latest.num,
|
||||||
|
updated_at: Date.now(),
|
||||||
|
});
|
||||||
|
upsertLocal(bm);
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
const saved = await apiPut(bm.key, bm);
|
||||||
|
upsertLocal(saved);
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
/* offline — the local cache still shows it, retried on a later visit */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing on a bookmarked series page: its chapter list is already here.
|
||||||
|
function maybeCaptureLatestOnSeriesPage() {
|
||||||
|
const p = state.page;
|
||||||
|
if (p.type !== "series") return;
|
||||||
|
const existing = state.byKey[keyOf(p)];
|
||||||
|
if (!existing) return;
|
||||||
|
applyLatestChapterIfChanged(
|
||||||
|
existing,
|
||||||
|
computeLatestChapter(p.site, anchorsFromDocument(document))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is only learned by fetching a series page. Same-origin
|
||||||
|
// only: these requests carry the session that gets us past the site's bot
|
||||||
|
// checks, which a request to the other site (or from a server) would not.
|
||||||
|
// One series per navigation keeps it indistinguishable from browsing.
|
||||||
|
async function backgroundRefreshLatest() {
|
||||||
|
const site = currentSite();
|
||||||
|
if (!site) return;
|
||||||
|
|
||||||
|
const checked = loadLastChecked();
|
||||||
|
const now = Date.now();
|
||||||
|
const due = state.list
|
||||||
|
.filter((b) => b.site === site && b.series_url)
|
||||||
|
.filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS)
|
||||||
|
.sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0))
|
||||||
|
.slice(0, LATEST_CHECK_BATCH);
|
||||||
|
if (due.length === 0) return;
|
||||||
|
|
||||||
|
for (const bm of due) {
|
||||||
|
// Recorded even when the fetch fails, so a broken series is retried on
|
||||||
|
// the next throttle window rather than on every single page load.
|
||||||
|
checked[bm.key] = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(bm.series_url, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const html = await res.text();
|
||||||
|
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html));
|
||||||
|
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest);
|
||||||
|
} catch (e) {
|
||||||
|
/* offline or blocked — try again after the throttle window */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveLastChecked(checked);
|
||||||
|
}
|
||||||
|
|
||||||
async function removeBookmark(key) {
|
async function removeBookmark(key) {
|
||||||
removeLocal(key); // optimistic
|
removeLocal(key); // optimistic
|
||||||
render();
|
render();
|
||||||
@@ -372,6 +551,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
let root, panelOpen = false;
|
let root, panelOpen = false;
|
||||||
|
let activeTab = "all"; // "all" | "favorites"; resets each page, by design
|
||||||
|
|
||||||
function buildUI() {
|
function buildUI() {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -400,6 +580,13 @@
|
|||||||
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
||||||
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
||||||
|
|
||||||
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"]]) {
|
||||||
|
root.getElementById(id).addEventListener("click", () => {
|
||||||
|
activeTab = tab;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
placeFab(fab);
|
placeFab(fab);
|
||||||
makeDraggable(fab);
|
makeDraggable(fab);
|
||||||
window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation
|
window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation
|
||||||
@@ -565,27 +752,52 @@
|
|||||||
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark list.
|
// Bookmark list. Favorites only narrows what is drawn — state.list keeps
|
||||||
|
// every bookmark, so favouriting one never removes it from "All".
|
||||||
|
const favorites = activeTab === "favorites";
|
||||||
|
root.getElementById("tabAll").classList.toggle("active", !favorites);
|
||||||
|
root.getElementById("tabFav").classList.toggle("active", favorites);
|
||||||
|
|
||||||
const listEl = root.getElementById("list");
|
const listEl = root.getElementById("list");
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
if (state.list.length === 0) {
|
const items = favorites ? state.list.filter((b) => b.favorite) : state.list;
|
||||||
listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." }));
|
if (items.length === 0) {
|
||||||
|
listEl.appendChild(
|
||||||
|
el("div", {
|
||||||
|
class: "empty",
|
||||||
|
text: favorites ? "No favourites yet." : "No bookmarks yet.",
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const b of state.list) {
|
for (const b of items) {
|
||||||
listEl.appendChild(renderItem(b));
|
listEl.appendChild(renderItem(b));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItem(b) {
|
function renderItem(b) {
|
||||||
const cont = b.last_chapter_url || b.series_url || "#";
|
const cont = b.last_chapter_url || b.series_url || "#";
|
||||||
|
// Only worth naming the latest chapter while it is ahead of the read one.
|
||||||
|
const behind =
|
||||||
|
b.latest_chapter &&
|
||||||
|
b.latest_chapter_num != null &&
|
||||||
|
(b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num);
|
||||||
|
const sub = behind
|
||||||
|
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
||||||
|
: (b.last_chapter || "—") + " · " + b.site;
|
||||||
const row = el("div", { class: "item" }, [
|
const row = el("div", { class: "item" }, [
|
||||||
b.cover
|
b.cover
|
||||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||||
: el("div", { class: "cover ph" }),
|
: el("div", { class: "cover ph" }),
|
||||||
el("div", { class: "meta" }, [
|
el("div", { class: "meta" }, [
|
||||||
el("div", { class: "t", text: b.title || b.series_id }),
|
el("div", { class: "t", text: b.title || b.series_id }),
|
||||||
el("div", { class: "c", text: (b.last_chapter || "—") + " · " + b.site }),
|
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||||
el("div", { class: "actions" }, [
|
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("a", { class: "btn small", href: cont, text: "Continue" }),
|
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
||||||
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
|
||||||
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
|
||||||
@@ -634,6 +846,10 @@
|
|||||||
state.page = detect();
|
state.page = detect();
|
||||||
render();
|
render();
|
||||||
maybeAutoUpdate();
|
maybeAutoUpdate();
|
||||||
|
maybeCaptureLatestOnSeriesPage();
|
||||||
|
// Asura is client-routed, so full loads are rare — checking here too is
|
||||||
|
// what keeps its bookmarks current. The throttle still caps the rate.
|
||||||
|
backgroundRefreshLatest();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
// Framework-agnostic URL-change watcher: patch history + poll as a fallback,
|
||||||
@@ -669,7 +885,13 @@
|
|||||||
state.page = detect();
|
state.page = detect();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page
|
// 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -688,6 +910,10 @@
|
|||||||
<button id="closeBtn" aria-label="Close">✕</button>
|
<button id="closeBtn" aria-label="Close">✕</button>
|
||||||
</header>
|
</header>
|
||||||
<section id="context"></section>
|
<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>
|
||||||
|
</div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
@@ -740,6 +966,13 @@
|
|||||||
}
|
}
|
||||||
.ctx-title { font-weight: 600; font-size: 15px; }
|
.ctx-title { font-weight: 600; font-size: 15px; }
|
||||||
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
||||||
|
#tabs { display: flex; gap: 14px; padding: 10px 16px 0; border-bottom: 1px solid #33333d; }
|
||||||
|
.tab {
|
||||||
|
background: none; border: none; color: #9a9aa5; cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: 600; padding: 4px 0 9px;
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
.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; }
|
.item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; }
|
||||||
@@ -748,6 +981,7 @@
|
|||||||
.meta { min-width: 0; flex: 1; }
|
.meta { min-width: 0; flex: 1; }
|
||||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; }
|
||||||
|
.c.behind { color: #c4b5fd; }
|
||||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.btn {
|
.btn {
|
||||||
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
||||||
@@ -757,6 +991,8 @@
|
|||||||
.btn.primary { background: #6d28d9; color: #fff; }
|
.btn.primary { background: #6d28d9; color: #fff; }
|
||||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||||
.btn.danger { background: #7f1d1d; }
|
.btn.danger { background: #7f1d1d; }
|
||||||
|
.btn.star { line-height: 1; }
|
||||||
|
.btn.star.on { background: #6d28d9; color: #fff; }
|
||||||
#toasts {
|
#toasts {
|
||||||
position: fixed; left: 50%; transform: translateX(-50%);
|
position: fixed; left: 50%; transform: translateX(-50%);
|
||||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||||
|
|||||||
Reference in New Issue
Block a user