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.
179 lines
6.3 KiB
Svelte
179 lines
6.3 KiB
Svelte
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
|
|
<script lang="ts">
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import EpisodeRow from "./EpisodeRow.svelte";
|
|
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
|
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
|
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
import { seasonAnchorId } from "./seriesNavigation";
|
|
|
|
interface Props {
|
|
season: MediaItem;
|
|
episodes: MediaItem[];
|
|
focusedEpisodeId?: string;
|
|
/** The episode the viewer is up to — highlighted and scrolled into view. */
|
|
currentEpisodeId?: string;
|
|
/**
|
|
* Whether this season's episode list is open. Only the current season
|
|
* starts expanded, so a ten-season show does not render every episode at
|
|
* once. TRACES: UR-062 | DR-107
|
|
*/
|
|
expanded?: boolean;
|
|
onToggle?: () => void;
|
|
onEpisodeClick?: (episode: MediaItem) => void;
|
|
onHistoryCleared?: () => void;
|
|
}
|
|
|
|
let {
|
|
season,
|
|
episodes,
|
|
focusedEpisodeId,
|
|
currentEpisodeId,
|
|
expanded = false,
|
|
onToggle,
|
|
onEpisodeClick,
|
|
onHistoryCleared,
|
|
}: Props = $props();
|
|
|
|
const holdsCurrentEpisode = $derived(
|
|
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId),
|
|
);
|
|
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
|
|
|
|
const episodeCount = $derived(episodes.length);
|
|
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
|
const seasonName = $derived(
|
|
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season"),
|
|
);
|
|
// Seasons have no page of their own; a season link scrolls to this anchor
|
|
// inside the series' single continuous episode list.
|
|
const anchor = $derived(seasonAnchorId(seasonNumber));
|
|
</script>
|
|
|
|
<section class="space-y-4 scroll-mt-4" id={anchor}>
|
|
<!-- Season header -->
|
|
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
|
|
<!-- Season poster -->
|
|
<div
|
|
class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]"
|
|
>
|
|
<CachedImage
|
|
itemId={season.id}
|
|
imageType="Primary"
|
|
tag={season.imageId}
|
|
maxWidth={200}
|
|
alt={seasonName}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Season info.
|
|
|
|
The header stacks on narrow screens and only shares a row from `sm` up.
|
|
Three action buttons and a season title cannot both fit across a phone,
|
|
and side-by-side they ended up overlapping. -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
|
<!-- The whole title block toggles the season open/closed. -->
|
|
<button
|
|
type="button"
|
|
onclick={onToggle}
|
|
aria-expanded={expanded}
|
|
aria-controls="{anchor}-episodes"
|
|
class="min-w-0 sm:flex-1 text-left group/season"
|
|
>
|
|
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
|
|
a flex item will not shrink below its content width without it, so
|
|
a long season name grew the row instead of ellipsising and ran
|
|
under the buttons. -->
|
|
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
|
|
<svg
|
|
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
|
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
<span class="truncate min-w-0">{seasonName}</span>
|
|
{#if holdsCurrentEpisode}
|
|
<span
|
|
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
|
>
|
|
Up next
|
|
</span>
|
|
{/if}
|
|
</h2>
|
|
|
|
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
|
|
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
|
|
<!-- Collapsed, this is the only progress signal the season shows. -->
|
|
{#if watchedCount > 0}
|
|
<span>•</span>
|
|
<span>
|
|
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
|
|
</span>
|
|
{/if}
|
|
{#if season.productionYear}
|
|
<span>•</span>
|
|
<span>{season.productionYear}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if season.overview && expanded}
|
|
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
|
|
{season.overview}
|
|
</p>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Per-season actions. `self-start` keeps them level with the title on
|
|
wide rows; on a stacked phone layout they sit under it. -->
|
|
<div class="flex-shrink-0 flex items-center gap-2 self-start">
|
|
<SeasonDownloadButton
|
|
seasonId={season.id}
|
|
seriesName={season.seriesName || ""}
|
|
{seasonName}
|
|
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
|
|
{episodeCount}
|
|
size="sm"
|
|
/>
|
|
<WatchedToggleButton
|
|
itemId={season.id}
|
|
watched={watchedCount === episodeCount && episodeCount > 0}
|
|
scope="season"
|
|
size="sm"
|
|
onChanged={onHistoryCleared}
|
|
/>
|
|
<ClearHistoryButton
|
|
itemId={season.id}
|
|
itemName={seasonName}
|
|
scope="season"
|
|
size="sm"
|
|
onCleared={onHistoryCleared}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Episode list -->
|
|
{#if expanded}
|
|
<div class="space-y-1 pl-2" id="{anchor}-episodes">
|
|
{#each episodes as episode (episode.id)}
|
|
<EpisodeRow
|
|
{episode}
|
|
focused={episode.id === focusedEpisodeId}
|
|
current={episode.id === currentEpisodeId}
|
|
onclick={() => onEpisodeClick?.(episode)}
|
|
onWatchedChanged={onHistoryCleared}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|