feat(web): MP-style media detail with seasons and episodes, shared by library and TMDB preview (MD-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:28:16 +08:00
1 parent 9cd5a28943
commit 97ac59c7ac
9 files changed
+1250 -190

No files matched your search

@@ -0,0 +1,403 @@
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
import { type ReactNode, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
/** TMDB status 原文 → 界面用词 */
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
"Returning Series": { label: "连载中", tone: "success" },
"In Production": { label: "制作中", tone: "neutral" },
Planned: { label: "计划中", tone: "neutral" },
Pilot: { label: "试播", tone: "neutral" },
Ended: { label: "已完结", tone: "neutral" },
Canceled: { label: "已取消", tone: "warning" },
Released: { label: "已上映", tone: "neutral" },
"Post Production": { label: "后期制作", tone: "neutral" },
Rumored: { label: "传闻", tone: "neutral" },
};
export interface MediaFacts {
title: string;
originalTitle: string | null;
/** 中文名与标题不同时显示在原名一行 */
titleZh?: string | null;
year: number | null;
/** movie / series / variety … */
kind: string;
kindLabel: string;
genres: string[];
voteAverage: number | null;
voteCount: number | null;
airStatus: string | null;
runtime: number | null;
seasonCount: number | null;
episodeCount: number | null;
releaseDate: string | null;
lastAirDate: string | null;
platform: string | null;
originCountry: string[];
overview: string | null;
tagline: string | null;
posterPath: string | null;
backdropPath: string | null;
externalLinks: { label: string; href: string }[];
}
const pad = (n: number) => String(n).padStart(2, "0");
export const today = () => new Date().toISOString().slice(0, 10);
function runtimeText(min: number | null, kind: string): string | null {
if (!min) return null;
const s = min >= 60 ? `${Math.floor(min / 60)} 小时${min % 60 ? ` ${min % 60} 分钟` : ""}` : `${min} 分钟`;
return kind === "movie" ? s : `单集 ${s}`;
}
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
export function ClampText({
text,
lines = 3,
className,
onMedia = false,
}: {
text: string;
lines?: 2 | 3 | 4;
className?: string;
onMedia?: boolean;
}) {
const [open, setOpen] = useState(false);
const long = text.length > lines * 38;
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
return (
<div className={className}>
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
{long ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
onMedia
? "text-on-media-tertiary hover:text-on-media-strong"
: "text-muted-foreground hover:text-foreground",
)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
const [bgFailed, setBgFailed] = useState(false);
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
const sub = [
facts.titleZh && facts.titleZh !== facts.title ? facts.titleZh : null,
facts.originalTitle && facts.originalTitle !== facts.title && facts.originalTitle !== facts.titleZh
? facts.originalTitle
: null,
].filter(Boolean);
const line = [
facts.kindLabel,
facts.year ? String(facts.year) : null,
facts.kind !== "movie" && facts.seasonCount ? `${facts.seasonCount} 季` : null,
facts.kind !== "movie" && facts.episodeCount ? `${facts.episodeCount} 集` : null,
runtimeText(facts.runtime, facts.kind),
facts.platform,
facts.originCountry.join(" / ") || null,
].filter(Boolean);
const dates = [
facts.releaseDate ? `${facts.kind === "movie" ? "上映" : "首播"} ${facts.releaseDate}` : null,
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
].filter(Boolean);
return (
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
{bg ? (
<img
src={bg}
alt=""
aria-hidden
onError={() => setBgFailed(true)}
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
/>
) : null}
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
<div
aria-hidden
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
/>
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
<CoverPlaceholder
title={facts.title}
kind={facts.kind}
posterPath={facts.posterPath}
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
/>
<div className="min-w-0 flex-1">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
{facts.title}
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
</h1>
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
{facts.voteAverage ? (
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{line.length ? <span>{line.join(" · ")}</span> : null}
</div>
{facts.genres.length ? (
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
{facts.genres.map((g) => (
<li
key={g}
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
>
{g}
</li>
))}
</ul>
) : null}
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
{facts.overview ? (
<ClampText
text={facts.overview}
lines={4}
onMedia
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
/>
) : (
<p className="mt-3 text-caption text-on-media-tertiary">没有简介。</p>
)}
{dates.length || facts.externalLinks.length ? (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
{dates.map((d) => (
<span key={d}>{d}</span>
))}
{facts.externalLinks.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
))}
</div>
) : null}
</div>
</div>
</section>
);
}
function Avatar({ path, name }: { path: string | null; name: string }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w185");
return src ? (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover"
/>
) : (
<span
aria-hidden
title={name}
className="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-border bg-foreground/[0.05] text-muted-foreground"
>
<UserRound className="h-6 w-6" />
</span>
);
}
const CREW_ROLE: Record<string, string> = { Director: "导演", Creator: "主创" };
/** 主创:导演 / 主创在前,演员前 10;头像经图片代理,点姓名去 TMDB 人物页 */
export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaCredit[] }) {
const people = [
...crew.map((c) => ({ ...c, role: c.role ? (CREW_ROLE[c.role] ?? c.role) : null, key: `c${c.id}` })),
...cast.slice(0, 10).map((c) => ({ ...c, key: `a${c.id}` })),
];
if (!people.length) return null;
return (
<ul className="scroll-bleed -mx-1 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap sm:overflow-visible">
{people.map((p) => (
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
<Avatar path={p.profilePath} name={p.name} />
<a
href={`https://www.themoviedb.org/person/${p.id}`}
target="_blank"
rel="noreferrer noopener"
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
>
{p.name}
</a>
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
</li>
))}
</ul>
);
}
export interface EpisodeRowData {
key: string;
seasonNumber: number;
episodeNumber: number;
name: string | null;
overview: string | null;
airDate: string | null;
runtime: number | null;
stillPath: string | null;
voteAverage: number | null;
/** 库内状态;null = 不显示(仅 TMDB 作品未收录) */
status?: { label: string; tone: StatusTone } | null;
/** 有锁定的字段 */
locked?: boolean;
onEdit?: () => void;
}
function Still({ path }: { path: string | null }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w300");
return (
<div className="aspect-video w-28 shrink-0 overflow-hidden rounded-lg border border-border bg-foreground/[0.05] sm:w-40">
{src ? (
<img src={src} alt="" loading="lazy" onError={() => setFailed(true)} className="h-full w-full object-cover" />
) : (
<div aria-hidden className="grid h-full w-full place-items-center text-muted-foreground">
<ImageOff className="h-4 w-4 opacity-60" />
</div>
)}
</div>
);
}
/** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */
export function EpisodeRow({ e }: { e: EpisodeRowData }) {
const unaired = !!e.airDate && e.airDate > today();
const meta = [
e.airDate ?? "播出日期未知",
e.runtime ? `${e.runtime} 分钟` : null,
e.voteAverage ? `★ ${e.voteAverage.toFixed(1)}` : null,
].filter(Boolean);
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
return (
<li className="flex gap-3 py-3 first:pt-0 last:pb-0">
<Still path={e.stillPath} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
<p className="min-w-0 text-sm font-medium break-words text-foreground">
<span className="mr-1.5 font-mono text-caption text-muted-foreground">
S{pad(e.seasonNumber)}E{pad(e.episodeNumber)}
</span>
{e.name ?? `第 ${e.episodeNumber} 集`}
{e.locked ? <Lock className="ml-1 inline h-3 w-3 text-muted-foreground" aria-label="有锁定字段" /> : null}
</p>
<div className="flex shrink-0 items-center gap-1">
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{e.onEdit ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改 S${pad(e.seasonNumber)}E${pad(e.episodeNumber)}`}
onClick={e.onEdit}
>
<PencilLine />
</Button>
) : null}
</div>
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
</div>
</li>
);
}
export interface SeasonHeaderData {
seasonNumber: number;
name: string | null;
airDate: string | null;
episodeCount: number | null;
posterPath: string | null;
overview: string | null;
/** 季头右侧的摘要,如「已入库 5/9」 */
summary?: ReactNode;
}
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
export function SeasonSection({
s,
open,
onToggle,
children,
}: {
s: SeasonHeaderData;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
const title = s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
return (
<section className="rounded-xl border border-border/70">
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-base hover:bg-wash/[0.04]"
>
<CoverPlaceholder title={title} kind="series" small posterPath={s.posterPath} className="h-14 w-10" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium break-words text-foreground">
{title}
<span className="ml-2 font-mono text-meta font-normal text-muted-foreground">S{pad(s.seasonNumber)}</span>
</p>
<p className="text-meta text-muted-foreground">
{[s.airDate ? s.airDate.slice(0, 4) : null, s.episodeCount !== null ? `${s.episodeCount} 集` : null]
.filter(Boolean)
.join(" · ") || "—"}
</p>
</div>
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
<ChevronDown
aria-hidden
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div className="border-t border-border/60 px-3 py-3">
{s.summary ? <div className="mb-2 sm:hidden">{s.summary}</div> : null}
{s.overview ? (
<ClampText text={s.overview} lines={2} className="mb-3 text-caption text-muted-foreground" />
) : null}
{children}
</div>
) : null}
</section>
);
}