Archived and finished buckets, userscript nav chips #4

Merged
sulthan merged 10 commits from feat/status-buckets into main 2026-07-27 16:58:06 +07:00
Showing only changes of commit 0a70d35da2 - Show all commits
+66 -15
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name Manga Bookmark Sync
// @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).
// @author you
// @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_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
const WEB_BASE = "https://manga.violetcrown.my.id"; // the browser UI, for the panel's nav chips
// Safe in Bromite's isolated world: the page's own JS cannot read these.
const CACHE_KEY = "mangabm:cache";
@@ -325,6 +326,12 @@
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)
// ============================================================
@@ -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
// 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
@@ -551,7 +578,7 @@
// ============================================================
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() {
const host = document.createElement("div");
@@ -580,7 +607,7 @@
root.getElementById("backdrop").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", () => {
activeTab = tab;
render();
@@ -752,22 +779,28 @@
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
// 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);
// 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 = 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) {
listEl.appendChild(
el("div", {
class: "empty",
text: favorites ? "No favourites yet." : "No bookmarks yet.",
})
);
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));
@@ -798,6 +831,11 @@
title: b.favorite ? "Remove from favourites" : "Add to favourites",
onclick: () => toggleFavorite(b.key),
}),
el("button", {
class: "btn small",
text: statusOf(b) === "archived" ? "Unarchive" : "Archive",
onclick: () => toggleArchive(b.key),
}),
el("a", { class: "btn small", href: cont, text: "Continue" }),
el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }),
el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }),
@@ -909,10 +947,16 @@
<span>Manga Bookmarks</span>
<button id="closeBtn" aria-label="Close">✕</button>
</header>
<div id="nav">
<a class="chip" href="${WEB_BASE}" target="_blank" rel="noopener">🌐 Web</a>
<a class="chip" href="https://asurascans.com" target="_blank" rel="noopener">Asura</a>
<a class="chip" href="https://demonicscans.org" target="_blank" rel="noopener">Demonic</a>
</div>
<section id="context"></section>
<div id="tabs" role="tablist">
<button id="tabAll" class="tab active" role="tab">All</button>
<button id="tabFav" class="tab" role="tab">★ Favourites</button>
<button id="tabArc" class="tab" role="tab">Archived</button>
</div>
<div id="list"></div>
</aside>
@@ -960,6 +1004,13 @@
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
}
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
#nav { display: flex; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #33333d; }
.chip {
background: #2a2a33; color: #c4b5fd; text-decoration: none;
padding: 6px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
white-space: nowrap;
}
.chip:active { opacity: .8; }
#context {
padding: 12px 16px; border-bottom: 1px solid #33333d;
display: flex; flex-direction: column; gap: 8px;