[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
+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;
}