list page pt. 4 better search

This commit is contained in:
2026-02-05 20:32:53 -08:00
parent 90671ee962
commit b282b824ed

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { Debounced } from "runed";
import { useSearchParams } from "runed/kit"; import { useSearchParams } from "runed/kit";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { z } from "zod"; import { z } from "zod";
@@ -23,19 +22,23 @@
type PageStatus = "idle" | "loading" | "ready" | "error"; 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 formMal = $state<string>("");
let status = $state<PageStatus>("idle"); let status = $state<PageStatus>("idle");
let error = $state<string | null>(null); let error = $state<string | null>(null);
let isLoadingMal = $state(false); let isLoadingMal = $state(false);
let isLoadingDb = $state(false); let isLoadingDb = $state(false);
// Keep MAL calls snappy if you later add more query params; for now it's mostly future-proofing. let isSearching = $state(false);
const debouncedMalUser = new Debounced(() => params.mal ?? "", 1000);
type MalListResponse = z.infer<typeof MalAnimeListResponseSchema>; type MalListResponse = z.infer<typeof MalAnimeListResponseSchema>;
type MalEntry = MalListResponse["data"][number]; type MalEntry = MalListResponse["data"][number];
@@ -47,6 +50,35 @@
type SongRow = Awaited<ReturnType<typeof getSongsForMalAnimeIds>>[number]; type SongRow = Awaited<ReturnType<typeof getSongsForMalAnimeIds>>[number];
let songRows = $state<SongRow[]>([]); 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). * Fetch MAL animelist (limited to LIST_QUERY_LIMIT for now).
* Uses zod schema parsing to avoid redefining types. * Uses zod schema parsing to avoid redefining types.
@@ -86,10 +118,7 @@
async function loadAllFor(username: string | undefined) { async function loadAllFor(username: string | undefined) {
const u = (username ?? "").trim(); const u = (username ?? "").trim();
if (!u) { if (!u) {
malResponse = null; clearResults();
malEntries = [];
malUsername = null;
songRows = [];
return; return;
} }
@@ -124,7 +153,12 @@
error = null; error = null;
try { try {
await loadAllFor(params.mal); // 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"; status = "ready";
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
@@ -136,14 +170,14 @@
void loadInitial(); void loadInitial();
}); });
// Auto re-run the search whenever status changes *as long as we have a loaded username*.
$effect(() => { $effect(() => {
if (status !== "ready") return; if (status !== "ready") return;
if (!malUsername) return;
const u = debouncedMalUser.current;
void (async () => { void (async () => {
try { try {
await loadAllFor(u); await runSearchFor(malUsername);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
status = "error"; status = "error";
@@ -174,8 +208,14 @@
{:else if status === "error"} {:else if status === "error"}
<p class="mt-3 text-sm text-red-600">Error: {error}</p> <p class="mt-3 text-sm text-red-600">Error: {error}</p>
{:else if status === "ready"} {:else if status === "ready"}
<form class="mt-4 flex flex-col gap-2"> <form
<div class="flex gap-2"> 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"> <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"
>MAL username</label >MAL username</label
@@ -184,9 +224,9 @@
id="mal-user" id="mal-user"
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={params.mal ?? ""} value={formMal}
oninput={(e) => oninput={(e) =>
(params.mal = (e.currentTarget as HTMLInputElement).value)} (formMal = (e.currentTarget as HTMLInputElement).value)}
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
/> />
@@ -199,10 +239,7 @@
<select <select
id="mal-status" id="mal-status"
class="rounded border px-3 py-2 text-sm" class="rounded border px-3 py-2 text-sm"
value={params.status} bind:value={params.status}
onchange={(e) =>
(params.status = (e.currentTarget as HTMLSelectElement)
.value as typeof params.status)}
> >
<option value="">All</option> <option value="">All</option>
<option value="watching">Watching</option> <option value="watching">Watching</option>
@@ -214,10 +251,24 @@
</div> </div>
</div> </div>
<div class="text-sm text-muted-foreground"> <div class="flex flex-col gap-2">
{#if !(params.mal ?? "").trim()} <button
Waiting for username… type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={isSearching ||
isLoadingMal ||
isLoadingDb ||
!(formMal ?? "").trim()}
>
{#if isSearching || isLoadingMal || isLoadingDb}
Searching…
{:else} {:else}
Search
{/if}
</button>
<div class="text-sm text-muted-foreground">
{#if !(malUsername ?? "").trim()}{:else}
{#if isLoadingMal} {#if isLoadingMal}
Fetching MAL list… Fetching MAL list…
{:else} {:else}
@@ -231,6 +282,7 @@
{/if} {/if}
{/if} {/if}
</div> </div>
</div>
{#if malUsername} {#if malUsername}
<div class="text-sm"> <div class="text-sm">
@@ -246,16 +298,16 @@
{/if} {/if}
</form> </form>
{#if (params.mal ?? "").trim() && !isLoadingMal && malEntries.length === 0} {#if (formMal ?? "").trim() && !isLoadingMal && malEntries.length === 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/sort?).
</p> </p>
{/if} {/if}
{#if (params.mal ?? "").trim() && !isLoadingDb && malEntries.length > 0 && songRows.length === 0} {#if (formMal ?? "").trim() && !isLoadingDb && malEntries.length > 0 && songRows.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 snapshot DB. anime IDs exist in the AMQ DB.
</p> </p>
{/if} {/if}