Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
144 lines
5.1 KiB
Svelte
144 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import { library } from "$lib/stores/library";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/stores";
|
|
import Search from "$lib/components/Search.svelte";
|
|
import SearchResults from "$lib/components/search/SearchResults.svelte";
|
|
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
|
import {
|
|
parseSearchScope,
|
|
searchRouteUrl,
|
|
seedFromSearchUrl,
|
|
type SearchScope,
|
|
type SearchSeed,
|
|
} from "$lib/utils/searchScope";
|
|
import { episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
// `?q=` / `?scope=` seed the page so the header search bar can hand a query
|
|
// over by navigating here — /search is the only surface that renders results,
|
|
// so every other search affordance routes into it.
|
|
// TRACES: UR-049 | DR-063
|
|
let searchQuery = $state($page.url.searchParams.get("q") ?? "");
|
|
let scope = $state<SearchScope>(parseSearchScope($page.url.searchParams.get("scope")));
|
|
|
|
// The URL is *consumed*, not continuously reconciled: `applied` records the
|
|
// seed already taken, so the user's typing and chip picks stand until a real
|
|
// navigation brings a different one. It is a plain `let` on purpose — as
|
|
// `$state` it would be a dependency of the effect that writes it and the
|
|
// effect would re-run itself.
|
|
//
|
|
// Reading `$library.searchQuery` here instead (the previous shape) made every
|
|
// search re-run this effect, which then re-asserted the URL's query over
|
|
// whatever had been typed since — the input snapped back a keystroke later.
|
|
// TRACES: UR-049 | DR-063, DR-064, DR-147
|
|
let applied: SearchSeed | null = null;
|
|
|
|
$effect(() => {
|
|
const seed = seedFromSearchUrl($page.url.searchParams, applied);
|
|
if (!seed) return;
|
|
applied = seed;
|
|
searchQuery = seed.query;
|
|
scope = seed.scope;
|
|
if (seed.query.trim()) {
|
|
library.search(seed.query, seed.scope);
|
|
} else {
|
|
library.clearSearch();
|
|
}
|
|
});
|
|
|
|
async function handleSearch(query: string) {
|
|
if (query.trim()) {
|
|
await library.search(query, scope);
|
|
} else {
|
|
library.clearSearch();
|
|
}
|
|
}
|
|
|
|
// Chips write the scope to the URL so the header bar (which searches with
|
|
// whatever scope the URL carries) and this page cannot disagree about it.
|
|
// Applied optimistically as well, so the chip highlights on click rather than
|
|
// a navigation later.
|
|
// TRACES: UR-049 | DR-064, DR-147
|
|
async function handleScopeChange(next: SearchScope) {
|
|
scope = next;
|
|
applied = { query: searchQuery, scope: next };
|
|
await goto(searchRouteUrl(searchQuery, next), {
|
|
replaceState: true,
|
|
keepFocus: true,
|
|
noScroll: true,
|
|
});
|
|
if (searchQuery.trim()) {
|
|
await library.search(searchQuery, next);
|
|
}
|
|
}
|
|
|
|
function handleItemClick(item: MediaItem) {
|
|
switch (item.type) {
|
|
case "Audio":
|
|
// Navigate to album page if available, otherwise detail page
|
|
if (item.albumId) {
|
|
goto(`/library/${item.albumId}`);
|
|
} else {
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
break;
|
|
case "MusicAlbum":
|
|
case "MusicArtist":
|
|
case "Series":
|
|
case "Movie":
|
|
// Navigate to detail page to show metadata and cast
|
|
goto(`/library/${item.id}`);
|
|
break;
|
|
case "Episode":
|
|
// Tap opens, it does not play (ux-flows §5B.1/§5B.5) — a search hit
|
|
// went straight to the player, so it was the one episode entry point
|
|
// with no way to reach download, cast or the rest of the episode.
|
|
// TRACES: UR-058 | DR-142
|
|
goto(episodeFocusHref(item));
|
|
break;
|
|
default:
|
|
goto(`/library/${item.id}`);
|
|
break;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="max-w-6xl mx-auto">
|
|
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
|
|
|
<!-- Search input. On md+ the header bar owns the input (it is present on this
|
|
route too), so rendering one here as well would put two search boxes on
|
|
the same screen; below md the header has none and this is the only one. -->
|
|
<div class="mb-6 space-y-3">
|
|
<div class="md:hidden">
|
|
<Search
|
|
bind:value={searchQuery}
|
|
placeholder="Search your library..."
|
|
onSearch={handleSearch}
|
|
/>
|
|
</div>
|
|
<SearchScopeChips {scope} onChange={handleScopeChange} />
|
|
</div>
|
|
|
|
<!-- Search Results -->
|
|
{#if searchQuery.trim()}
|
|
<SearchResults
|
|
results={$library.searchResults}
|
|
loading={$library.loadingCount > 0}
|
|
{scope}
|
|
onItemClick={handleItemClick}
|
|
/>
|
|
{:else}
|
|
<div class="text-center text-gray-400 mt-12">
|
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
|
|
/>
|
|
</svg>
|
|
<p>Search your entire library</p>
|
|
<p class="text-sm text-gray-500 mt-2">Find music, movies, shows, and more</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|