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">
<h1 class="text-2xl font-semibold">{data.anime.mainName}</h1>
<p class="text-sm text-muted-foreground">
{data.anime.year}{seasonName(data.anime.seasonId)} • ANN {data.anime
.annId} • MAL {data.anime.malId}
{data.anime.year}{seasonName(Number(data.anime.seasonId))} • ANN {data
.anime.annId} • MAL {data.anime.malId}
</p>
</header>

View File

@@ -1,191 +1,72 @@
<script lang="ts">
import { useSearchParams } from "runed/kit";
import { onMount } from "svelte";
import { z } from "zod";
import {
ensureSeeded,
getClientDb,
getSongsForMalAnimeIds,
} from "$lib/db/client-db";
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageData } from "./$types";
const LIST_QUERY_LIMIT = 1000;
const LIST_QUERY_LIMIT = 20;
const ListSearchSchema = MalAnimeListQuerySchema.extend({
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""),
}).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, {
pushHistory: 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 status = $state<PageStatus>("idle");
let error = $state<string | null>(null);
// Songs displayed in the UI: start with server-provided `songRows`,
// then hydrate client-side if needed.
let hydratedSongRows = $state<PageData["songRows"]>([]);
let isLoadingMal = $state(false);
let isLoadingDb = $state(false);
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();
// Keep form input synced with navigation (back/forward) and initial load.
$effect(() => {
formMal = data.username || params.mal || "";
});
// 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(() => {
if (status !== "ready") return;
if (!malUsername) return;
hydratedSongRows = data.songRows;
});
// If SSR returned no songRows (because client DB wasn't available), hydrate in browser.
$effect(() => {
void (async () => {
try {
await runSearchFor(malUsername);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
status = "error";
}
// If server already provided songs, we are done.
if (data.songRows.length > 0) return;
// If we don't have an active username or MAL response, nothing to hydrate.
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;
}
@@ -197,159 +78,138 @@
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
<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"
>/list?mal=USERNAME</code
>. This will fetch up to {LIST_QUERY_LIMIT} anime from MAL (for now) and then query
your local client DB for related songs.
Enter a MAL username in the URL as
<code class="rounded bg-muted px-1 py-0.5">/list?mal=USERNAME</code>. This
loads up to {LIST_QUERY_LIMIT} anime from MAL (for now) and then queries your local
client DB for related songs.
</p>
{#if status === "loading"}
<p class="mt-3 text-sm text-muted-foreground">Loading…</p>
{:else if status === "error"}
<p class="mt-3 text-sm text-red-600">Error: {error}</p>
{:else if status === "ready"}
<form
class="mt-4 flex flex-col gap-2 center"
onsubmit={(e) => {
e.preventDefault();
void onSearch();
}}
>
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-user"
>MAL username</label
>
<input
id="mal-user"
class="rounded border px-3 py-2 text-sm"
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>
<form
class="mt-4 flex flex-col gap-2"
onsubmit={(e) => {
e.preventDefault();
onSearch();
}}
>
<div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-user"
>MAL username</label
>
<input
id="mal-user"
class="rounded border px-3 py-2 text-sm"
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 class="flex flex-col justify-end">
<button
type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={isSearching ||
isLoadingMal ||
isLoadingDb ||
!(formMal ?? "").trim()}
disabled={!(formMal ?? "").trim()}
>
{#if isSearching || isLoadingMal || isLoadingDb}
Searching…
{:else}
Search
{/if}
Search
</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>
{#if malUsername}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(malUsername)}
target="_blank"
rel="noreferrer"
>
View {malUsername} on MAL
</a>
</div>
<div class="text-sm text-muted-foreground">
{#if !data.username}
Waiting for username…
{:else}
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {LIST_QUERY_LIMIT})
• Songs found: {hydratedSongRows.length}
{/if}
</form>
</div>
{#if (formMal ?? "").trim() && !isLoadingMal && malEntries.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status/sort?).
</p>
{#if data.username}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if}
</form>
{#if (formMal ?? "").trim() && !isLoadingDb && malEntries.length > 0 && songRows.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
</p>
{/if}
{#if songRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each songRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li class="rounded border px-3 py-2">
<div class="font-medium">
{r.songName}
{#if songArtistLabel(r)}
<span class="text-sm text-muted-foreground">
{songArtistLabel(r)}</span
>
{/if}
</div>
<div class="text-sm text-muted-foreground">
{r.animeName} • ANN {r.annId} • MAL {r.malId} • link type {r.type} #{r.number}
</div>
{#if r.fileName}
<div class="mt-2 text-sm">
<a
class="hover:underline"
href={"https://nawdist.animemusicquiz.com/" + r.fileName}
target="_blank"
rel="noreferrer"
>
Audio file
</a>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{#if 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 (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status?).
</p>
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && hydratedSongRows.length === 0}
<p class="mt-4 text-sm text-muted-foreground">
No songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB.
</p>
{/if}
{#if hydratedSongRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each hydratedSongRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li class="rounded border px-3 py-2">
<div class="font-medium">
{r.songName}
{#if songArtistLabel(r)}
<span class="text-sm text-muted-foreground">
{songArtistLabel(r)}</span
>
{/if}
</div>
<div class="text-sm text-muted-foreground">
{r.animeName} • ANN {r.annId} • MAL {r.malId} • link type {r.type} #{r.number}
</div>
{#if r.fileName}
<div class="mt-2 text-sm">
<a
class="hover:underline"
href={"https://nawdist.animemusicquiz.com/" + r.fileName}
target="_blank"
rel="noreferrer"
>
Audio file
</a>
</div>
{/if}
</li>
{/each}
</ul>
{/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}

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,
};
};