Neither search backend orders by *where* the query matched, so a mid-word hit could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above "Parks and Recreation". Add `domain/search_rank.rs`, which sorts by match position (prefix → word-start → mid-word substring → no name match), then by media kind so a container outranks its own contents. The sort is stable, so each backend's own relevance still breaks ties it was never overruled on. `repository_search` applies it to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land. Ranking lives in Rust because "a better match" is domain vocabulary, not presentation. On the frontend, the combined `tvShows` result group splits into separate Shows and Episodes groups so a show no longer competes with its own episodes for a slot, and a People group is added so searching an actor's name reaches their bio. A stored `tvShows` order expands in place, keeping the position an upgrading user chose for it.
76 lines
2.6 KiB
Svelte
76 lines
2.6 KiB
Svelte
<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, scope = "all", onItemClick }: Props = $props();
|
|
|
|
const groups = $derived(composeSearchGroups(results, scope, $searchGroupOrder));
|
|
</script>
|
|
|
|
{#if loading}
|
|
<div class="flex justify-center py-12">
|
|
<div
|
|
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
|
></div>
|
|
</div>
|
|
{: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"/>
|
|
</svg>
|
|
<p>No results found</p>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-8">
|
|
{#each groups as group (group.id)}
|
|
<div>
|
|
<h2 class="text-2xl font-semibold text-white px-4 mb-3">
|
|
{group.label} ({group.items.length})
|
|
</h2>
|
|
{#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" && group.id !== "people"}
|
|
onclick={() => onItemClick?.(item)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
.scrollbar-hide {
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
}
|
|
</style>
|