Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
151 lines
5.4 KiB
Svelte
151 lines
5.4 KiB
Svelte
<!--
|
|
Favourites — everything the viewer has hearted, across every library.
|
|
|
|
Scope tabs are `?scope=`, so a tab is linkable and survives a back press (the
|
|
same convention as the video library `?view=` tabs). Each tab sends an opaque
|
|
`SearchScope`; what it *means* in Jellyfin item types is expanded in Rust
|
|
(`SearchScope::item_types`), never here — see docs/specs/scoped-search-boundary.md.
|
|
|
|
ux-flows §5C.2. TRACES: UR-067 | DR-117
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { page } from "$app/stores";
|
|
import { goto } from "$app/navigation";
|
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { isServerReachable } from "$lib/stores/connectivity";
|
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
|
import { navigateBack } from "$lib/utils/navigation";
|
|
import { favoriteOverrides, retainFavorites } from "$lib/stores/favorites";
|
|
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
|
import BackButton from "$lib/components/common/BackButton.svelte";
|
|
import type { MediaItem, Library } from "$lib/api/types";
|
|
import {
|
|
FAVORITE_SCOPES,
|
|
FAVORITE_SCOPE_LABELS,
|
|
resolveFavoritesScope,
|
|
favoritesRouteUrl,
|
|
emptyStateMessage,
|
|
} from "$lib/utils/favoritesView";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("FavoritesPage");
|
|
|
|
const scope = $derived(resolveFavoritesScope($page.url.searchParams.get("scope")));
|
|
|
|
let items = $state<MediaItem[]>([]);
|
|
let loading = $state(true);
|
|
let loadError = $state<string | null>(null);
|
|
let unlistenFavorites: UnlistenFn | null = null;
|
|
|
|
// Un-hearting here must remove the card immediately rather than wait for a
|
|
// refetch; a newly hearted item stays put. TRACES: UR-067 | DR-117 | UT-106
|
|
const visibleItems = $derived(retainFavorites(items, $favoriteOverrides));
|
|
|
|
async function load(currentScope = scope) {
|
|
loading = true;
|
|
loadError = null;
|
|
try {
|
|
const repo = auth.getRepository();
|
|
const result = await repo.getFavorites(currentScope, { limit: 500 });
|
|
items = result.items;
|
|
} catch (error) {
|
|
log.error("Failed to load favorites:", error);
|
|
loadError = "Could not load your favourites.";
|
|
items = [];
|
|
} finally {
|
|
loading = false;
|
|
markLoaded();
|
|
}
|
|
}
|
|
|
|
// Reload when the tab changes.
|
|
let loadedScope = "";
|
|
$effect(() => {
|
|
if (scope === loadedScope) return;
|
|
loadedScope = scope;
|
|
load(scope);
|
|
});
|
|
|
|
onMount(async () => {
|
|
// The backend reports ids whose favourite state changed behind our back —
|
|
// a favourite marked in another client, or pending toggles pushed on
|
|
// reconnect. Refetch rather than patch: the scope decides what belongs.
|
|
// TRACES: UR-069 | DR-120
|
|
unlistenFavorites = await listen("favorites-changed", () => {
|
|
load(scope);
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
unlistenFavorites?.();
|
|
unlistenFavorites = null;
|
|
});
|
|
|
|
const { markLoaded } = useServerReachabilityReload(() => load(scope));
|
|
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
|
useOfflineFilterReload(() => load(scope));
|
|
|
|
function selectScope(next: (typeof FAVORITE_SCOPES)[number]) {
|
|
if (next === scope) return;
|
|
// replaceState: switching tabs is not a back-press-worthy navigation step.
|
|
goto(favoritesRouteUrl(next), { replaceState: true, noScroll: true });
|
|
}
|
|
|
|
function handleItemClick(item: MediaItem | Library) {
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<div class="flex items-center gap-3 px-4 pt-4">
|
|
<BackButton onClick={() => navigateBack("/library")} />
|
|
<h1 class="text-2xl font-bold text-white">Favourites</h1>
|
|
</div>
|
|
|
|
<nav class="flex items-center gap-1 px-4" aria-label="Favourite categories">
|
|
{#each FAVORITE_SCOPES as tab (tab)}
|
|
<button
|
|
onclick={() => selectScope(tab)}
|
|
aria-current={tab === scope ? "page" : undefined}
|
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
|
{tab === scope
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
|
>
|
|
{FAVORITE_SCOPE_LABELS[tab]}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<div class="px-4 pb-8">
|
|
{#if loading}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each Array(12) as _}
|
|
<div class="animate-pulse">
|
|
<div class="aspect-square bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
|
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if loadError}
|
|
<p class="text-gray-400 py-12 text-center">{loadError}</p>
|
|
{:else if visibleItems.length === 0}
|
|
<div class="py-16 text-center space-y-2">
|
|
<p class="text-gray-300">{emptyStateMessage(scope)}</p>
|
|
{#if !$isServerReachable}
|
|
<p class="text-sm text-gray-500">
|
|
Offline — showing favourites available on this device.
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Card shape follows the media, not the page (§5A.1), so a mixed All
|
|
tab reads as posters, squares and thumbnails side by side. -->
|
|
<LibraryGrid items={visibleItems} onItemClick={handleItemClick} />
|
|
{/if}
|
|
</div>
|
|
</div>
|