TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
279 lines
9.5 KiB
Svelte
279 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { page } from "$app/stores";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import { goto } from "$app/navigation";
|
|
import { navigateUp } from "$lib/utils/navigation";
|
|
import { currentLibrary } from "$lib/stores/library";
|
|
import { auth } from "$lib/stores/auth";
|
|
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
|
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
|
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("GenericGenreBrowser");
|
|
|
|
/**
|
|
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
|
* Consolidates duplicate genre-browsing logic across media types
|
|
*
|
|
* @req: UR-007 - Navigate media in library
|
|
* @req: UR-030 - Quick genre browsing and filtering
|
|
* @req: DR-007 - Library browsing screens (genre filtering)
|
|
*/
|
|
|
|
export interface GenreConfig {
|
|
itemTypes: ItemType[]; // ["Movie"] or ["MusicAlbum"] or ["Series"]
|
|
title: string; // "Movie Genres" or "Genres" or "TV Genres"
|
|
backPath: string; // "/library" or "/library/music"
|
|
genreIcon: string; // SVG path for genre icon
|
|
itemDisplayMode: "poster" | "square"; // Aspect ratio: 2/3 or 1/1
|
|
searchPlaceholder?: string; // Optional custom placeholder
|
|
noItemsMessage?: string; // Optional custom empty state
|
|
}
|
|
|
|
interface Props {
|
|
config: GenreConfig;
|
|
/**
|
|
* Suppress the back button + title when this renders as a *tab* of a
|
|
* library page that already has a header. Drilling into a single genre
|
|
* still shows the header — there the back button is the way out.
|
|
* TRACES: UR-063 | DR-105
|
|
*/
|
|
showHeader?: boolean;
|
|
}
|
|
|
|
let { config, showHeader = true }: Props = $props();
|
|
|
|
let genres = $state<Genre[]>([]);
|
|
let filteredGenres = $state<Genre[]>([]);
|
|
let loading = $state(true);
|
|
let searchQuery = $state("");
|
|
let selectedGenre = $state<Genre | null>(null);
|
|
let genreItems = $state<MediaItem[]>([]);
|
|
let loadingItems = $state(false);
|
|
async function reloadGenreBrowse() {
|
|
await loadGenres();
|
|
if (selectedGenre) {
|
|
await loadGenreItems(selectedGenre);
|
|
}
|
|
}
|
|
|
|
const { markLoaded } = useServerReachabilityReload(reloadGenreBrowse);
|
|
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
|
useOfflineFilterReload(reloadGenreBrowse);
|
|
|
|
onMount(async () => {
|
|
await loadGenres();
|
|
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
|
|
const requestedGenre = $page.url.searchParams.get("genre");
|
|
if (requestedGenre) {
|
|
const match = genres.find(
|
|
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
|
|
);
|
|
if (match) {
|
|
await loadGenreItems(match);
|
|
}
|
|
}
|
|
markLoaded();
|
|
});
|
|
|
|
async function loadGenres() {
|
|
if (!$currentLibrary) {
|
|
goto(config.backPath);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
loading = true;
|
|
const repo = auth.getRepository();
|
|
const result = await repo.getGenres($currentLibrary.id);
|
|
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
|
applyFilter();
|
|
} catch (e) {
|
|
log.error("Failed to load genres:", e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function loadGenreItems(genre: Genre) {
|
|
if (!$currentLibrary) return;
|
|
|
|
try {
|
|
loadingItems = true;
|
|
selectedGenre = genre;
|
|
const repo = auth.getRepository();
|
|
const result = await repo.getItems($currentLibrary.id, {
|
|
includeItemTypes: config.itemTypes,
|
|
genres: [genre.name],
|
|
sortBy: "SortName",
|
|
sortOrder: "Ascending",
|
|
recursive: true,
|
|
limit: 10000,
|
|
});
|
|
genreItems = result.items;
|
|
} catch (e) {
|
|
log.error("Failed to load genre items:", e);
|
|
} finally {
|
|
loadingItems = false;
|
|
}
|
|
}
|
|
|
|
function applyFilter() {
|
|
let result = [...genres];
|
|
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter((genre) => genre.name.toLowerCase().includes(query));
|
|
}
|
|
|
|
filteredGenres = result;
|
|
}
|
|
|
|
function handleSearch(query: string) {
|
|
searchQuery = query;
|
|
applyFilter();
|
|
}
|
|
|
|
function handleGenreClick(genre: Genre) {
|
|
loadGenreItems(genre);
|
|
}
|
|
|
|
function handleItemClick(item: MediaItem) {
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
|
|
function goBack() {
|
|
if (selectedGenre) {
|
|
selectedGenre = null;
|
|
genreItems = [];
|
|
} else {
|
|
navigateUp(config.backPath);
|
|
}
|
|
}
|
|
|
|
const aspectRatioClass = $derived(config.itemDisplayMode === "poster" ? "aspect-[2/3]" : "aspect-square");
|
|
const gridColsClass = $derived(
|
|
config.itemDisplayMode === "poster"
|
|
? "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
|
: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
|
|
);
|
|
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
|
|
const noItemsMessage = $derived(config.noItemsMessage || `No ${config.itemTypes[0]?.toLowerCase() || "items"} found in this genre`);
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Header. Inside a genre the back button is the only way out, so it shows
|
|
even when the host page suppresses the top-level header. -->
|
|
{#if showHeader || selectedGenre}
|
|
<div class="flex items-center gap-4">
|
|
<BackButton onClick={goBack} label="Back" />
|
|
<h1 class="text-3xl font-bold text-white">
|
|
{#if selectedGenre}
|
|
{selectedGenre.name}
|
|
{:else}
|
|
{config.title}
|
|
{/if}
|
|
</h1>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !selectedGenre}
|
|
<!-- Genre Browser -->
|
|
<SearchBar value={searchQuery} placeholder={searchPlaceholder} onInput={handleSearch} />
|
|
|
|
{#if !loading && filteredGenres.length > 0}
|
|
<ResultsCounter count={filteredGenres.length} itemType="genre" searchQuery={searchQuery} />
|
|
{/if}
|
|
|
|
<!-- Genres Grid -->
|
|
{#if loading}
|
|
<div class="grid {gridColsClass} gap-4">
|
|
{#each Array(12) 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 if filteredGenres.length === 0}
|
|
<div class="text-center py-12 text-gray-400">
|
|
<p>No genres found</p>
|
|
</div>
|
|
{:else}
|
|
<div class="grid {gridColsClass} gap-4">
|
|
{#each filteredGenres as genre (genre.id)}
|
|
<button onclick={() => handleGenreClick(genre)} class="group text-left">
|
|
<div
|
|
class="aspect-square bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-[var(--color-jellyfin)]/5 rounded-lg flex items-center justify-center group-hover:from-[var(--color-jellyfin)]/30 group-hover:to-[var(--color-jellyfin)]/10 transition-all"
|
|
>
|
|
<svg
|
|
class="w-12 h-12 text-[var(--color-jellyfin)] opacity-70 group-hover:opacity-100 transition-opacity"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
{@html config.genreIcon}
|
|
</svg>
|
|
</div>
|
|
<p class="mt-2 text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{genre.name}
|
|
</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<!-- Genre Items View -->
|
|
{#if loadingItems}
|
|
<div class="grid {gridColsClass} gap-4">
|
|
{#each Array(10) as _}
|
|
<div class="animate-pulse">
|
|
<div class="{aspectRatioClass} 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 if genreItems.length === 0}
|
|
<div class="text-center py-12 text-gray-400">
|
|
<p>{noItemsMessage}</p>
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<ResultsCounter count={genreItems.length} itemType={config.itemTypes[0]?.toLowerCase() || "item"} />
|
|
<div class="grid {gridColsClass} gap-4 mt-4">
|
|
{#each genreItems as item (item.id)}
|
|
<button onclick={() => handleItemClick(item)} class="group text-left">
|
|
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
|
<CachedImage
|
|
itemId={item.id}
|
|
imageType="Primary"
|
|
tag={item.imageId}
|
|
maxWidth={300}
|
|
alt={item.name}
|
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
|
/>
|
|
</div>
|
|
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{truncateMiddle(item.name, 40)}
|
|
</p>
|
|
{#if item.productionYear}
|
|
<p class="text-sm text-gray-400">
|
|
{item.productionYear}
|
|
{#if item.communityRating}
|
|
<span class="text-yellow-500 ml-1">★ {item.communityRating.toFixed(1)}</span>
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|