[V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit)

This commit is contained in:
Youwes09
2026-02-23 22:40:00 -06:00
parent fb82abaf21
commit 523fb40538
19 changed files with 3096 additions and 983 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Session-level request cache.
*
* Key design decisions:
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
* - On real errors the entry is evicted so the next call retries.
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
*/
const store = new Map<string, Promise<unknown>>();
const subs = new Map<string, Set<() => void>>();
export const cache = {
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (!store.has(key)) {
store.set(key, fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}));
}
return store.get(key) as Promise<T>;
},
has(key: string): boolean { return store.has(key); },
clear(key: string) {
store.delete(key);
subs.get(key)?.forEach((cb) => cb());
},
clearAll() {
store.clear();
subs.forEach((set) => set.forEach((cb) => cb()));
},
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb);
return () => subs.get(key)?.delete(cb);
},
};
// ── Cache key constants — single source of truth, prevents mismatches ─────────
export const CACHE_KEYS = {
LIBRARY: "library",
SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
} as const;
// ── In-flight request deduplication (for non-cached calls) ────────────────────
//
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
// cache but still get fired multiple times when a user rapidly opens/closes a
// manga. This map deduplicates them so only one network round-trip is active at
// a time per key — regardless of how many components request it simultaneously.
//
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const p = fetcher().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// ── Source frecency helpers ────────────────────────────────────────────────────
const FRECENCY_KEY = "moku-source-frecency";
const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap {
try {
const raw = localStorage.getItem(FRECENCY_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
}
export function recordSourceAccess(sourceId: string) {
if (!sourceId || sourceId === "0") return;
const map = loadFrecency();
map[sourceId] = (map[sourceId] ?? 0) + 1;
saveFrecency(map);
}
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
const map = loadFrecency();
const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 }));
const hasFrecency = withScore.some((x) => x.score > 0);
if (hasFrecency) {
return withScore
.sort((a, b) => b.score - a.score)
.slice(0, MAX_FRECENCY_SOURCES)
.map((x) => x.s);
}
return sources.slice(0, MAX_FRECENCY_SOURCES);
}
+53 -14
View File
@@ -1,7 +1,6 @@
const DEFAULT_URL = "http://127.0.0.1:4567";
function getServerUrl(): string {
// Read from persisted Zustand store if available, fall back to default
try {
const raw = localStorage.getItem("moku-store");
if (raw) {
@@ -26,15 +25,55 @@ interface GQLResponse<T> {
errors?: { message: string }[];
}
// Retry with exponential backoff — Suwayomi may not be ready on first load
async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise<Response> {
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
const timer = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
/**
* Retry wrapper with these guarantees:
* 1. AbortErrors always propagate immediately — no retry, no delay.
* 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang.
* 3. If the signal is already aborted before we even start, we bail instantly.
*/
async function fetchWithRetry(
url: string,
init: RequestInit,
signal?: AbortSignal,
retries = 3,
delayMs = 300,
): Promise<Response> {
// Bail immediately if already aborted before we start
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
for (let i = 0; i < retries; i++) {
// Check abort at the top of every iteration
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
try {
const res = await fetch(url, init);
const res = await fetch(url, { ...init, signal });
// Check abort again — fetch can return a response even after abort in some runtimes
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return res;
} catch (e) {
} catch (e: any) {
// Never retry aborted requests
const isAbort = e?.name === "AbortError" || signal?.aborted;
if (isAbort) throw new DOMException("Aborted", "AbortError");
// Last retry — give up
if (i === retries - 1) throw e;
await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i)));
// Abort-aware delay between retries
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
}
}
throw new Error("unreachable");
@@ -42,23 +81,23 @@ async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delay
export async function gql<T>(
query: string,
variables?: Record<string, unknown>
variables?: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const res = await fetchWithRetry(gqlUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
}, signal);
if (!res.ok) {
throw new Error(`Suwayomi HTTP ${res.status}`);
}
// Check abort before reading the body — avoids hanging on res.json() after cancel
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json();
if (json.errors?.length) {
throw new Error(json.errors[0].message);
}
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
+46
View File
@@ -0,0 +1,46 @@
import type { Source } from "./types";
/**
* Deduplicates sources by name, preferring the given language.
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately.
*/
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
const byName = new Map<string, Source[]>();
for (const src of sources) {
if (src.id === "0") continue;
if (!byName.has(src.name)) byName.set(src.name, []);
byName.get(src.name)!.push(src);
}
const picked: Source[] = [];
for (const group of byName.values()) {
const preferred = group.find((s) => s.lang === preferredLang);
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
}
return picked;
}
/**
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
* This eliminates the same series appearing from multiple sources in grids.
*/
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const m of items) {
const key = m.title.toLowerCase().trim();
if (!seen.has(key)) { seen.add(key); out.push(m); }
}
return out;
}
/**
* Deduplicates manga by id only (lossless — use when sources are already deduped).
*/
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
const seen = new Set<number>();
const out: T[] = [];
for (const m of items) {
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
}
return out;
}