feat(userscript): archived tab, archive button, and site nav chips
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.2.0
|
// @version 1.3.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/*
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
||||||
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
||||||
|
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
|
||||||
|
|
||||||
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
||||||
const CACHE_KEY = "mangabm:cache";
|
const CACHE_KEY = "mangabm:cache";
|
||||||
@@ -325,6 +326,12 @@
|
|||||||
saveCache(state.list);
|
saveCache(state.list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A list cached by an older version has no status field, and a row the
|
||||||
|
// server defaulted has "reading" — both mean the same thing here.
|
||||||
|
function statusOf(b) {
|
||||||
|
return b.status || "reading";
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Mutations (optimistic: update UI/cache first, then sync)
|
// Mutations (optimistic: update UI/cache first, then sync)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -415,6 +422,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archive parks a series: it leaves All and Favourites but the server keeps
|
||||||
|
// polling it for new chapters. "finished" is deliberately not reachable from
|
||||||
|
// here — the API rejects that value, it is a web-UI decision.
|
||||||
|
async function toggleArchive(key) {
|
||||||
|
const existing = state.byKey[key];
|
||||||
|
if (!existing) return;
|
||||||
|
const next = statusOf(existing) === "archived" ? "reading" : "archived";
|
||||||
|
const bm = Object.assign({}, existing, { status: next });
|
||||||
|
upsertLocal(bm);
|
||||||
|
render();
|
||||||
|
try {
|
||||||
|
const saved = await apiPut(bm.key, bm);
|
||||||
|
upsertLocal(saved); // adopt the stored row: the server owns updated_at
|
||||||
|
render();
|
||||||
|
toast(next === "archived" ? "Archived" : "Back in your list");
|
||||||
|
} catch (e) {
|
||||||
|
toast("Archive failed — retry when online", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Records the newest chapter a site has published. Silent: this fires from
|
// 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
|
// 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
|
// reorders the list — updated_at is a candidate the server discards unless
|
||||||
@@ -551,7 +578,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
let root, panelOpen = false;
|
let root, panelOpen = false;
|
||||||
let activeTab = "all"; // "all" | "favorites"; resets each page, by design
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
||||||
|
|
||||||
function buildUI() {
|
function buildUI() {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -580,7 +607,7 @@
|
|||||||
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"]]) {
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById(id).addEventListener("click", () => {
|
root.getElementById(id).addEventListener("click", () => {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
render();
|
render();
|
||||||
@@ -752,22 +779,28 @@
|
|||||||
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. Favorites only narrows what is drawn — state.list keeps
|
// Tabs narrow what is drawn; state.list always holds every bookmark.
|
||||||
// every bookmark, so favouriting one never removes it from "All".
|
// Archived rows are hidden from All and Favourites, and finished ones —
|
||||||
const favorites = activeTab === "favorites";
|
// which only the web UI can set — are hidden from every tab.
|
||||||
root.getElementById("tabAll").classList.toggle("active", !favorites);
|
for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) {
|
||||||
root.getElementById("tabFav").classList.toggle("active", favorites);
|
root.getElementById(id).classList.toggle("active", activeTab === tab);
|
||||||
|
}
|
||||||
|
|
||||||
const listEl = root.getElementById("list");
|
const listEl = root.getElementById("list");
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
const items = favorites ? state.list.filter((b) => b.favorite) : state.list;
|
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) {
|
if (items.length === 0) {
|
||||||
listEl.appendChild(
|
const empty = {
|
||||||
el("div", {
|
favorites: "No favourites yet.",
|
||||||
class: "empty",
|
archived: "Nothing archived.",
|
||||||
text: favorites ? "No favourites yet." : "No bookmarks yet.",
|
all: "No bookmarks yet.",
|
||||||
})
|
}[activeTab];
|
||||||
);
|
listEl.appendChild(el("div", { class: "empty", text: empty }));
|
||||||
}
|
}
|
||||||
for (const b of items) {
|
for (const b of items) {
|
||||||
listEl.appendChild(renderItem(b));
|
listEl.appendChild(renderItem(b));
|
||||||
@@ -798,6 +831,11 @@
|
|||||||
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
title: b.favorite ? "Remove from favourites" : "Add to favourites",
|
||||||
onclick: () => toggleFavorite(b.key),
|
onclick: () => toggleFavorite(b.key),
|
||||||
}),
|
}),
|
||||||
|
el("button", {
|
||||||
|
class: "btn small",
|
||||||
|
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
|
||||||
|
onclick: () => toggleArchive(b.key),
|
||||||
|
}),
|
||||||
el("a", { class: "btn small", href: cont, text: "Continue" }),
|
el("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) }),
|
||||||
@@ -909,10 +947,16 @@
|
|||||||
<span>Manga Bookmarks</span>
|
<span>Manga Bookmarks</span>
|
||||||
<button id="closeBtn" aria-label="Close">✕</button>
|
<button id="closeBtn" aria-label="Close">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="nav">
|
||||||
|
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
|
||||||
|
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
|
||||||
|
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
|
||||||
|
</div>
|
||||||
<section id="context"></section>
|
<section id="context"></section>
|
||||||
<div id="tabs" role="tablist">
|
<div id="tabs" role="tablist">
|
||||||
<button id="tabAll" class="tab active" role="tab">All</button>
|
<button id="tabAll" class="tab active" role="tab">All</button>
|
||||||
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
|
||||||
|
<button id="tabArc" class="tab" role="tab">Archived</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -960,6 +1004,13 @@
|
|||||||
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
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; }
|
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
||||||
|
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
|
||||||
|
.chip {
|
||||||
|
background: #2a2a33; color: #c4b5fd; text-decoration: none;
|
||||||
|
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip:active { opacity: .8; }
|
||||||
#context {
|
#context {
|
||||||
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user