list page pt. 5 use load function
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
97
src/routes/list/+page.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user