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,24 +78,19 @@
<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"}
<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) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
void onSearch(); onSearch();
}} }}
> >
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-user" <label class="text-sm text-muted-foreground" for="mal-user"
@@ -225,8 +101,7 @@
class="rounded border px-3 py-2 text-sm" class="rounded border px-3 py-2 text-sm"
placeholder="e.g. CaZzzer" placeholder="e.g. CaZzzer"
value={formMal} value={formMal}
oninput={(e) => oninput={(e) => (formMal = (e.currentTarget as HTMLInputElement).value)}
(formMal = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
/> />
@@ -249,73 +124,59 @@
<option value="plan_to_watch">Plan to watch</option> <option value="plan_to_watch">Plan to watch</option>
</select> </select>
</div> </div>
</div>
<div class="flex flex-col gap-2"> <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}
Searching…
{:else}
Search Search
{/if}
</button> </button>
</div>
</div>
<div class="text-sm text-muted-foreground"> <div class="text-sm text-muted-foreground">
{#if !(malUsername ?? "").trim()}{:else} {#if !data.username}
{#if isLoadingMal} Waiting for username…
Fetching MAL list…
{:else} {:else}
MAL entries: {malEntries.length} (limited to {LIST_QUERY_LIMIT}) MAL entries: {data.malResponse?.data.length ?? 0} (limited to {LIST_QUERY_LIMIT})
• Songs found: {hydratedSongRows.length}
{/if} {/if}
{" • "}
{#if isLoadingDb}
Querying songs…
{:else}
Songs found: {songRows.length}
{/if}
{/if}
</div>
</div> </div>
{#if malUsername} {#if data.username}
<div class="text-sm"> <div class="text-sm">
<a <a
class="hover:underline" class="hover:underline"
href={makeMalHref(malUsername)} href={makeMalHref(data.username)}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
View {malUsername} on MAL View {data.username} on MAL
</a> </a>
</div> </div>
{/if} {/if}
</form> </form>
{#if (formMal ?? "").trim() && !isLoadingMal && malEntries.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 anime returned from MAL (did you set a restrictive status/sort?). No anime returned from MAL (did you set a restrictive status?).
</p> </p>
{/if} {/if}
{#if (formMal ?? "").trim() && !isLoadingDb && malEntries.length > 0 && songRows.length === 0} {#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && hydratedSongRows.length === 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 songs matched in the local database. This likely means none of the MAL
anime IDs exist in the AMQ DB. anime IDs exist in the AMQ DB.
</p> </p>
{/if} {/if}
{#if songRows.length > 0} {#if hydratedSongRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2> <h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2"> <ul class="mt-3 space-y-2">
{#each songRows as r (String(r.annId) + ":" + String(r.annSongId))} {#each hydratedSongRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li class="rounded border px-3 py-2"> <li class="rounded border px-3 py-2">
<div class="font-medium"> <div class="font-medium">
{r.songName} {r.songName}
@@ -345,11 +206,10 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if malResponse?.paging?.next} {#if data.malResponse?.paging?.next}
<p class="mt-6 text-sm text-muted-foreground"> <p class="mt-6 text-sm text-muted-foreground">
More results exist on MAL, but pagination is not wired yet. More results exist on MAL, but pagination is not wired yet.
</p> </p>
{/if}
{/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,
};
};