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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user