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:
@@ -1,36 +1,28 @@
|
|||||||
<script lang="ts">
|
<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 type { MediaItem } from "$lib/api/types";
|
||||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
||||||
import TrackList from "$lib/components/library/TrackList.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 {
|
interface Props {
|
||||||
results: MediaItem[];
|
results: MediaItem[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
scope?: SearchScope;
|
||||||
onItemClick?: (item: MediaItem) => void;
|
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 groups = $derived(composeSearchGroups(results, scope, $searchGroupOrder));
|
||||||
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
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -39,7 +31,7 @@
|
|||||||
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !hasAnyResults}
|
{:else if groups.length === 0}
|
||||||
<div class="text-center py-12 text-gray-400">
|
<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">
|
<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"/>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Music Section -->
|
{#each groups as group (group.id)}
|
||||||
{#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}
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
|
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
|
||||||
Movies ({categorized.movies.length})
|
{group.label} ({group.items.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
{#if group.id === "songs"}
|
||||||
{#each categorized.movies as item (item.id)}
|
<TrackList tracks={group.items} />
|
||||||
<MediaCard
|
{:else}
|
||||||
{item}
|
<div class="flex gap-4 overflow-x-auto scrollbar-hide px-4">
|
||||||
size="medium"
|
{#each group.items as item (item.id)}
|
||||||
showProgress={true}
|
<MediaCard
|
||||||
onclick={() => onItemClick?.(item)}
|
{item}
|
||||||
/>
|
size="medium"
|
||||||
{/each}
|
showProgress={group.id !== "artists"}
|
||||||
</div>
|
onclick={() => onItemClick?.(item)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
|
|
||||||
<!-- 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}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
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";
|
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
|
// Bump the request id for every call (including clears) so any in-flight
|
||||||
// backend update for a previous query is ignored when it arrives.
|
// backend update for a previous query is ignored when it arrives.
|
||||||
const requestId = ++searchRequestId;
|
const requestId = ++searchRequestId;
|
||||||
@@ -247,8 +259,13 @@ function createLibraryStore() {
|
|||||||
// Phase 1: the command resolves with instant local-cache results. The
|
// Phase 1: the command resolves with instant local-cache results. The
|
||||||
// merged (cache + server) union arrives later via the `search-event`
|
// merged (cache + server) union arrives later via the `search-event`
|
||||||
// listener above, tagged with this same requestId.
|
// 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([
|
const result = await Promise.race([
|
||||||
repo.search(query, { limit: 10000 }, requestId),
|
repo.search(query, options, requestId),
|
||||||
timeoutPromise
|
timeoutPromise
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
import { onMount, onDestroy, setContext } from "svelte";
|
import { onMount, onDestroy, setContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
|
||||||
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
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 BottomUi from "$lib/components/BottomUi.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
|
|
||||||
@@ -17,7 +19,6 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let showOverflowMenu = $state(false);
|
|
||||||
let showSleepTimerModal = $state(false);
|
let showSleepTimerModal = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -33,19 +34,34 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogout() {
|
// The header search outlives navigation, so the route seeds the scope only
|
||||||
await auth.logout();
|
// while no search is active. Once the user has typed (or picked a chip),
|
||||||
library.reset();
|
// their scope governs until they clear the query — navigating must not snap
|
||||||
goto("/");
|
// 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) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
await library.search(query);
|
await library.search(query, searchScope);
|
||||||
} else {
|
} else {
|
||||||
library.clearSearch();
|
library.clearSearch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleScopeChange(next: SearchScope) {
|
||||||
|
searchScope = next;
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
await library.search(searchQuery, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isAuthLoading}
|
{#if $isAuthLoading}
|
||||||
@@ -54,140 +70,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if $isAuthenticated}
|
{:else if $isAuthenticated}
|
||||||
<div class="h-screen flex flex-col overflow-hidden">
|
<div class="h-screen flex flex-col overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header (shared across all authenticated chrome; library supplies search) -->
|
||||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
<AppHeader search={librarySearch} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
{#snippet librarySearch()}
|
||||||
<nav class="hidden md:flex items-center gap-1">
|
<Search
|
||||||
<a
|
bind:value={searchQuery}
|
||||||
href="/"
|
placeholder="Search your library..."
|
||||||
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'}"
|
onSearch={handleSearch}
|
||||||
>
|
/>
|
||||||
Home
|
{#if searchQuery.trim()}
|
||||||
</a>
|
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
|
||||||
<a
|
{/if}
|
||||||
href="/library"
|
{/snippet}
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
<!-- 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
|
scroller is physically bounded above it and its last row can never
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { library } from "$lib/stores/library";
|
import { library } from "$lib/stores/library";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
import SearchResults from "$lib/components/search/SearchResults.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";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
|
||||||
let searchQuery = $state("");
|
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) {
|
async function handleSearch(query: string) {
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
await library.search(query);
|
await library.search(query, scope);
|
||||||
} else {
|
} else {
|
||||||
library.clearSearch();
|
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) {
|
function handleItemClick(item: MediaItem) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "Audio":
|
case "Audio":
|
||||||
@@ -47,12 +63,13 @@
|
|||||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||||
|
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="mb-6">
|
<div class="mb-6 space-y-3">
|
||||||
<Search
|
<Search
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Search your library..."
|
placeholder="Search your library..."
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
/>
|
/>
|
||||||
|
<SearchScopeChips {scope} onChange={handleScopeChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
@@ -60,6 +77,7 @@
|
|||||||
<SearchResults
|
<SearchResults
|
||||||
results={$library.searchResults}
|
results={$library.searchResults}
|
||||||
loading={$library.loadingCount > 0}
|
loading={$library.loadingCount > 0}
|
||||||
|
{scope}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user