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