First working POC
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import BackButton from "$lib/components/common/BackButton.svelte";
|
||||
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
|
||||
/**
|
||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||
* Consolidates duplicate music library browsing logic
|
||||
*
|
||||
* @req: UR-007 - Navigate media in library
|
||||
* @req: UR-008 - Search media across libraries
|
||||
* @req: DR-007 - Library browsing screens
|
||||
*/
|
||||
|
||||
export interface MediaListConfig {
|
||||
itemType: string; // "MusicAlbum", "MusicArtist", "Playlist", "Audio"
|
||||
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
||||
backPath: string; // "/library/music"
|
||||
searchPlaceholder?: string;
|
||||
sortOptions: SortOption[];
|
||||
defaultSort: string;
|
||||
displayComponent: "grid" | "tracklist"; // Which component to use
|
||||
searchFields: string[]; // Which fields to search in: ["name", "artists"], etc.
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: MediaListConfig;
|
||||
}
|
||||
|
||||
let { config }: Props = $props();
|
||||
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let filteredItems = $state<MediaItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state<string>(config.defaultSort);
|
||||
|
||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadItems();
|
||||
markLoaded();
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
if (!$currentLibrary) {
|
||||
goto(config.backPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const repo = auth.getRepository();
|
||||
const result = await repo.getItems($currentLibrary.id, {
|
||||
includeItemTypes: [config.itemType],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
});
|
||||
items = result.items;
|
||||
applySortAndFilter();
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${config.itemType}:`, e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySortAndFilter() {
|
||||
let result = [...items];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((item) => {
|
||||
return config.searchFields.some((field) => {
|
||||
if (field === "artists" && item.artists) {
|
||||
return item.artists.some((a) => a.toLowerCase().includes(query));
|
||||
}
|
||||
const value = item[field as keyof MediaItem];
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase().includes(query);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting - find the matching sort option and use its compareFn
|
||||
const selectedSortOption = config.sortOptions.find((opt) => opt.key === sortBy);
|
||||
if (selectedSortOption && "compareFn" in selectedSortOption) {
|
||||
result.sort(selectedSortOption.compareFn as (a: MediaItem, b: MediaItem) => number);
|
||||
}
|
||||
|
||||
filteredItems = result;
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
searchQuery = query;
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function handleSort(newSort: string) {
|
||||
sortBy = newSort;
|
||||
applySortAndFilter();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto(config.backPath);
|
||||
}
|
||||
|
||||
const searchPlaceholder = config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`;
|
||||
|
||||
function handleItemClick(item: MediaItem) {
|
||||
// Navigate to detail page for browseable items
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
function handleTrackClick(track: MediaItem, _index: number) {
|
||||
// For track lists, navigate to the track's album if available, otherwise detail page
|
||||
if (track.albumId) {
|
||||
goto(`/library/${track.albumId}`);
|
||||
} else {
|
||||
goto(`/library/${track.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton onClick={goBack} label="Back" />
|
||||
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search and Sort Bar -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1">
|
||||
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
||||
</div>
|
||||
|
||||
<!-- Sort (only show if there are sort options) -->
|
||||
{#if config.sortOptions.length > 0}
|
||||
<SortButtonGroup options={config.sortOptions} selected={sortBy} onSelect={handleSort} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
{#if !loading}
|
||||
<ResultsCounter count={filteredItems.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
|
||||
{/if}
|
||||
|
||||
<!-- Items List/Grid -->
|
||||
{#if loading}
|
||||
{#if config.displayComponent === "grid"}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{#each Array(10) as _}
|
||||
<div class="animate-pulse">
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg"></div>
|
||||
<div class="mt-2 h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<div class="animate-pulse h-16 bg-[var(--color-surface)] rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No {config.title.toLowerCase()} found</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if config.displayComponent === "grid"}
|
||||
<LibraryGrid items={filteredItems} onItemClick={handleItemClick} />
|
||||
{:else if config.displayComponent === "tracklist"}
|
||||
<TrackList tracks={filteredItems} onTrackClick={handleTrackClick} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user