Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):
- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
(CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
sessions.test.ts NowPlayingItem mock.
Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
113 lines
3.2 KiB
Svelte
113 lines
3.2 KiB
Svelte
<!-- TRACES: UR-035 | JA-029 | DR-040, DR-044 -->
|
|
<script lang="ts">
|
|
import type { Person, PersonType } from "$lib/api/types";
|
|
import { goto } from "$app/navigation";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
people: Person[];
|
|
title?: string;
|
|
}
|
|
|
|
let { people, title = "Cast & Crew" }: Props = $props();
|
|
|
|
// Group people by type
|
|
const groupedPeople = $derived.by(() => {
|
|
const groups: Record<string, Person[]> = {
|
|
Actor: [],
|
|
Director: [],
|
|
Writer: [],
|
|
Producer: [],
|
|
Composer: [],
|
|
Other: [],
|
|
};
|
|
|
|
for (const person of people) {
|
|
// Skip people without valid ID (can occur if API response is incomplete)
|
|
if (!person.id || person.id.trim() === "") {
|
|
continue;
|
|
}
|
|
const type = person.type;
|
|
if (!type) continue;
|
|
if (type in groups) {
|
|
groups[type].push(person);
|
|
} else {
|
|
groups.Other.push(person);
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
});
|
|
|
|
// Order: Actors first, then Directors, Writers, etc.
|
|
const orderedTypes = ["Actor", "Director", "Writer", "Producer", "Composer", "Other"] as const;
|
|
|
|
// Get display name for type
|
|
function getTypeName(type: string): string {
|
|
switch (type) {
|
|
case "Actor":
|
|
return "Cast";
|
|
case "Director":
|
|
return "Directors";
|
|
case "Writer":
|
|
return "Writers";
|
|
case "Producer":
|
|
return "Producers";
|
|
case "Composer":
|
|
return "Composers";
|
|
default:
|
|
return "Other";
|
|
}
|
|
}
|
|
|
|
function handlePersonClick(person: Person) {
|
|
goto(`/library/${person.id}`);
|
|
}
|
|
</script>
|
|
|
|
<section class="space-y-6">
|
|
<h2 class="text-xl font-semibold text-white">{title}</h2>
|
|
|
|
{#each orderedTypes as type}
|
|
{#if groupedPeople[type]?.length > 0}
|
|
<div class="space-y-3">
|
|
<h3 class="text-sm font-medium text-gray-400 uppercase tracking-wide">
|
|
{getTypeName(type)}
|
|
</h3>
|
|
|
|
<div class="flex gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-700">
|
|
{#each groupedPeople[type] as person (person.id)}
|
|
<button
|
|
type="button"
|
|
class="flex-shrink-0 w-24 group text-left"
|
|
onclick={() => handlePersonClick(person)}
|
|
>
|
|
<!-- Person image -->
|
|
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
|
<CachedImage
|
|
itemId={person.id ?? ""}
|
|
imageType="Primary"
|
|
tag={person.primaryImageTag}
|
|
maxWidth={200}
|
|
alt={person.name}
|
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Name and role -->
|
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{person.name}
|
|
</p>
|
|
{#if person.role}
|
|
<p class="text-xs text-gray-400 truncate">
|
|
{person.role}
|
|
</p>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</section>
|