Files
jellytau/src/routes/library/+layout.svelte
T
dtourolle 124da29fc7 fix(search): route the library header search to /search
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.
2026-07-25 15:13:45 +02:00

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}