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.
115 lines
3.2 KiB
Svelte
115 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>
|