songs page init
This commit is contained in:
303
src/routes/songs/+page.svelte
Normal file
303
src/routes/songs/+page.svelte
Normal file
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { useSearchParams } from "runed/kit";
|
||||
import { onMount } from "svelte";
|
||||
import { z } from "zod";
|
||||
import { browser } from "$app/environment";
|
||||
import { invalidate } from "$app/navigation";
|
||||
import SongEntry from "$lib/components/SongEntry.svelte";
|
||||
import { db as clientDb } from "$lib/db/client-db";
|
||||
import { addAllToQueue, playAllNext } from "$lib/player/player.svelte";
|
||||
import { trackFromSongRow } from "$lib/player/types";
|
||||
import { SongCategoryMap, SongTypeReverseMap } from "$lib/utils/amq";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
// Zod schema for URL search parameters to ensure type safety and parsing
|
||||
const SongsSearchSchema = z
|
||||
.object({
|
||||
q: z.string().default(""), // song name
|
||||
artist: z.string().default(""), // artist name
|
||||
anime: z.string().default(""), // anime mainName
|
||||
type: z.array(z.string()).default([]), // song type (e.g., "OP", "ED", "INS")
|
||||
gpm: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) => (s ? parseInt(s, 10) : undefined)), // global percent min
|
||||
gpx: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) => (s ? parseInt(s, 10) : undefined)), // global percent max
|
||||
cat: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) => (s ? parseInt(s, 10) : undefined)), // category
|
||||
})
|
||||
.strict();
|
||||
|
||||
const params = useSearchParams(SongsSearchSchema, {
|
||||
pushHistory: false,
|
||||
showDefaults: false,
|
||||
});
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Local form values, bound to inputs, which will update params.
|
||||
let form = $state({
|
||||
q: params.q,
|
||||
artist: params.artist,
|
||||
anime: params.anime,
|
||||
type: params.type,
|
||||
gpm: params.gpm ?? "",
|
||||
gpx: params.gpx ?? "",
|
||||
cat: params.cat ?? "",
|
||||
});
|
||||
|
||||
// If SSR returned no songRows (because client DB wasn't available),
|
||||
// re-run load on the client once the DB is ready by invalidating.
|
||||
onMount(() => {
|
||||
if (data.songRows.length > 0) return;
|
||||
|
||||
if (clientDb) {
|
||||
void invalidate("clientdb:songs");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
function songArtistLabel(r: (typeof data.songRows)[number]) {
|
||||
// Use animeMainName as the primary anime name for display
|
||||
return r.artistName ?? r.groupName ?? null;
|
||||
}
|
||||
|
||||
// Helper to map song type number back to string for UI display
|
||||
function getSongTypeLabel(type: number): string {
|
||||
return SongTypeReverseMap[type] || `Type ${type}`;
|
||||
}
|
||||
|
||||
// Helper to map song category number back to string for UI display
|
||||
function getSongCategoryLabel(category: number): string {
|
||||
return SongCategoryMap[category] || `Category ${category}`;
|
||||
}
|
||||
|
||||
const tracksFromResults = $derived.by(() =>
|
||||
data.songRows
|
||||
.map((r) =>
|
||||
trackFromSongRow({
|
||||
annSongId: r.annSongId,
|
||||
animeName: r.animeMainName, // Use animeMainName from the query result
|
||||
type: r.type,
|
||||
number: r.number,
|
||||
songName: r.songName,
|
||||
artistName: songArtistLabel(r),
|
||||
fileName: r.fileName,
|
||||
}),
|
||||
)
|
||||
.filter((t) => t !== null),
|
||||
);
|
||||
|
||||
function applyFilters() {
|
||||
params.q = form.q;
|
||||
params.artist = form.artist;
|
||||
params.anime = form.anime;
|
||||
params.type = form.type;
|
||||
// Only set gpm/gpx if they are valid numbers
|
||||
params.gpm = form.gpm !== "" ? String(form.gpm) : undefined;
|
||||
params.gpx = form.gpx !== "" ? String(form.gpx) : undefined;
|
||||
// Only set cat if it's a valid number
|
||||
params.cat = form.cat !== "" ? String(form.cat) : undefined;
|
||||
}
|
||||
|
||||
// Handle checkbox changes for song types
|
||||
function handleSongTypeChange(typeValue: string, isChecked: boolean) {
|
||||
if (isChecked) {
|
||||
form.type = [...form.type, typeValue];
|
||||
} else {
|
||||
form.type = form.type.filter((t) => t !== typeValue);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-semibold">Songs Search</h1>
|
||||
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{#if !clientDb}
|
||||
Loading DB...
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="mt-4 flex flex-col gap-4"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyFilters();
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground" for="song-name"
|
||||
>Song Name</label
|
||||
>
|
||||
<input
|
||||
id="song-name"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
placeholder="e.g. Renai Circulation"
|
||||
bind:value={form.q}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground" for="artist-name"
|
||||
>Artist Name</label
|
||||
>
|
||||
<input
|
||||
id="artist-name"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
placeholder="e.g. Kana Hanazawa"
|
||||
bind:value={form.artist}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground" for="anime-name"
|
||||
>Anime Name</label
|
||||
>
|
||||
<input
|
||||
id="anime-name"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
placeholder="e.g. Bakemonogatari"
|
||||
bind:value={form.anime}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">Song Type</label>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{#each [1, 2, 3] as typeNum}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={getSongTypeLabel(typeNum)}
|
||||
checked={form.type.includes(getSongTypeLabel(typeNum))}
|
||||
onchange={(e) =>
|
||||
handleSongTypeChange(e.target.value, e.target.checked)}
|
||||
/>
|
||||
{getSongTypeLabel(typeNum)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground" for="global-percent-min"
|
||||
>Global Percent Range</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="global-percent-min"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
class="rounded border px-3 py-2 text-sm w-1/2"
|
||||
placeholder="Min (0-100)"
|
||||
bind:value={form.gpm}
|
||||
/>
|
||||
<input
|
||||
id="global-percent-max"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
class="rounded border px-3 py-2 text-sm w-1/2"
|
||||
placeholder="Max (0-100)"
|
||||
bind:value={form.gpx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground" for="song-category"
|
||||
>Category</label
|
||||
>
|
||||
<select
|
||||
id="song-category"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
bind:value={form.cat}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{#each Object.entries(SongCategoryMap) as [key, value]}
|
||||
<option value={key}>{value}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border px-4 py-2 text-sm bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
Search Songs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if data.songRows.length > 0}
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
onclick={() => addAllToQueue(tracksFromResults)}
|
||||
disabled={tracksFromResults.length === 0}
|
||||
>
|
||||
Add all to queue
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-2 text-sm"
|
||||
onclick={() => playAllNext(tracksFromResults)}
|
||||
disabled={tracksFromResults.length === 0}
|
||||
>
|
||||
Play all next
|
||||
</button>
|
||||
|
||||
{#if tracksFromResults.length !== data.songRows.length}
|
||||
<span class="self-center text-sm text-muted-foreground">
|
||||
({tracksFromResults.length} playable)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
|
||||
|
||||
<ul class="mt-3 space-y-2">
|
||||
{#each data.songRows as r (String(r.annSongId) + "-" + String(r.animeAnnId) + "-" + String(r.type) + "-" + String(r.number))}
|
||||
<li>
|
||||
<SongEntry
|
||||
annSongId={r.annSongId}
|
||||
animeName={r.animeMainName}
|
||||
type={r.type}
|
||||
number={r.number}
|
||||
songName={r.songName}
|
||||
artistName={songArtistLabel(r)}
|
||||
fileName={r.fileName}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if Object.values(form).some((val) => (Array.isArray(val) && val.length > 0) || (typeof val === "string" && val.trim() !== "") || typeof val === "number")}
|
||||
<p class="mt-4 text-sm text-muted-foreground">
|
||||
No songs found matching your criteria.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-4 text-sm text-muted-foreground">
|
||||
Use the filters above to search for songs.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !browser}
|
||||
Loading stuff...
|
||||
{/if}
|
||||
Reference in New Issue
Block a user