feat(search): context-scoped search with filter chips and group order
Add a search scope (all/music/shows/movies) resolved from the entry route and adjustable via filter chips, threaded through the library store's search() into includeItemTypes. Results group by type in a user-configurable order, editable from settings. TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
import { onMount, onDestroy, setContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
||||
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 SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
|
||||
import { resolveSearchScope, 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";
|
||||
|
||||
@@ -17,7 +19,6 @@
|
||||
let { children } = $props();
|
||||
|
||||
let searchQuery = $state("");
|
||||
let showOverflowMenu = $state(false);
|
||||
let showSleepTimerModal = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
@@ -33,19 +34,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
library.reset();
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch(query: string) {
|
||||
if (query.trim()) {
|
||||
await library.search(query);
|
||||
await library.search(query, searchScope);
|
||||
} else {
|
||||
library.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScopeChange(next: SearchScope) {
|
||||
searchScope = next;
|
||||
if (searchQuery.trim()) {
|
||||
await library.search(searchQuery, next);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isAuthLoading}
|
||||
@@ -54,140 +70,19 @@
|
||||
</div>
|
||||
{:else if $isAuthenticated}
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||
<div class="px-4 py-3 flex items-center gap-4">
|
||||
<!-- Logo -->
|
||||
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
|
||||
JellyTau
|
||||
</a>
|
||||
<!-- Header (shared across all authenticated chrome; library supplies search) -->
|
||||
<AppHeader search={librarySearch} />
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href="/"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/downloads"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {$page.url.pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Search (desktop only) -->
|
||||
<div class="flex-1 max-w-md hidden md:block">
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
|
||||
|
||||
<!-- Desktop: Downloads icon -->
|
||||
<a
|
||||
href="/downloads"
|
||||
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
||||
title="Downloads"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Mobile: Overflow menu button -->
|
||||
<div class="relative md:hidden">
|
||||
<button
|
||||
onclick={() => showOverflowMenu = !showOverflowMenu}
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
title="More options"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Overflow menu dropdown -->
|
||||
{#if showOverflowMenu}
|
||||
<!-- Backdrop to close menu when clicking outside -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') showOverflowMenu = false; }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close menu"
|
||||
></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="absolute right-0 top-full mt-2 w-48 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50">
|
||||
<a
|
||||
href="/downloads"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => showOverflowMenu = false}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
<button
|
||||
onclick={() => { showOverflowMenu = false; handleLogout(); }}
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Logout button -->
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{#snippet librarySearch()}
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
{#if searchQuery.trim()}
|
||||
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
||||
{/if}
|
||||
{/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
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
<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 { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
// Route resolves the *initial* scope only. Deriving it reactively would snap
|
||||
// a user who widened to All back to the route's scope on any navigation.
|
||||
// TRACES: UR-049 | DR-064
|
||||
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
|
||||
|
||||
async function handleSearch(query: string) {
|
||||
if (query.trim()) {
|
||||
await library.search(query);
|
||||
await library.search(query, scope);
|
||||
} else {
|
||||
library.clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Changing the chip re-runs the current query; changing the query keeps scope.
|
||||
async function handleScopeChange(next: SearchScope) {
|
||||
scope = next;
|
||||
if (searchQuery.trim()) {
|
||||
await library.search(searchQuery, next);
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
switch (item.type) {
|
||||
case "Audio":
|
||||
@@ -47,12 +63,13 @@
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 space-y-3">
|
||||
<Search
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search your library..."
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
<SearchScopeChips {scope} onChange={handleScopeChange} />
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
@@ -60,6 +77,7 @@
|
||||
<SearchResults
|
||||
results={$library.searchResults}
|
||||
loading={$library.loadingCount > 0}
|
||||
{scope}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user