Typing in the desktop header search bar ran library.search() in place and relied on /library rendering the results inline. On every other /library/** route nothing rendered them, so the search bar looked broken: results were fetched and never shown. Make /search the single surface that renders results. The header bar becomes a navigator — it hands the query and route-derived scope to /search via ?q= and ?scope=, which seed the page and run the search on arrival. The inline result block and the header's scope chips are removed; the chips live on /search, which owns the results. The empty `all` scope is omitted from the URL, and typing while already on /search does not push a history entry per keystroke.
114 lines
3.9 KiB
Svelte
114 lines
3.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/stores";
|
|
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
|
|
import { library } from "$lib/stores/library";
|
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
|
import Search from "$lib/components/Search.svelte";
|
|
import {
|
|
resolveSearchScope,
|
|
searchRouteUrl,
|
|
shouldNavigateToSearch,
|
|
type SearchScope,
|
|
} from "$lib/utils/searchScope";
|
|
import AppHeader from "$lib/components/AppHeader.svelte";
|
|
import BottomUi from "$lib/components/BottomUi.svelte";
|
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
|
|
|
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
|
const scrollGuard = useScrollGuard(300);
|
|
setContext("scrollGuard", scrollGuard);
|
|
|
|
let { children } = $props();
|
|
|
|
let searchQuery = $state("");
|
|
let showSleepTimerModal = $state(false);
|
|
|
|
onMount(() => {
|
|
return () => {
|
|
scrollGuard.cleanup();
|
|
};
|
|
});
|
|
|
|
// Redirect to login if not authenticated
|
|
$effect(() => {
|
|
if (!$isAuthLoading && !$isAuthenticated) {
|
|
goto("/");
|
|
}
|
|
});
|
|
|
|
// The header search outlives navigation, so the route seeds the scope only
|
|
// while no search is active. Once the user has typed (or picked a chip),
|
|
// their scope governs until they clear the query — navigating must not snap
|
|
// a widened search back to the section they happen to be in.
|
|
// TRACES: UR-049 | DR-064
|
|
let searchScope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
|
|
|
|
$effect(() => {
|
|
const pathname = $page.url.pathname;
|
|
if (!searchQuery.trim()) {
|
|
searchScope = resolveSearchScope(pathname);
|
|
}
|
|
});
|
|
|
|
// The header bar is a *navigator*, not a second results surface: /search is
|
|
// the only route that renders searchResults, so searching here routes there
|
|
// with the query + route-derived scope in the URL. Previously this ran
|
|
// library.search() in place, which was invisible on every /library/** page
|
|
// except /library itself.
|
|
// TRACES: UR-049 | DR-063
|
|
async function handleSearch(query: string) {
|
|
if (!query.trim()) {
|
|
library.clearSearch();
|
|
return;
|
|
}
|
|
if (shouldNavigateToSearch($page.url.pathname, query)) {
|
|
await goto(searchRouteUrl(query, searchScope));
|
|
// The query now lives in the URL; clear the header input so returning to
|
|
// a library page does not leave a stale term sitting in the box.
|
|
searchQuery = "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if $isAuthLoading}
|
|
<div class="min-h-screen flex items-center justify-center">
|
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
{:else if $isAuthenticated}
|
|
<div class="h-screen flex flex-col overflow-hidden">
|
|
<!-- Header (shared across all authenticated chrome; library supplies search) -->
|
|
<AppHeader search={librarySearch} />
|
|
|
|
{#snippet librarySearch()}
|
|
<!-- Scope chips live on /search, which owns the results. -->
|
|
<Search
|
|
bind:value={searchQuery}
|
|
placeholder="Search your library..."
|
|
onSearch={handleSearch}
|
|
/>
|
|
{/snippet}
|
|
|
|
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
|
scroller is physically bounded above it and its last row can never
|
|
render behind the nav — no measurement, no reserved padding. -->
|
|
<main
|
|
class="flex-1 overflow-y-auto p-4 min-h-0"
|
|
style="overscroll-behavior: contain"
|
|
onscroll={scrollGuard.onScroll}
|
|
>
|
|
{@render children()}
|
|
</main>
|
|
|
|
<!-- Bottom UI (mini player + nav), in-flow below the scroller. -->
|
|
<BottomUi />
|
|
|
|
<!-- Sleep Timer Modal -->
|
|
<SleepTimerModal
|
|
isOpen={showSleepTimerModal}
|
|
onClose={() => showSleepTimerModal = false}
|
|
/>
|
|
</div>
|
|
{/if}
|