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">
|
||||
// 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>
|
||||
Reference in New Issue
Block a user