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:
2026-07-23 20:02:15 +02:00
parent e083b53ee8
commit c175378f38
11 changed files with 1135 additions and 256 deletions
+31 -110
View File
@@ -1,36 +1,28 @@
<script lang="ts">
// Renders search results as groups, in the user's configured order.
//
// Scope and order are composed as two independent axes (see
// composeSearchGroups): out-of-scope groups drop out, the rest sort by the
// saved order, and empty groups are omitted — the saved order itself is
// never rewritten by scoping.
//
// TRACES: UR-050 | DR-067
import type { MediaItem } from "$lib/api/types";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import TrackList from "$lib/components/library/TrackList.svelte";
import { searchGroupOrder } from "$lib/stores/searchGroupOrder";
import { composeSearchGroups, type SearchScope } from "$lib/utils/searchScope";
interface Props {
results: MediaItem[];
loading?: boolean;
scope?: SearchScope;
onItemClick?: (item: MediaItem) => void;
}
let { results, loading = false, onItemClick }: Props = $props();
let { results, loading = false, scope = "all", onItemClick }: Props = $props();
// Categorize results by type
const categorized = $derived({
music: {
tracks: results.filter((i) => i.type === "Audio"),
albums: results.filter((i) => i.type === "MusicAlbum"),
artists: results.filter((i) => i.type === "MusicArtist"),
},
movies: results.filter((i) => i.type === "Movie"),
tvShows: results.filter((i) => i.type === "Series" || i.type === "Episode"),
});
const hasMusic = $derived(
categorized.music.tracks.length > 0 ||
categorized.music.albums.length > 0 ||
categorized.music.artists.length > 0
);
const hasAnyResults = $derived(
hasMusic || categorized.movies.length > 0 || categorized.tvShows.length > 0
);
const groups = $derived(composeSearchGroups(results, scope, $searchGroupOrder));
</script>
{#if loading}
@@ -39,7 +31,7 @@
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !hasAnyResults}
{:else if groups.length === 0}
<div class="text-center py-12 text-gray-400">
<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"/>
@@ -48,98 +40,27 @@
</div>
{:else}
<div class="space-y-8">
<!-- Music Section -->
{#if hasMusic}
<div class="space-y-6">
<h2 class="text-2xl font-semibold text-white px-4">Music</h2>
<!-- Tracks Subsection -->
{#if categorized.music.tracks.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Tracks ({categorized.music.tracks.length})
</h3>
<TrackList tracks={categorized.music.tracks} />
</div>
{/if}
<!-- Albums Subsection -->
{#if categorized.music.albums.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Albums ({categorized.music.albums.length})
</h3>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.music.albums as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
<!-- Artists Subsection -->
{#if categorized.music.artists.length > 0}
<div>
<h3 class="text-lg text-gray-300 px-4 mb-3">
Artists ({categorized.music.artists.length})
</h3>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.music.artists as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={false}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Movies Section -->
{#if categorized.movies.length > 0}
{#each groups as group (group.id)}
<div>
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
Movies ({categorized.movies.length})
{group.label} ({group.items.length})
</h2>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.movies as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
{#if group.id === "songs"}
<TrackList tracks={group.items} />
{:else}
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each group.items as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={group.id !== "artists"}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
{/if}
</div>
{/if}
<!-- TV Shows Section -->
{#if categorized.tvShows.length > 0}
<div>
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
TV Shows ({categorized.tvShows.length})
</h2>
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
{#each categorized.tvShows as item (item.id)}
<MediaCard
{item}
size="medium"
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
// Scope filter chips shown under the search bar.
//
// The chip row is a *radio group*: exactly one scope is active, so arrow keys
// move between chips and the selected one is the sole tab stop.
//
// TRACES: UR-049 | DR-064
import { SCOPE_LABELS, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
interface Props {
scope: SearchScope;
onChange: (scope: SearchScope) => void;
}
let { scope, onChange }: Props = $props();
let chipEls: HTMLButtonElement[] = $state([]);
function select(next: SearchScope) {
if (next === scope) return;
onChange(next);
}
function onKeyDown(event: KeyboardEvent, index: number) {
const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (delta === 0) return;
event.preventDefault();
const next = (index + delta + SEARCH_SCOPES.length) % SEARCH_SCOPES.length;
chipEls[next]?.focus();
select(SEARCH_SCOPES[next]);
}
</script>
<div
class="flex gap-2 overflow-x-auto scrollbar-hide"
role="radiogroup"
aria-label="Search scope"
>
{#each SEARCH_SCOPES as s, i (s)}
<button
bind:this={chipEls[i]}
type="button"
role="radio"
aria-checked={scope === s}
tabindex={scope === s ? 0 : -1}
onclick={() => select(s)}
onkeydown={(e) => onKeyDown(e, i)}
class="px-4 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors border {scope === s
? 'bg-[var(--color-jellyfin)] border-[var(--color-jellyfin)] text-white'
: 'bg-[var(--color-surface)] border-gray-700 text-gray-300 hover:text-white hover:border-gray-500'}"
>
{SCOPE_LABELS[s]}
</button>
{/each}
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
</style>
@@ -0,0 +1,138 @@
<script lang="ts">
// Reorderable list of search result groups.
//
// Drag-and-drop alone would leave this unusable with a screen reader or any
// pointerless input, so every row also carries labelled move up/down buttons
// which are the primary, always-available mechanism.
//
// TRACES: UR-050 | DR-066
import { searchGroupOrder } from "$lib/stores/searchGroupOrder";
import { GROUP_LABELS, type SearchGroupId } from "$lib/utils/searchScope";
let dragIndex = $state<number | null>(null);
let overIndex = $state<number | null>(null);
// Announced to assistive tech after a move, since the list itself reorders
// silently.
let announcement = $state("");
function announce(id: SearchGroupId) {
const position = $searchGroupOrder.indexOf(id) + 1;
announcement = `${GROUP_LABELS[id]} moved to position ${position} of ${$searchGroupOrder.length}`;
}
function move(id: SearchGroupId, delta: number) {
searchGroupOrder.move(id, delta);
announce(id);
}
function onDragStart(event: DragEvent, index: number) {
dragIndex = index;
event.dataTransfer?.setData("text/plain", String(index));
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
}
function onDragOver(event: DragEvent, index: number) {
if (dragIndex === null) return;
event.preventDefault();
overIndex = index;
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
function onDrop(event: DragEvent, index: number) {
event.preventDefault();
if (dragIndex !== null && dragIndex !== index) {
const id = $searchGroupOrder[dragIndex];
searchGroupOrder.reorder(dragIndex, index);
if (id) announce(id);
}
dragIndex = null;
overIndex = null;
}
function onDragEnd() {
dragIndex = null;
overIndex = null;
}
</script>
<ul class="space-y-2">
{#each $searchGroupOrder as id, i (id)}
<li
draggable="true"
ondragstart={(e) => onDragStart(e, i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={(e) => onDrop(e, i)}
ondragend={onDragEnd}
class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex ===
i && dragIndex !== i
? 'border-[var(--color-jellyfin)]'
: 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}"
>
<!-- Decorative: dragging is the mouse affordance, the buttons below are
the accessible path. -->
<svg
class="w-4 h-4 text-gray-500 flex-shrink-0 cursor-grab"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M9 4h2v2H9V4zm4 0h2v2h-2V4zM9 9h2v2H9V9zm4 0h2v2h-2V9zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2z" />
</svg>
<span class="text-sm text-gray-400 w-5 flex-shrink-0">{i + 1}</span>
<span class="flex-1 text-white">{GROUP_LABELS[id]}</span>
<button
type="button"
onclick={() => move(id, -1)}
disabled={i === 0}
aria-label="Move {GROUP_LABELS[id]} up"
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onclick={() => move(id, 1)}
disabled={i === $searchGroupOrder.length - 1}
aria-label="Move {GROUP_LABELS[id]} down"
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</li>
{/each}
</ul>
<div class="sr-only" role="status" aria-live="polite">{announcement}</div>
<div class="flex justify-end pt-3">
<button
type="button"
onclick={() => {
searchGroupOrder.reset();
announcement = "Search group order reset to default";
}}
class="text-sm text-gray-400 hover:text-white transition-colors"
>
Reset to default
</button>
</div>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
+19 -2
View File
@@ -4,6 +4,8 @@
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
/**
@@ -222,7 +224,17 @@ function createLibraryStore() {
}
}
async function search(query: string) {
/**
* Search the library, optionally narrowed to a scope.
*
* `scope` is additive and defaults to `all`, which sends no
* `includeItemTypes` at all — see scopeItemTypes() for why that differs from
* listing every type. Both the online and offline repository paths already
* honour the filter.
*
* TRACES: UR-049 | DR-065
*/
async function search(query: string, scope: SearchScope = "all") {
// Bump the request id for every call (including clears) so any in-flight
// backend update for a previous query is ignored when it arrives.
const requestId = ++searchRequestId;
@@ -247,8 +259,13 @@ function createLibraryStore() {
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const itemTypes = scopeItemTypes(scope);
const options: SearchOptions = { limit: 10000 };
// Omit the key entirely for the `all` scope rather than sending null.
if (itemTypes) options.includeItemTypes = itemTypes;
const result = await Promise.race([
repo.search(query, { limit: 10000 }, requestId),
repo.search(query, options, requestId),
timeoutPromise
]);
+122
View File
@@ -0,0 +1,122 @@
/**
* Scoped search through the library store.
*
* TRACES: UR-049 | DR-065 | UT-*
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
const searchMock = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("./auth", () => ({
auth: {
getRepository: () => ({ search: searchMock }),
},
}));
import { library } from "./library";
function result(items: { id: string; type: string }[] = []) {
return { items, totalRecordCount: items.length };
}
describe("library.search scoping", () => {
beforeEach(() => {
searchMock.mockReset();
searchMock.mockResolvedValue(result());
library.clearSearch();
});
it("omits includeItemTypes entirely for the default (all) scope", async () => {
await library.search("office");
const options = searchMock.mock.calls[0][1];
expect(options).not.toHaveProperty("includeItemTypes");
expect(options.limit).toBe(10000);
});
it("forwards music item types when scoped to music", async () => {
await library.search("office", "music");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
"MusicAlbum",
"MusicArtist",
"Audio",
"Playlist",
]);
});
it("forwards tv item types when scoped to tv", async () => {
await library.search("office", "tv");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
});
it("forwards movie item types when scoped to movies", async () => {
await library.search("office", "movies");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
});
it("stores results and the query on success", async () => {
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
await library.search("office", "movies");
const state = get(library);
expect(state.searchQuery).toBe("office");
expect(state.searchResults.map((i) => i.id)).toEqual(["1"]);
});
it("clears results for an empty query without hitting the repository", async () => {
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
await library.search("office", "movies");
searchMock.mockClear();
await library.search(" ");
expect(searchMock).not.toHaveBeenCalled();
const state = get(library);
expect(state.searchQuery).toBe("");
expect(state.searchResults).toEqual([]);
});
it("discards a superseded response (stale requestId guard)", async () => {
// First search resolves *after* a newer one has already started; its
// results must not clobber the fresher ones.
let resolveFirst: (value: unknown) => void = () => {};
searchMock.mockImplementationOnce(
() => new Promise((resolve) => (resolveFirst = resolve))
);
searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }]));
const first = library.search("old", "all");
await library.search("new", "movies");
resolveFirst(result([{ id: "old", type: "Audio" }]));
await first;
expect(get(library).searchResults.map((i) => i.id)).toEqual(["new"]);
});
it("passes an increasing requestId to the repository", async () => {
await library.search("a");
await library.search("b");
const [firstId, secondId] = searchMock.mock.calls.map((c) => c[2]);
expect(secondId).toBeGreaterThan(firstId);
});
it("surfaces a repository failure as a store error", async () => {
searchMock.mockRejectedValue(new Error("boom"));
await expect(library.search("office", "tv")).rejects.toThrow("boom");
expect(get(library).error).toBe("boom");
expect(get(library).loadingCount).toBe(0);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Persisted search result group order.
*
* TRACES: UR-050 | DR-066 | UT-*
*/
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
import { DEFAULT_GROUP_ORDER } from "$lib/utils/searchScope";
const STORAGE_KEY = "jellytau-search-group-order";
// This jsdom setup doesn't expose localStorage, so stand in a minimal
// implementation — the store only uses getItem/setItem.
const store = new Map<string, string>();
const localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorage);
});
afterAll(() => {
vi.unstubAllGlobals();
});
describe("searchGroupOrder", () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
});
it("starts at the shipped default with nothing stored", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"movies",
"songs",
"albums",
"artists",
]);
});
it("appends groups a partial stored order does not mention", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(["movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
it("falls back to the default on corrupt stored JSON", async () => {
localStorage.setItem(STORAGE_KEY, "{not json");
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"movies",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("normalizes an explicitly set order", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
});
+77
View File
@@ -0,0 +1,77 @@
// Persisted order of search result groups.
//
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
// precedent in library.ts — no Rust settings command backs this.
//
// TRACES: UR-050 | DR-066
import { writable } from "svelte/store";
import {
DEFAULT_GROUP_ORDER,
moveGroup,
normalizeGroupOrder,
reorderGroups,
type SearchGroupId,
} from "$lib/utils/searchScope";
const STORAGE_KEY = "jellytau-search-group-order";
function load(): SearchGroupId[] {
if (typeof localStorage === "undefined") return [...DEFAULT_GROUP_ORDER];
try {
const raw = localStorage.getItem(STORAGE_KEY);
// A corrupt or hand-edited value must not break search rendering.
return normalizeGroupOrder(raw ? JSON.parse(raw) : null);
} catch {
return [...DEFAULT_GROUP_ORDER];
}
}
function persist(order: SearchGroupId[]) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
} catch {
// Quota or private-mode failure — keep the in-memory order.
}
}
function createSearchGroupOrderStore() {
const { subscribe, set, update } = writable<SearchGroupId[]>(load());
return {
subscribe,
set(order: SearchGroupId[]) {
const normalized = normalizeGroupOrder(order);
persist(normalized);
set(normalized);
},
/** Move one group up (-1) or down (+1) — the keyboard-accessible path. */
move(id: SearchGroupId, delta: number) {
update((order) => {
const next = moveGroup(order, id, delta);
persist(next);
return next;
});
},
/** Drop handler for drag-and-drop reordering. */
reorder(from: number, to: number) {
update((order) => {
const next = reorderGroups(order, from, to);
persist(next);
return next;
});
},
reset() {
const next = [...DEFAULT_GROUP_ORDER];
persist(next);
set(next);
},
};
}
export const searchGroupOrder = createSearchGroupOrderStore();
+285
View File
@@ -0,0 +1,285 @@
import { describe, it, expect } from "vitest";
import {
composeSearchGroups,
DEFAULT_GROUP_ORDER,
groupsForScope,
moveGroup,
normalizeGroupOrder,
reorderGroups,
resolveSearchScope,
scopeItemTypes,
type SearchGroupId,
} from "./searchScope";
describe("resolveSearchScope", () => {
it("scopes music routes to music", () => {
expect(resolveSearchScope("/library/music")).toBe("music");
expect(resolveSearchScope("/library/music/albums")).toBe("music");
expect(resolveSearchScope("/library/music/artists")).toBe("music");
expect(resolveSearchScope("/library/music/genres")).toBe("music");
expect(resolveSearchScope("/library/music/playlists")).toBe("music");
expect(resolveSearchScope("/library/music/tracks")).toBe("music");
});
it("scopes movie routes to movies", () => {
expect(resolveSearchScope("/library/movies")).toBe("movies");
expect(resolveSearchScope("/library/movies/all")).toBe("movies");
expect(resolveSearchScope("/library/movies/genres")).toBe("movies");
});
it("scopes tv routes to tv", () => {
expect(resolveSearchScope("/library/tv")).toBe("tv");
expect(resolveSearchScope("/library/tv/shows")).toBe("tv");
});
it("treats /library/shows as tv", () => {
// The TV genre page lives under `shows`, not `tv`.
expect(resolveSearchScope("/library/shows/genres")).toBe("tv");
expect(resolveSearchScope("/library/shows")).toBe("tv");
});
it("falls back to all for home, library root, search and unknown routes", () => {
expect(resolveSearchScope("/")).toBe("all");
expect(resolveSearchScope("/library")).toBe("all");
expect(resolveSearchScope("/search")).toBe("all");
expect(resolveSearchScope("/settings")).toBe("all");
expect(resolveSearchScope("/downloads")).toBe("all");
expect(resolveSearchScope("/library/abc123")).toBe("all");
expect(resolveSearchScope("/nonsense/route")).toBe("all");
});
it("tolerates trailing slashes, query strings and hashes", () => {
expect(resolveSearchScope("/library/music/")).toBe("music");
expect(resolveSearchScope("/library/tv?foo=1")).toBe("tv");
expect(resolveSearchScope("/library/movies#top")).toBe("movies");
expect(resolveSearchScope("")).toBe("all");
});
it("does not match a prefix that is only a partial segment", () => {
expect(resolveSearchScope("/library/musicvideos")).toBe("all");
});
});
describe("scopeItemTypes", () => {
it("omits the key entirely for the all scope", () => {
// `all` must send no includeItemTypes — an explicit union would silently
// drop types nobody enumerated (Person, folders).
expect(scopeItemTypes("all")).toBeUndefined();
});
it("maps each narrow scope to its item types", () => {
expect(scopeItemTypes("music")).toEqual(["MusicAlbum", "MusicArtist", "Audio", "Playlist"]);
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
expect(scopeItemTypes("tv")).toEqual(["Series", "Episode"]);
});
it("returns a fresh array callers cannot mutate into the table", () => {
const first = scopeItemTypes("movies")!;
first.push("Series");
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
});
});
describe("normalizeGroupOrder", () => {
it("returns the default for missing or non-array input", () => {
expect(normalizeGroupOrder(null)).toEqual([...DEFAULT_GROUP_ORDER]);
expect(normalizeGroupOrder(undefined)).toEqual([...DEFAULT_GROUP_ORDER]);
expect(normalizeGroupOrder("nonsense")).toEqual([...DEFAULT_GROUP_ORDER]);
expect(normalizeGroupOrder({})).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("drops ids that no longer exist", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
it("appends groups a stored order does not mention", () => {
// A user upgrading from a build with fewer groups must not lose the new ones.
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
it("de-duplicates repeated ids", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs",
"movies",
"albums",
"artists",
"tvShows",
]);
});
it("preserves a complete valid order unchanged", () => {
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
expect(normalizeGroupOrder(order)).toEqual(order);
});
});
describe("groupsForScope", () => {
it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
"movies",
"songs",
"tvShows",
"albums",
"artists",
]);
});
it("keeps only in-scope groups, in saved order", () => {
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
expect(groupsForScope("movies", order)).toEqual(["movies"]);
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
});
});
describe("composeSearchGroups", () => {
const results = [
{ id: "1", type: "Audio" },
{ id: "2", type: "MusicAlbum" },
{ id: "3", type: "Movie" },
{ id: "4", type: "Series" },
{ id: "5", type: "Episode" },
{ id: "6", type: "Person" },
];
it("renders groups in the configured order", () => {
const groups = composeSearchGroups(results, "all", [
"tvShows",
"movies",
"songs",
"albums",
"artists",
]);
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
});
it("omits empty groups", () => {
// No artists in the fixture, so the artists group never renders.
const groups = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(groups.map((g) => g.id)).not.toContain("artists");
});
it("drops out-of-scope groups", () => {
expect(composeSearchGroups(results, "music", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"songs",
"albums",
]);
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"tvShows",
]);
});
it("groups series and episodes together under tvShows", () => {
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
});
it("ignores item types that belong to no group", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
});
it("narrowing then widening restores the full arrangement", () => {
// Scope is a filter over the saved order, never a rewrite of it.
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
composeSearchGroups(results, "music", order);
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
});
it("survives a stored order containing an unknown id", () => {
const groups = composeSearchGroups(results, "all", [
"podcasts",
"movies",
] as unknown as SearchGroupId[]);
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
});
it("handles items with a missing type", () => {
const groups = composeSearchGroups(
[{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[],
"all",
DEFAULT_GROUP_ORDER
);
expect(groups).toEqual([]);
});
});
describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([
"songs",
"artists",
"albums",
"movies",
"tvShows",
]);
});
it("moves a group down", () => {
expect(moveGroup(order, "songs", 1)).toEqual([
"albums",
"songs",
"artists",
"movies",
"tvShows",
]);
});
it("is a no-op at the boundaries", () => {
expect(moveGroup(order, "songs", -1)).toEqual(order);
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
});
it("is a no-op for an unknown id", () => {
expect(moveGroup(order, "podcasts" as SearchGroupId, 1)).toEqual(order);
});
it("does not mutate the input", () => {
const input = [...order];
moveGroup(input, "songs", 1);
expect(input).toEqual(order);
});
});
describe("reorderGroups", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
it("moves an item from one index to another", () => {
expect(reorderGroups(order, 0, 4)).toEqual([
"albums",
"artists",
"movies",
"tvShows",
"songs",
]);
expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"movies",
]);
});
it("is a no-op for equal or out-of-range indices", () => {
expect(reorderGroups(order, 2, 2)).toEqual(order);
expect(reorderGroups(order, -1, 2)).toEqual(order);
expect(reorderGroups(order, 0, 9)).toEqual(order);
});
});
+205
View File
@@ -0,0 +1,205 @@
// Search scoping and result-group ordering.
//
// Two independent axes govern how search results are presented:
// - *scope* narrows which item types are requested from the repository,
// - *group order* decides the sequence the surviving groups render in.
// Neither one rewrites the other: narrowing to Music and widening back to All
// restores the user's saved arrangement untouched.
//
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
export type SearchScope = "all" | "music" | "movies" | "tv";
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
export const SCOPE_LABELS: Record<SearchScope, string> = {
all: "All",
music: "Music",
movies: "Movies",
tv: "TV",
};
/**
* Jellyfin item types requested for each scope.
*
* `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
* as sending the union of the lists below — types nobody enumerated here
* (Person, folders, …) would be filtered out by an explicit list.
*/
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
/**
* Item types to send with a scoped search, or `undefined` for the `all` scope
* so the caller omits the key entirely.
*
* TRACES: UR-049 | DR-063
*/
export function scopeItemTypes(scope: SearchScope): string[] | undefined {
if (scope === "all") return undefined;
return [...SCOPE_ITEM_TYPES[scope]];
}
/**
* Resolve the scope a search started from a given route should default to.
* Pure — takes a pathname, touches no DOM, so it unit-tests directly.
*
* TRACES: UR-049 | DR-063
*/
export function resolveSearchScope(pathname: string): SearchScope {
// Tolerate query strings, hashes and trailing slashes.
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
// `/library/shows/genres` is the TV genre route despite the differing segment.
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
return "all";
}
// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
/** Shipped default order, per the spec. */
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"songs",
"albums",
"artists",
"movies",
"tvShows",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
songs: "Songs",
albums: "Albums",
artists: "Artists",
movies: "Movies",
tvShows: "TV Shows",
};
/** Which scopes each group belongs to (`all` always includes everything). */
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
songs: "music",
albums: "music",
artists: "music",
movies: "movies",
tvShows: "tv",
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
movies: ["Movie"],
tvShows: ["Series", "Episode"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]];
}
/**
* Normalise a stored order into a usable one.
*
* The stored array is a *hint*, not a contract: ids that no longer exist are
* dropped, and groups it never mentions (a user upgrading from a build with
* fewer groups) are appended in default order rather than lost.
*
* TRACES: UR-050 | DR-066
*/
export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
const known = new Set<string>(DEFAULT_GROUP_ORDER);
const seen = new Set<SearchGroupId>();
const order: SearchGroupId[] = [];
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue;
const groupId = id as SearchGroupId;
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
}
}
for (const id of DEFAULT_GROUP_ORDER) {
if (!seen.has(id)) order.push(id);
}
return order;
}
/** Groups visible under a scope, in the user's configured order. */
export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
}
export interface SearchGroup<T> {
id: SearchGroupId;
label: string;
items: T[];
}
/**
* Compose scope, saved order and the results into the sections to render:
* drop out-of-scope groups, sort by the saved order, omit empty groups.
*
* TRACES: UR-050 | DR-067
*/
export function composeSearchGroups<T extends { type?: string | null }>(
results: readonly T[],
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroup<T>[] {
return groupsForScope(scope, order)
.map((id) => {
const types = GROUP_ITEM_TYPES[id];
return {
id,
label: GROUP_LABELS[id],
items: results.filter((item) => item.type != null && types.includes(item.type)),
};
})
.filter((group) => group.items.length > 0);
}
/** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */
export function moveGroup(
order: readonly SearchGroupId[],
id: SearchGroupId,
delta: number
): SearchGroupId[] {
const next = [...order];
const from = next.indexOf(id);
if (from === -1) return next;
const to = from + delta;
if (to < 0 || to >= next.length) return next;
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
/** Move a group from one index to another (drag-and-drop drop handler). */
export function reorderGroups(
order: readonly SearchGroupId[],
from: number,
to: number
): SearchGroupId[] {
const next = [...order];
if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
+37 -142
View File
@@ -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
+20 -2
View File
@@ -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}