list page pt. 5 use load function

This commit is contained in:
2026-02-05 22:09:42 -08:00
parent b282b824ed
commit 61dcd2e173
3 changed files with 256 additions and 299 deletions

View File

@@ -90,8 +90,8 @@
<header class="mt-2 space-y-1"> <header class="mt-2 space-y-1">
<h1 class="text-2xl font-semibold">{data.anime.mainName}</h1> <h1 class="text-2xl font-semibold">{data.anime.mainName}</h1>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{data.anime.year}{seasonName(data.anime.seasonId)} • ANN {data.anime {data.anime.year}{seasonName(Number(data.anime.seasonId))} • ANN {data
.annId} • MAL {data.anime.malId} .anime.annId} • MAL {data.anime.malId}
</p> </p>
</header> </header>

View File

@@ -1,191 +1,72 @@
<script lang="ts"> <script lang="ts">
import { useSearchParams } from "runed/kit"; import { useSearchParams } from "runed/kit";
import { onMount } from "svelte";
import { z } from "zod"; import { z } from "zod";
import { import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
ensureSeeded,
getClientDb,
getSongsForMalAnimeIds,
} from "$lib/db/client-db";
import { import {
MalAnimeListQuerySchema, MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum, MalAnimeListStatusEnum,
} from "$lib/types/mal"; } from "$lib/types/mal";
import type { PageData } from "./$types";
const LIST_QUERY_LIMIT = 1000; const LIST_QUERY_LIMIT = 20;
const ListSearchSchema = MalAnimeListQuerySchema.extend({ const ListSearchSchema = MalAnimeListQuerySchema.extend({
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""), status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""), mal: z.string().default(""),
}).strict(); }).strict();
type PageStatus = "idle" | "loading" | "ready" | "error";
// This is the URL-backed state. `mal` only updates when you press Search.
// `status` can auto-update and re-run queries without pressing Search again.
const params = useSearchParams(ListSearchSchema, { const params = useSearchParams(ListSearchSchema, {
pushHistory: false, pushHistory: false,
showDefaults: false, showDefaults: false,
}); });
// Local form state (username does NOT update the URL on change) let { data }: { data: PageData } = $props();
// Local username field that does NOT update the URL as you type.
let formMal = $state<string>(""); let formMal = $state<string>("");
let status = $state<PageStatus>("idle"); // Songs displayed in the UI: start with server-provided `songRows`,
let error = $state<string | null>(null); // then hydrate client-side if needed.
let hydratedSongRows = $state<PageData["songRows"]>([]);
let isLoadingMal = $state(false); // Keep form input synced with navigation (back/forward) and initial load.
let isLoadingDb = $state(false); $effect(() => {
formMal = data.username || params.mal || "";
let isSearching = $state(false);
type MalListResponse = z.infer<typeof MalAnimeListResponseSchema>;
type MalEntry = MalListResponse["data"][number];
let malResponse = $state<MalListResponse | null>(null);
let malEntries = $state<MalEntry[]>([]);
let malUsername = $state<string | null>(null);
type SongRow = Awaited<ReturnType<typeof getSongsForMalAnimeIds>>[number];
let songRows = $state<SongRow[]>([]);
function clearResults() {
malResponse = null;
malEntries = [];
malUsername = null;
songRows = [];
}
async function runSearchFor(username: string) {
const u = username.trim();
if (!u) {
clearResults();
return;
}
isSearching = true;
try {
await loadAllFor(u);
} finally {
isSearching = false;
}
}
async function onSearch() {
// Sync username -> URL only on Search
params.mal = formMal;
await runSearchFor(formMal);
}
/**
* Fetch MAL animelist (limited to LIST_QUERY_LIMIT for now).
* Uses zod schema parsing to avoid redefining types.
*/
async function fetchMalAnimeList(username: string) {
const u = username.trim();
if (!u) return null;
// Always limit to LIST_QUERY_LIMIT for now; we'll add pagination later.
const upstream = new URL(
`/api/mal/animelist/${encodeURIComponent(u)}`,
window.location.origin,
);
// Forward optional query params (status/sort/offset) while forcing limit=LIST_QUERY_LIMIT
if (params.status) upstream.searchParams.set("status", params.status);
if (params.sort) upstream.searchParams.set("sort", params.sort);
if (params.offset !== undefined)
upstream.searchParams.set("offset", String(params.offset));
upstream.searchParams.set("limit", LIST_QUERY_LIMIT.toString());
const res = await fetch(upstream);
if (!res.ok) {
const msg =
res.status === 404
? "MAL user not found"
: res.status === 401 || res.status === 403
? "MAL auth failed"
: `MAL request failed (${res.status})`;
throw new Error(msg);
}
const json = await res.json();
return MalAnimeListResponseSchema.parse(json);
}
async function loadAllFor(username: string | undefined) {
const u = (username ?? "").trim();
if (!u) {
clearResults();
return;
}
error = null;
// MAL fetch
isLoadingMal = true;
try {
const r = await fetchMalAnimeList(u);
malResponse = r;
malEntries = r?.data ?? [];
malUsername = u;
} finally {
isLoadingMal = false;
}
// DB query
isLoadingDb = true;
try {
await ensureSeeded();
const { db } = getClientDb();
const malIds = (malResponse?.data ?? []).map((e) => e.node.id);
songRows = await getSongsForMalAnimeIds(db, malIds);
} finally {
isLoadingDb = false;
}
}
async function loadInitial() {
status = "loading";
error = null;
try {
// Initialize local form username from URL once on load
formMal = params.mal ?? "";
// If the URL already has a username, load immediately (no URL writes)
await loadAllFor(formMal);
status = "ready";
} catch (e) {
error = e instanceof Error ? e.message : String(e);
status = "error";
}
}
onMount(() => {
void loadInitial();
}); });
// Auto re-run the search whenever status changes *as long as we have a loaded username*. // Keep displayed songs synced to the latest `data` (avoid stale capture).
$effect(() => { $effect(() => {
if (status !== "ready") return; hydratedSongRows = data.songRows;
if (!malUsername) return; });
// If SSR returned no songRows (because client DB wasn't available), hydrate in browser.
$effect(() => {
void (async () => { void (async () => {
try { // If server already provided songs, we are done.
await runSearchFor(malUsername); if (data.songRows.length > 0) return;
} catch (e) {
error = e instanceof Error ? e.message : String(e); // If we don't have an active username or MAL response, nothing to hydrate.
status = "error"; if (!data.username) return;
} if (!data.malResponse) return;
// If client DB isn't ready (SSR) we'll re-run once it exists (module init).
if (!db) return;
await ensureSeeded();
const malIds = data.malResponse.data.map((e) => e.node.id);
hydratedSongRows = await getSongsForMalAnimeIds(db, malIds);
})(); })();
}); });
function songArtistLabel(r: SongRow) { function onSearch() {
// Only update username param on explicit Search
params.mal = formMal;
}
function songArtistLabel(r: (typeof hydratedSongRows)[number]) {
return r.artistName ?? r.groupName ?? null; return r.artistName ?? r.groupName ?? null;
} }
@@ -197,159 +78,138 @@
<h1 class="text-2xl font-semibold">MAL List → Songs</h1> <h1 class="text-2xl font-semibold">MAL List → Songs</h1>
<p class="mt-2 text-sm text-muted-foreground"> <p class="mt-2 text-sm text-muted-foreground">
Enter a MAL username in the URL as <code class="rounded bg-muted px-1 py-0.5" Enter a MAL username in the URL as
>/list?mal=USERNAME</code <code class="rounded bg-muted px-1 py-0.5">/list?mal=USERNAME</code>. This
>. This will fetch up to {LIST_QUERY_LIMIT} anime from MAL (for now) and then query loads up to {LIST_QUERY_LIMIT} anime from MAL (for now) and then queries your local
your local client DB for related songs. client DB for related songs.
</p> </p>
{#if status === "loading"} <form
<p class="mt-3 text-sm text-muted-foreground">Loading…</p> class="mt-4 flex flex-col gap-2"
{:else if status === "error"} onsubmit={(e) => {
<p class="mt-3 text-sm text-red-600">Error: {error}</p> e.preventDefault();
{:else if status === "ready"} onSearch();
<form }}
class="mt-4 flex flex-col gap-2 center" >
onsubmit={(e) => { <div class="flex flex-wrap gap-2">
e.preventDefault(); <div class="flex flex-col gap-2">
void onSearch(); <label class="text-sm text-muted-foreground" for="mal-user"
}} >MAL username</label
> >
<div class="flex flex-wrap gap-2"> <input
<div class="flex flex-col gap-2"> id="mal-user"
<label class="text-sm text-muted-foreground" for="mal-user" class="rounded border px-3 py-2 text-sm"
>MAL username</label placeholder="e.g. CaZzzer"
> value={formMal}
<input oninput={(e) => (formMal = (e.currentTarget as HTMLInputElement).value)}
id="mal-user" autocomplete="off"
class="rounded border px-3 py-2 text-sm" spellcheck={false}
placeholder="e.g. CaZzzer" />
value={formMal}
oninput={(e) =>
(formMal = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off"
spellcheck={false}
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
<div class="flex flex-col justify-end">
<button <button
type="submit" type="submit"
class="rounded border px-3 py-2 text-sm" class="rounded border px-3 py-2 text-sm"
disabled={isSearching || disabled={!(formMal ?? "").trim()}
isLoadingMal ||
isLoadingDb ||
!(formMal ?? "").trim()}
> >
{#if isSearching || isLoadingMal || isLoadingDb} Search
Searching…
{:else}
Search
{/if}
</button> </button>
<div class="text-sm text-muted-foreground">
{#if !(malUsername ?? "").trim()}{:else}
{#if isLoadingMal}
Fetching MAL list…
{:else}
MAL entries: {malEntries.length} (limited to {LIST_QUERY_LIMIT})
{/if}
{" • "}
{#if isLoadingDb}
Querying songs…
{:else}
Songs found: {songRows.length}
{/if}
{/if}
</div>
</div> </div>
</div>
{#if malUsername} <div class="text-sm text-muted-foreground">
<div class="text-sm"> {#if !data.username}
<a Waiting for username…
class="hover:underline" {:else}
href={makeMalHref(malUsername)} MAL entries: {data.malResponse?.data.length ?? 0} (limited to {LIST_QUERY_LIMIT})
target="_blank" • Songs found: {hydratedSongRows.length}
rel="noreferrer"
>
View {malUsername} on MAL
</a>
</div>
{/if} {/if}
</form> </div>
{#if (formMal ?? "").trim() && !isLoadingMal && malEntries.length === 0} {#if data.username}
<p class="mt-4 text-sm text-muted-foreground"> <div class="text-sm">
No anime returned from MAL (did you set a restrictive status/sort?). <a
</p> class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if} {/if}
</form>
{#if (formMal ?? "").trim() && !isLoadingDb && malEntries.length > 0 && songRows.length === 0} {#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground"> <p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL No anime returned from MAL (did you set a restrictive status?).
anime IDs exist in the AMQ DB. </p>
</p> {/if}
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && hydratedSongRows.length === 0}
{#if songRows.length > 0} <p class="mt-4 text-sm text-muted-foreground">
<h2 class="mt-6 text-lg font-semibold">Songs</h2> No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
<ul class="mt-3 space-y-2"> </p>
{#each songRows as r (String(r.annId) + ":" + String(r.annSongId))} {/if}
<li class="rounded border px-3 py-2">
<div class="font-medium"> {#if hydratedSongRows.length > 0}
{r.songName} <h2 class="mt-6 text-lg font-semibold">Songs</h2>
{#if songArtistLabel(r)}
<span class="text-sm text-muted-foreground"> <ul class="mt-3 space-y-2">
{songArtistLabel(r)}</span {#each hydratedSongRows as r (String(r.annId) + ":" + String(r.annSongId))}
> <li class="rounded border px-3 py-2">
{/if} <div class="font-medium">
</div> {r.songName}
{#if songArtistLabel(r)}
<div class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{r.animeName} • ANN {r.annId} • MAL {r.malId} • link type {r.type} #{r.number} {songArtistLabel(r)}</span
</div> >
{/if}
{#if r.fileName} </div>
<div class="mt-2 text-sm">
<a <div class="text-sm text-muted-foreground">
class="hover:underline" {r.animeName} • ANN {r.annId} • MAL {r.malId} • link type {r.type} #{r.number}
href={"https://nawdist.animemusicquiz.com/" + r.fileName} </div>
target="_blank"
rel="noreferrer" {#if r.fileName}
> <div class="mt-2 text-sm">
Audio file <a
</a> class="hover:underline"
</div> href={"https://nawdist.animemusicquiz.com/" + r.fileName}
{/if} target="_blank"
</li> rel="noreferrer"
{/each} >
</ul> Audio file
{/if} </a>
</div>
{#if malResponse?.paging?.next} {/if}
<p class="mt-6 text-sm text-muted-foreground"> </li>
More results exist on MAL, but pagination is not wired yet. {/each}
</p> </ul>
{/if} {/if}
{#if data.malResponse?.paging?.next}
<p class="mt-6 text-sm text-muted-foreground">
More results exist on MAL, but pagination is not wired yet.
</p>
{/if} {/if}

97
src/routes/list/+page.ts Normal file
View File

@@ -0,0 +1,97 @@
import { z } from "zod";
// Import client-db index directly as requested.
// On the server, `db` will be null (because `browser` is false in that module).
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageLoad } from "./$types";
const MAL_LIMIT = 20;
const SearchSchema = MalAnimeListQuerySchema.extend({
// Username
mal: z.string().optional(),
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
}).strict();
type StatusParam = z.infer<typeof SearchSchema>["status"];
function normalizeStatus(
status: StatusParam,
): z.infer<typeof MalAnimeListStatusEnum> | undefined {
if (status == null || status === "") return undefined;
return status;
}
export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("mal:animelist");
depends("clientdb:songs");
const parsed = SearchSchema.safeParse(
Object.fromEntries(url.searchParams.entries()),
);
const mal = parsed.success ? parsed.data.mal : undefined;
const status = parsed.success
? normalizeStatus(parsed.data.status)
: undefined;
const username = (mal ?? "").trim();
// Always return a stable shape for hydration
if (!username) {
return {
username: "",
status: status ?? null,
malResponse: null as z.infer<typeof MalAnimeListResponseSchema> | null,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// This endpoint proxies MAL and works server-side.
const malUrl = new URL(
`/api/mal/animelist/${encodeURIComponent(username)}`,
url.origin,
);
malUrl.searchParams.set("limit", String(MAL_LIMIT));
if (status) malUrl.searchParams.set("status", status);
// NOTE: If you later want to support sort/offset, add them here from SearchSchema too.
const malRes = await fetch(malUrl);
if (!malRes.ok) {
// Let +page.svelte decide how to display errors; throw to use SvelteKit error page
throw new Error(`MAL request failed (${malRes.status})`);
}
const malJson: unknown = await malRes.json();
const malResponse = MalAnimeListResponseSchema.parse(malJson);
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
if (!db) {
return {
username,
status: status ?? null,
malResponse,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// Browser path: seed then query local DB for songs by MAL ids
await ensureSeeded();
const malIds = malResponse.data.map((e) => e.node.id);
const songRows = await getSongsForMalAnimeIds(db, malIds);
return {
username,
status: status ?? null,
malResponse,
songRows,
};
};