feat(web): history of searches on the page
Each search is kept in IndexedDB with its covers and edits; the History list and a shelf of recent series on the start screen reopen it instantly. Searching a series again keeps the edits of covers that are still there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ee5f0efa
commit
2da992fd81
7 files changed
+342
-28
No files matched your search
+146
-26
@@ -2,11 +2,15 @@ import { LITTERBOX_TIMES, type HostId, type HostOptions, type LitterboxTime } fr
|
||||
import { HOSTS } from "../core/hosting/registry";
|
||||
import { coverFormUrl } from "../core/mangabaka/client";
|
||||
import { CONTENT_RATINGS, GRANULARITIES, type ContentRating, type Granularity } from "../core/model";
|
||||
import type { RunResult } from "../core/pipeline";
|
||||
import { carryEdits, fromSaved, saveAndPrune, toSaved, type HistoryStore, type Marks } from "../core/saved";
|
||||
import { loadToken, saveToken, type KeyValueStore, type PreparedSubmission, type Session } from "../core/session";
|
||||
import { SOURCES } from "../core/sources/registry";
|
||||
import { zipStore } from "../core/zip";
|
||||
import { renderRecent } from "./recent";
|
||||
import { renderCards, renderPresent, renderPreview } from "./render";
|
||||
import { saveFile } from "./save-file";
|
||||
import { GRANULARITY_LABELS, defaultHost, hostChoices, stageText, type ServerConfig } from "./view-model";
|
||||
import { GRANULARITY_LABELS, defaultHost, hostChoices, relativeTime, stageText, type ServerConfig } from "./view-model";
|
||||
|
||||
export const USERHASH_KEY = "mbseed.catbox-userhash";
|
||||
export const VIEW_KEY = "mbseed.view";
|
||||
@@ -20,8 +24,8 @@ export interface AppOptions {
|
||||
catboxNeedsServer: boolean;
|
||||
/** Userscript: the MangaBaka series page it runs on. */
|
||||
seriesLink?: string;
|
||||
/** Called after "Download selected (ZIP)" handed the file to the browser. */
|
||||
onDownloaded?: () => void;
|
||||
/** The page's history of searches (spec 002 §3); the userscript has none. */
|
||||
history?: HistoryStore | null;
|
||||
}
|
||||
|
||||
/** Wires APP_HTML to a Session. Nothing is uploaded or sent without a click (spec §1). */
|
||||
@@ -72,6 +76,15 @@ export function mountApp(o: AppOptions): void {
|
||||
previewDiff: q<HTMLElement>("preview-diff"),
|
||||
confirm: q<HTMLButtonElement>("confirm"),
|
||||
submitStatus: q<HTMLElement>("submit-status"),
|
||||
recent: q<HTMLElement>("recent"),
|
||||
recentList: q<HTMLElement>("recent-list"),
|
||||
recentStatus: q<HTMLElement>("recent-status"),
|
||||
recentClear: q<HTMLButtonElement>("recent-clear"),
|
||||
recentShelfWrap: q<HTMLElement>("recent-shelf-wrap"),
|
||||
recentShelf: q<HTMLElement>("recent-shelf"),
|
||||
foundLine: q<HTMLElement>("found-line"),
|
||||
foundWhen: q<HTMLElement>("found-when"),
|
||||
searchAgain: q<HTMLButtonElement>("search-again"),
|
||||
};
|
||||
const plates = [...o.root.querySelectorAll<HTMLElement>(".mbseed-plates [data-stage]")];
|
||||
let currentStage = "";
|
||||
@@ -92,6 +105,11 @@ export function mountApp(o: AppOptions): void {
|
||||
let running: AbortController | null = null;
|
||||
let stopHistory: AbortController | null = null;
|
||||
let prepared: PreparedSubmission | null = null;
|
||||
/** The history entry on screen: what was done with it, and when its search ran. */
|
||||
let marks: Marks = {};
|
||||
let foundAt = "";
|
||||
let currentId: number | null = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const fill = (select: HTMLSelectElement, values: readonly (string | number)[], label: (v: string) => string, selected: string) =>
|
||||
select.replaceChildren(...values.map(String).map((v) => new Option(label(v), v, false, v === selected)));
|
||||
@@ -163,6 +181,109 @@ export function mountApp(o: AppOptions): void {
|
||||
ui.confirm.hidden = true;
|
||||
ui.previewDiff.replaceChildren();
|
||||
draw();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function refreshRecent(): Promise<void> {
|
||||
if (!o.history) return;
|
||||
const items = await o.history.list().catch(() => []);
|
||||
ui.recent.hidden = items.length === 0;
|
||||
ui.recentShelfWrap.hidden = items.length === 0;
|
||||
renderRecent(ui.recentList, ui.recentShelf, items, currentId, new Date(), { open: (id) => void open(id), remove: (id) => void forget(id) });
|
||||
}
|
||||
|
||||
/** Spec 002 §3.1: the entry is rewritten after the search, each edit, rehosting, a download and filing. */
|
||||
function save(): void {
|
||||
clearTimeout(saveTimer);
|
||||
const r = o.session.result;
|
||||
const input = o.session.input;
|
||||
if (!o.history || !r || !input) return;
|
||||
const entry = toSaved({
|
||||
result: r, cards: o.session.cards, makeDefault: o.session.makeDefault, input,
|
||||
rating: ui.ratingAll.value as ContentRating, marks, foundAt, now: new Date(),
|
||||
});
|
||||
saveAndPrune(o.history, entry)
|
||||
.then(() => status(ui.recentStatus, ""))
|
||||
.catch(() => status(ui.recentStatus, "This search couldn't be saved: the browser refused the storage.", true))
|
||||
.finally(() => void refreshRecent());
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
clearTimeout(saveTimer);
|
||||
if (o.history) saveTimer = setTimeout(save, 500);
|
||||
}
|
||||
|
||||
function mark(patch: Marks): void {
|
||||
marks = { ...marks, ...patch };
|
||||
save();
|
||||
}
|
||||
|
||||
/** Shows a finished search: plates, notes, the series head and the shelf. */
|
||||
function show(r: RunResult, historyOff: boolean): void {
|
||||
markStage("compare", "done");
|
||||
setCount("series", `#${r.seriesId}`);
|
||||
setCount("source", r.series.language);
|
||||
if (historyOff) setCount("history", "off");
|
||||
setCount("inspect", String(r.fresh.length + r.present.length));
|
||||
setCount("compare", `${r.present.length} on MangaBaka`);
|
||||
const notes = [...r.warnings];
|
||||
const landing = r.fresh.filter((c) => c.shape === "tall").length;
|
||||
if (landing) {
|
||||
notes.push(`${landing} tall image${landing === 1 ? " looks" : "s look"} like landing-page art and start${landing === 1 ? "s" : ""} unselected.`);
|
||||
}
|
||||
ui.warnings.replaceChildren(...notes.map((textContent) => Object.assign(document.createElement("li"), { textContent })));
|
||||
ui.progress.max = 1;
|
||||
ui.progress.value = 1;
|
||||
ui.seriesTitle.textContent = r.mbSeries.title;
|
||||
ui.seriesSource.textContent = `← ${r.source.name} “${r.series.title}” · ${r.series.language}`;
|
||||
ui.matureWarning.hidden = !r.series.mature;
|
||||
ui.formLink.href = coverFormUrl(r.seriesId);
|
||||
ui.review.hidden = false;
|
||||
draw();
|
||||
}
|
||||
|
||||
/** Clears what belonged to the previous series on screen. */
|
||||
function resetReview(): void {
|
||||
prepared = null;
|
||||
ui.confirm.hidden = true;
|
||||
ui.previewDiff.replaceChildren();
|
||||
ui.linkConfirm.checked = false;
|
||||
for (const line of [ui.rehostStatus, ui.manualStatus, ui.submitStatus]) status(line, "");
|
||||
}
|
||||
|
||||
async function open(seriesId: number): Promise<void> {
|
||||
if (!o.history || running) return;
|
||||
save();
|
||||
try {
|
||||
const entry = await o.history.get(seriesId);
|
||||
if (!entry) throw new Error("That entry is no longer in the history.");
|
||||
const back = fromSaved(entry, SOURCES, Date.now());
|
||||
o.session.restore(back.result, back.cards, back.makeDefault, back.input);
|
||||
resetReview();
|
||||
if (!ui.mbLink.readOnly) ui.mbLink.value = back.input.mangabakaLink;
|
||||
ui.sourceLink.value = back.input.sourceLink;
|
||||
ui.historyOn.checked = back.input.history !== null;
|
||||
if (back.input.history !== null) ui.historyEvery.value = String(back.input.history);
|
||||
ui.ratingAll.value = back.rating;
|
||||
marks = back.marks;
|
||||
foundAt = back.foundAt;
|
||||
currentId = seriesId;
|
||||
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
|
||||
show(back.result, back.input.history === null);
|
||||
ui.foundWhen.textContent = `Found ${relativeTime(back.foundAt)}.`;
|
||||
ui.foundLine.hidden = false;
|
||||
status(ui.stage, `Opened from the history: ${back.result.fresh.length} new cover(s); ${back.result.present.length} already on MangaBaka.`);
|
||||
} catch (err) {
|
||||
status(ui.stage, errorText(err), true);
|
||||
}
|
||||
void refreshRecent();
|
||||
}
|
||||
|
||||
async function forget(seriesId: number): Promise<void> {
|
||||
if (!o.history) return;
|
||||
await o.history.remove(seriesId).catch(() => undefined);
|
||||
if (currentId === seriesId) currentId = null;
|
||||
void refreshRecent();
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
@@ -195,9 +316,8 @@ export function mountApp(o: AppOptions): void {
|
||||
ui.cancel.hidden = false;
|
||||
ui.warnings.replaceChildren();
|
||||
ui.review.hidden = true;
|
||||
ui.confirm.hidden = true;
|
||||
ui.previewDiff.replaceChildren();
|
||||
ui.linkConfirm.checked = false;
|
||||
resetReview();
|
||||
ui.foundLine.hidden = true;
|
||||
for (const plate of plates) setCount(plate.dataset.stage ?? "", "");
|
||||
status(ui.stage, "Starting…");
|
||||
try {
|
||||
@@ -226,26 +346,15 @@ export function mountApp(o: AppOptions): void {
|
||||
},
|
||||
ui.ratingAll.value as ContentRating,
|
||||
);
|
||||
markStage("compare", "done");
|
||||
setCount("series", `#${r.seriesId}`);
|
||||
setCount("source", r.series.language);
|
||||
if (ui.historyOn.checked === false) setCount("history", "off");
|
||||
setCount("compare", `${r.present.length} on MangaBaka`);
|
||||
const landing = r.fresh.filter((c) => c.shape === "tall").length;
|
||||
if (landing) {
|
||||
ui.warnings.append(Object.assign(document.createElement("li"), {
|
||||
textContent: `${landing} tall image${landing === 1 ? " looks" : "s look"} like landing-page art and start${landing === 1 ? "s" : ""} unselected.`,
|
||||
}));
|
||||
}
|
||||
// Searching a series again keeps what the user did with it (spec 002 §3.3).
|
||||
const previous = o.history ? await o.history.get(r.seriesId).catch(() => null) : null;
|
||||
if (previous) carryEdits(previous.cards, o.session.cards, Date.now());
|
||||
marks = previous?.marks ?? {};
|
||||
foundAt = new Date().toISOString();
|
||||
currentId = r.seriesId;
|
||||
show(r, !ui.historyOn.checked);
|
||||
status(ui.stage, `Found ${r.fresh.length} new cover(s); ${r.present.length} already on MangaBaka.`);
|
||||
ui.progress.max = 1;
|
||||
ui.progress.value = 1;
|
||||
ui.seriesTitle.textContent = r.mbSeries.title;
|
||||
ui.seriesSource.textContent = `← ${r.source.name} “${r.series.title}” · ${r.series.language}`;
|
||||
ui.matureWarning.hidden = !r.series.mature;
|
||||
ui.formLink.href = coverFormUrl(r.seriesId);
|
||||
ui.review.hidden = false;
|
||||
draw();
|
||||
save();
|
||||
} catch (err) {
|
||||
if (running?.signal.aborted) {
|
||||
markStage(currentStage, "reset");
|
||||
@@ -306,6 +415,7 @@ export function mountApp(o: AppOptions): void {
|
||||
if (res.allFailed) status(ui.rehostStatus, `Every upload failed: ${res.failures[0]?.message ?? "unknown error"}`, true);
|
||||
else {
|
||||
const done = host === "direct" ? `${res.hosted.size} ready with their source URLs (not rehosted)` : `${res.hosted.size} rehosted`;
|
||||
marks = { ...marks, rehosted: { at: new Date().toISOString(), host } };
|
||||
status(ui.rehostStatus, `${done}${res.failures.length ? `, ${res.failures.length} failed (see the covers)` : ""}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -320,7 +430,7 @@ export function mountApp(o: AppOptions): void {
|
||||
const bundle = o.session.bundle();
|
||||
saveFile(zipStore(bundle.files), bundle.zipName, "application/zip", ui.download);
|
||||
status(ui.manualStatus, `Saved ${bundle.zipName} (${bundle.files.length - 1} covers and covers.txt).`);
|
||||
o.onDownloaded?.();
|
||||
mark({ downloaded: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
status(ui.manualStatus, errorText(err), true);
|
||||
}
|
||||
@@ -363,6 +473,7 @@ export function mountApp(o: AppOptions): void {
|
||||
const outcome = await o.session.submit(t, prepared, host, hostOptions());
|
||||
if (outcome.status === "saved") {
|
||||
status(ui.submitStatus, `Filed edit request #${outcome.result.submission_id} (${outcome.result.status}). A MangaBaka moderator will review it.`);
|
||||
mark({ filed: { at: new Date().toISOString(), submissionId: outcome.result.submission_id } });
|
||||
prepared = null;
|
||||
ui.confirm.hidden = true;
|
||||
} else {
|
||||
@@ -378,5 +489,14 @@ export function mountApp(o: AppOptions): void {
|
||||
}
|
||||
});
|
||||
|
||||
ui.searchAgain.addEventListener("click", () => void run());
|
||||
ui.recentClear.addEventListener("click", async () => {
|
||||
if (!o.history || !confirm("Clear the whole history? The saved covers are deleted from this browser.")) return;
|
||||
await o.history.clear().catch(() => undefined);
|
||||
currentId = null;
|
||||
void refreshRecent();
|
||||
});
|
||||
|
||||
drawHosts();
|
||||
void refreshRecent();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { summarize, type HistoryStore, type SavedSearch, type SearchSummary } from "../core/saved";
|
||||
|
||||
// Spec 002 §3.2: IndexedDB on the page's origin. `summaries` is what the list reads; `searches` holds the bytes.
|
||||
|
||||
const DB = "mbseed";
|
||||
const SUMMARIES = "summaries";
|
||||
const SEARCHES = "searches";
|
||||
|
||||
const done = <T>(req: IDBRequest<T>): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
|
||||
});
|
||||
|
||||
const committed = (tx: IDBTransaction): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
||||
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
||||
});
|
||||
|
||||
/** The page's history, or null when this browser won't give us IndexedDB (private windows, blocked storage). */
|
||||
export async function openHistory(factory: IDBFactory | undefined = globalThis.indexedDB): Promise<HistoryStore | null> {
|
||||
if (!factory) return null;
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
const req = factory.open(DB, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
req.result.createObjectStore(SUMMARIES, { keyPath: "seriesId" });
|
||||
req.result.createObjectStore(SEARCHES, { keyPath: "seriesId" });
|
||||
};
|
||||
db = await done(req);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const write = async (run: (summaries: IDBObjectStore, searches: IDBObjectStore) => void) => {
|
||||
const tx = db.transaction([SUMMARIES, SEARCHES], "readwrite");
|
||||
run(tx.objectStore(SUMMARIES), tx.objectStore(SEARCHES));
|
||||
await committed(tx);
|
||||
};
|
||||
return {
|
||||
list: async () => {
|
||||
const all = await done(db.transaction(SUMMARIES).objectStore(SUMMARIES).getAll() as IDBRequest<SearchSummary[]>);
|
||||
return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
||||
},
|
||||
get: async (id) => (await done(db.transaction(SEARCHES).objectStore(SEARCHES).get(id) as IDBRequest<SavedSearch | undefined>)) ?? null,
|
||||
put: (s) => write((summaries, searches) => {
|
||||
summaries.put(summarize(s));
|
||||
searches.put(s);
|
||||
}),
|
||||
remove: (id) => write((summaries, searches) => {
|
||||
summaries.delete(id);
|
||||
searches.delete(id);
|
||||
}),
|
||||
clear: () => write((summaries, searches) => {
|
||||
summaries.clear();
|
||||
searches.clear();
|
||||
}),
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { Session } from "../core/session";
|
||||
import { withPolicy } from "../core/transport";
|
||||
import { mountApp } from "./app";
|
||||
import { browserThemeDocument, followDarkReader } from "./dark-reader";
|
||||
import { openHistory } from "./history-db";
|
||||
import { APP_HTML } from "./template";
|
||||
import { localStore, pageTransport } from "./transport";
|
||||
import type { ServerConfig } from "./view-model";
|
||||
@@ -13,6 +14,7 @@ const config = await fetch("/api/config")
|
||||
.then((r) => (r.ok ? (r.json() as Promise<ServerConfig>) : null))
|
||||
.catch(() => null);
|
||||
const store = localStore();
|
||||
const history = await openHistory();
|
||||
const session = new Session({
|
||||
transport: withPolicy(pageTransport(location.origin)),
|
||||
serverBase: location.origin,
|
||||
@@ -20,4 +22,4 @@ const session = new Session({
|
||||
store,
|
||||
persistHostedUrls: false,
|
||||
});
|
||||
mountApp({ root: document, session, store, config, catboxNeedsServer: true });
|
||||
mountApp({ root: document, session, store, config, catboxNeedsServer: true, history });
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SearchSummary } from "../core/saved";
|
||||
import { el } from "./render";
|
||||
import { markText, relativeTime, summaryLine } from "./view-model";
|
||||
|
||||
// Spec 002 §3.4: the History list on the left desk and the shelf of recent series on the start screen.
|
||||
|
||||
export interface RecentHandlers {
|
||||
open(seriesId: number): void;
|
||||
remove(seriesId: number): void;
|
||||
}
|
||||
|
||||
const SHELF_SIZE = 6;
|
||||
/** Thumbnail Blob URLs of the last render, revoked on the next one. */
|
||||
let thumbUrls: string[] = [];
|
||||
|
||||
function thumb(s: SearchSummary): HTMLImageElement | HTMLSpanElement {
|
||||
if (!s.thumb) return el("span", { className: "mbseed-recent-thumb is-blank" });
|
||||
const url = URL.createObjectURL(new Blob([s.thumb.bytes as Uint8Array<ArrayBuffer>], { type: s.thumb.contentType }));
|
||||
thumbUrls.push(url);
|
||||
return el("img", { className: "mbseed-recent-thumb", src: url, alt: "", decoding: "async" });
|
||||
}
|
||||
|
||||
const CROSS = `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" stroke="currentColor" stroke-width="1.6"/></svg>`;
|
||||
|
||||
function row(s: SearchSummary, current: boolean, now: Date, h: RecentHandlers): HTMLLIElement {
|
||||
const marks = markText(s.marks);
|
||||
const open = el("button", { type: "button", className: "mbseed-recent-open" },
|
||||
thumb(s),
|
||||
el("span", { className: "mbseed-recent-text" },
|
||||
el("span", { className: "mbseed-recent-title", textContent: s.title }),
|
||||
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${summaryLine(s)}` }),
|
||||
...(marks.length ? [el("span", { className: "mbseed-recent-marks", textContent: marks.join(" · ") })] : [])));
|
||||
if (current) open.setAttribute("aria-current", "true");
|
||||
open.addEventListener("click", () => h.open(s.seriesId));
|
||||
const remove = el("button", { type: "button", className: "mbseed-recent-remove", title: "Remove from history", ariaLabel: `Remove ${s.title} from history` });
|
||||
remove.innerHTML = CROSS;
|
||||
remove.addEventListener("click", () => h.remove(s.seriesId));
|
||||
return el("li", {}, open, remove);
|
||||
}
|
||||
|
||||
function shelfBook(s: SearchSummary, now: Date, h: RecentHandlers): HTMLButtonElement {
|
||||
const book = el("button", { type: "button", className: "mbseed-recent-book", ariaLabel: `Open ${s.title}` },
|
||||
el("span", { className: "mbseed-recent-jacket" }, thumb(s)),
|
||||
el("span", { className: "mbseed-recent-band" },
|
||||
el("span", { className: "mbseed-recent-title", textContent: s.title }),
|
||||
el("span", { className: "mbseed-recent-meta", textContent: `${relativeTime(s.savedAt, now)} · ${s.fresh} new` })));
|
||||
book.addEventListener("click", () => h.open(s.seriesId));
|
||||
return book;
|
||||
}
|
||||
|
||||
export function renderRecent(
|
||||
list: HTMLElement,
|
||||
shelf: HTMLElement,
|
||||
items: readonly SearchSummary[],
|
||||
current: number | null,
|
||||
now: Date,
|
||||
h: RecentHandlers,
|
||||
): void {
|
||||
for (const url of thumbUrls) URL.revokeObjectURL(url);
|
||||
thumbUrls = [];
|
||||
list.replaceChildren(...items.map((s) => row(s, s.seriesId === current, now, h)));
|
||||
shelf.replaceChildren(...items.slice(0, SHELF_SIZE).map((s) => shelfBook(s, now, h)));
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ const ROLE: Readonly<Record<CoverRole, string>> = {
|
||||
/** Results already shelved once: the shelving motion plays on a new run, not on every edit. */
|
||||
const shelved = new WeakSet<object>();
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
props: Partial<HTMLElementTagNameMap[K]> = {},
|
||||
...children: (Node | string)[]
|
||||
|
||||
@@ -226,11 +226,63 @@
|
||||
.mbseed-app .mbseed-warnings li { font-size: 12px; color: var(--mb-ink-2); padding-left: 12px; position: relative; overflow-wrap: anywhere; }
|
||||
.mbseed-app .mbseed-warnings li::before { content: ""; position: absolute; left: 0; top: 0.6em; width: 5px; height: 5px; background: var(--mb-kraft); }
|
||||
|
||||
/* ---------- history: a ruled list on the left desk (spec 002 §3.4) ---------- */
|
||||
.mbseed-app .mbseed-recent { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||
.mbseed-app .mbseed-recent-list {
|
||||
list-style: none; margin: 0; padding: 0; display: grid; max-height: 24rem; overflow: auto;
|
||||
scrollbar-width: thin; scrollbar-color: var(--mb-kraft) transparent;
|
||||
}
|
||||
.mbseed-app .mbseed-recent-list li { display: flex; margin-top: -1px; border: 1px solid var(--mb-line); }
|
||||
.mbseed-app .mbseed-recent-open {
|
||||
flex: 1; min-width: 0; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; align-items: center;
|
||||
padding: 6px 8px; border: 0; background: transparent; color: var(--mb-ink-2); font: inherit; text-align: left; cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.mbseed-app .mbseed-recent-open:hover, .mbseed-app .mbseed-recent-open[aria-current="true"] { background: var(--mb-slip); }
|
||||
.mbseed-app .mbseed-recent-list .mbseed-recent-thumb {
|
||||
display: block; width: 30px; height: 40px; object-fit: contain; background: var(--mb-page);
|
||||
outline: 1px solid var(--mb-frame); outline-offset: -1px;
|
||||
}
|
||||
.mbseed-app .mbseed-recent-text { display: grid; gap: 1px; min-width: 0; }
|
||||
.mbseed-app .mbseed-recent-title { font-size: 13px; font-weight: 700; color: var(--mb-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mbseed-app .mbseed-recent-meta { font-size: 11.5px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mbseed-app .mbseed-recent-marks { font-size: 11.5px; font-weight: 600; color: var(--mb-teal); }
|
||||
.mbseed-app .mbseed-recent-remove {
|
||||
flex: none; width: 28px; display: grid; place-items: center; padding: 0;
|
||||
border: 0; border-left: 1px solid var(--mb-line); background: transparent; color: var(--mb-ink-2); cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
.mbseed-app .mbseed-recent-remove:hover { background: var(--mb-ink); color: var(--mb-page); }
|
||||
.mbseed-app .mbseed-recent .mbseed-line:empty { display: none; }
|
||||
.mbseed-app .mbseed-recent > .is-quiet { justify-self: start; width: auto; font-size: 12.5px; color: var(--mb-ink-2); }
|
||||
|
||||
/* ---------- empty state ---------- */
|
||||
.mbseed-app .mbseed-empty { max-width: 46rem; }
|
||||
.mbseed-app .mbseed-empty h2 { font-size: clamp(22px, 3cqi, 30px); font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 10px; text-wrap: balance; }
|
||||
.mbseed-app .mbseed-empty p { color: var(--mb-ink-2); max-width: 62ch; margin: 0 0 10px; font-size: 14px; }
|
||||
.mbseed-app .mbseed-empty strong { color: var(--mb-ink); }
|
||||
/* Recently searched: the same books on the same kraft rail, smaller, each opening its history entry. */
|
||||
.mbseed-app .mbseed-recent-shelf-wrap { display: grid; gap: 14px; margin-top: 34px; }
|
||||
.mbseed-app .mbseed-recent-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); align-items: end; column-gap: 0; row-gap: 30px; }
|
||||
.mbseed-app .mbseed-recent-book {
|
||||
display: flex; flex-direction: column; justify-content: flex-end; min-width: 0; padding: 0 9px;
|
||||
border: 0; border-bottom: 10px solid var(--mb-kraft); box-shadow: 0 2px 0 var(--mb-rail);
|
||||
background: transparent; color: var(--mb-ink); font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.mbseed-app .mbseed-recent-jacket { display: block; }
|
||||
.mbseed-app .mbseed-recent-book .mbseed-recent-thumb {
|
||||
display: block; width: 100%; height: auto; max-height: 160px; object-fit: contain; object-position: bottom;
|
||||
outline: 1px solid var(--mb-frame); outline-offset: -1px; box-shadow: 0 8px 14px -8px var(--mb-shadow);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.mbseed-app .mbseed-recent-book .mbseed-recent-thumb.is-blank { height: 120px; background: var(--mb-panel); box-shadow: none; }
|
||||
.mbseed-app .mbseed-recent-book:hover .mbseed-recent-thumb { transform: translateY(-4px); }
|
||||
.mbseed-app .mbseed-recent-band {
|
||||
position: relative; margin-top: -12px; display: grid; gap: 1px; padding: 5px 7px 6px;
|
||||
background: var(--mb-slip); border: 1px solid var(--mb-line); border-top: 3px solid var(--mb-ink);
|
||||
}
|
||||
.mbseed-app .mbseed-recent-band .mbseed-recent-title { font-size: 12px; }
|
||||
.mbseed-app .mbseed-recent-band .mbseed-recent-meta { color: var(--mb-ink-2); }
|
||||
|
||||
/* ---------- review head ---------- */
|
||||
.mbseed-app .mbseed-review-head {
|
||||
@@ -242,6 +294,8 @@
|
||||
.mbseed-app .mbseed-views { margin-left: auto; display: flex; border: 1.5px solid var(--mb-ink); }
|
||||
.mbseed-app .mbseed-view { border: 0; background: transparent; color: var(--mb-ink); padding: 4px 12px; font: 700 12.5px var(--mb-sans); cursor: pointer; }
|
||||
.mbseed-app .mbseed-view[aria-pressed="true"] { background: var(--mb-ink); color: var(--mb-page); }
|
||||
.mbseed-app .mbseed-found { margin: 8px 0 0; display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; font-size: 13px; color: var(--mb-ink-2); }
|
||||
.mbseed-app .mbseed-found .is-quiet { width: auto; color: var(--mb-ink); }
|
||||
.mbseed-app .mbseed-notice {
|
||||
margin: 12px 0 0; padding: 8px 12px; font-size: 13px;
|
||||
background: color-mix(in srgb, var(--mb-obi) 12%, var(--mb-page)); border-top: 2px solid var(--mb-obi);
|
||||
|
||||
@@ -39,6 +39,13 @@ export const APP_HTML = /* html */ `
|
||||
<h3 class="mbseed-cap mbseed-notes-cap">Notes</h3>
|
||||
<ul id="warnings" class="mbseed-warnings"></ul>
|
||||
</section>
|
||||
|
||||
<section id="recent" class="mbseed-recent" hidden>
|
||||
<h3 class="mbseed-cap">History</h3>
|
||||
<ol id="recent-list" class="mbseed-recent-list"></ol>
|
||||
<p id="recent-status" class="mbseed-line" aria-live="polite"></p>
|
||||
<button id="recent-clear" class="mbseed-button is-quiet" type="button">Clear history</button>
|
||||
</section>
|
||||
<a class="mbseed-link mbseed-install" href="/mbseed.user.js">Install the userscript</a>
|
||||
</aside>
|
||||
|
||||
@@ -47,6 +54,10 @@ export const APP_HTML = /* html */ `
|
||||
<p>Paste a MangaBaka series link and the series page on its source site, then press <strong>Find covers</strong>.
|
||||
Current covers come straight from the source; older ones from the Wayback Machine.</p>
|
||||
<p>Nothing is uploaded or sent to MangaBaka until you press a button for it.</p>
|
||||
<section id="recent-shelf-wrap" class="mbseed-recent-shelf-wrap" hidden>
|
||||
<h3 class="mbseed-cap">Recently searched</h3>
|
||||
<div id="recent-shelf" class="mbseed-recent-shelf"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="review" class="mbseed-review" hidden>
|
||||
@@ -59,6 +70,10 @@ export const APP_HTML = /* html */ `
|
||||
<button type="button" class="mbseed-view" data-view="timeline" aria-pressed="false">Timeline</button>
|
||||
</div>
|
||||
</header>
|
||||
<p id="found-line" class="mbseed-found" hidden>
|
||||
<span id="found-when"></span>
|
||||
<button id="search-again" class="mbseed-button is-quiet" type="button">Search again</button>
|
||||
</p>
|
||||
<p id="mature-warning" class="mbseed-notice" hidden>The source marks this series as mature. Consider a content rating above "safe".</p>
|
||||
<p id="link-warning" class="mbseed-notice" hidden>
|
||||
This MangaBaka series has no link to this source series.
|
||||
|
||||
Reference in new issue
Block a user