132 lines
3.1 KiB
Svelte
132 lines
3.1 KiB
Svelte
<script lang="ts">
|
|
import { Debounced } from "runed";
|
|
import { useSearchParams } from "runed/kit";
|
|
import { onMount } from "svelte";
|
|
import {
|
|
ensureSeeded,
|
|
getAnimeList,
|
|
getClientDb,
|
|
searchAnimeByName,
|
|
} from "$lib/db/client-db";
|
|
import { AmqBrowseSearchSchema } from "$lib/types/search/amq-browse";
|
|
import { seasonName } from "$lib/utils/amq";
|
|
|
|
const params = useSearchParams(AmqBrowseSearchSchema, {
|
|
debounce: 250,
|
|
pushHistory: false,
|
|
showDefaults: false,
|
|
});
|
|
|
|
let status = $state<"idle" | "loading" | "ready" | "error">("idle");
|
|
let error = $state<string | null>(null);
|
|
|
|
let isSearching = $state(false);
|
|
|
|
// Debounce the actual DB query updates (separate from URL debounce)
|
|
const debouncedQuery = new Debounced(() => params.q, 250);
|
|
|
|
type AnimeItem = Awaited<ReturnType<typeof getAnimeList>>[number];
|
|
let anime = $state<AnimeItem[]>([]);
|
|
|
|
async function loadListFor(query: string) {
|
|
const q = query.trim();
|
|
|
|
try {
|
|
isSearching = true;
|
|
|
|
const { db } = await getClientDb();
|
|
|
|
if (!q) {
|
|
anime = await getAnimeList(db, 20);
|
|
} else {
|
|
anime = await searchAnimeByName(db, q, 20);
|
|
}
|
|
} finally {
|
|
isSearching = false;
|
|
}
|
|
}
|
|
|
|
function animeHref(annId: number) {
|
|
return `/anime/${annId}`;
|
|
}
|
|
|
|
async function loadInitial() {
|
|
status = "loading";
|
|
error = null;
|
|
|
|
try {
|
|
await ensureSeeded();
|
|
await loadListFor(params.q);
|
|
status = "ready";
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
status = "error";
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
void loadInitial();
|
|
});
|
|
|
|
// React to debounced query changes (URL updates + user typing)
|
|
$effect(() => {
|
|
if (status !== "ready") return;
|
|
|
|
// Track debounced query to avoid hammering the DB while typing
|
|
const q = debouncedQuery.current;
|
|
|
|
void (async () => {
|
|
try {
|
|
await loadListFor(q);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
status = "error";
|
|
}
|
|
})();
|
|
});
|
|
</script>
|
|
|
|
<h1 class="text-2xl font-semibold">AMQ Browser</h1>
|
|
|
|
{#if status === "loading"}
|
|
<p class="mt-3 text-sm text-muted-foreground">Loading client database…</p>
|
|
{:else if status === "error"}
|
|
<p class="mt-3 text-sm text-red-600">Error: {error}</p>
|
|
{:else if status === "ready"}
|
|
<div class="mt-3 flex flex-col gap-2">
|
|
<label class="text-sm text-muted-foreground" for="anime-search">
|
|
Search anime
|
|
</label>
|
|
<input
|
|
id="anime-search"
|
|
class="rounded border px-3 py-2 text-sm"
|
|
placeholder="Type to search by name…"
|
|
value={params.q}
|
|
oninput={(e) => (params.q = (e.currentTarget as HTMLInputElement).value)}
|
|
autocomplete="off"
|
|
spellcheck={false}
|
|
/>
|
|
<p class="text-sm text-muted-foreground">
|
|
{#if isSearching}
|
|
Searching…
|
|
{:else}
|
|
Showing {anime.length} anime
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<ul class="mt-4 space-y-2">
|
|
{#each anime as a (a.annId)}
|
|
<li class="rounded border px-3 py-2">
|
|
<a class="font-medium hover:underline" href={animeHref(a.annId)}>
|
|
{a.mainName}
|
|
</a>
|
|
<div class="text-sm text-muted-foreground">
|
|
{a.year}
|
|
{seasonName(a.seasonId)} • ANN {a.annId} • MAL {a.malId}
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|