Files
UHD_Media_Tool/apps/web/app/components/library/episode-structure.tsx
T

367 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EpisodeGroupSetResult, EpisodeStructureSeason } from "@umt/contracts";
import { Check, Layers } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { toast } from "sonner";
import { useEpisodeGroups, useEpisodeStructure, useSetEpisodeGroup } from "~/api/episode-mapping.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { EpisodeRow, SeasonSection } from "~/components/media/media-detail.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
DEFAULT_STRUCTURE_LABEL,
hasStructureChoice,
inLibraryText,
originText,
type StructureOption,
seasonLine,
seasonTitle,
structureOptions,
switchDescription,
} from "./episode-structure-model.ts";
// 季集结构选择(学 MoviePilot SubscribeSeasonDialog):横向轨道选「TMDB 默认季 / 各剧集组」,
// 下面按所选结构显示季卡片;选了非当前结构时可以「改用这套结构」(改的是作品设置,先确认)。
/** 横向轨道:每个选项一张小卡(名称 + 类型 · 季数 · 集数),当前结构带勾 */
export function StructureTrack({
options,
value,
onChange,
}: {
options: StructureOption[];
value: string | null;
onChange: (groupId: string | null) => void;
}) {
return (
<fieldset aria-label="季集结构" className="m-0 min-w-0 border-0 p-0">
<div className="scroll-bleed -mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{options.map((o) => {
const selected = o.groupId === value;
return (
<button
key={o.groupId ?? "default"}
type="button"
aria-pressed={selected}
onClick={() => onChange(o.groupId)}
className={cn(
"flex min-w-[9.5rem] shrink-0 cursor-pointer flex-col items-start rounded-xl border px-3 py-2 text-left transition-colors duration-base",
selected
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-wash/[0.06] hover:text-foreground",
)}
>
<span className="flex items-center gap-1 text-sm font-medium text-foreground">
{o.current ? <Check className="h-3.5 w-3.5" aria-label="当前" /> : null}
{o.label}
</span>
<span className="text-meta text-muted-foreground">{o.meta ?? (o.current ? "当前" : "按 TMDB 季")}</span>
</button>
);
})}
</div>
</fieldset>
);
}
/** 季卡片网格(订阅选季):海报、季名、首播年 · 集数、已入库;可选中 */
export function SeasonCardGrid({
seasons,
selected,
onPick,
disabled,
}: {
seasons: EpisodeStructureSeason[];
/** 选中的季号 */
selected: number | null;
onPick: (s: EpisodeStructureSeason) => void;
disabled?: boolean;
}) {
return (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{seasons.map((s) => {
const on = s.seasonNumber === selected;
const lib = inLibraryText(s);
return (
<li key={s.seasonNumber}>
<button
type="button"
aria-pressed={on}
disabled={disabled}
onClick={() => onPick(s)}
className={cn(
"flex w-full cursor-pointer flex-col gap-1.5 rounded-xl border p-1.5 text-left transition-colors duration-base disabled:cursor-not-allowed disabled:opacity-50",
on ? "pill-selected" : "border-border hover:bg-wash/[0.06]",
)}
>
<CoverPlaceholder
title={seasonTitle(s)}
kind="series"
small
posterPath={s.posterPath}
className="aspect-[2/3] h-auto w-full"
/>
<span className="min-w-0 px-0.5">
<span className="block truncate text-caption font-medium text-foreground">{seasonTitle(s)}</span>
<span className="block text-meta text-muted-foreground tabular-nums">{seasonLine(s)}</span>
{lib ? <span className="block text-meta text-muted-foreground tabular-nums">{lib}</span> : null}
</span>
</button>
</li>
);
})}
</ul>
);
}
/** 某套结构的季折叠列表(作品页预览):季头 + 每集(集号、标题、播出日期、入库状态、原季集对照) */
export function StructurePreview({ seasons }: { seasons: EpisodeStructureSeason[] }) {
const [open, setOpen] = useState<Set<number>>(new Set());
const toggle = (n: number) => {
const next = new Set(open);
if (next.has(n)) next.delete(n);
else next.add(n);
setOpen(next);
};
return (
<PaginatedList rows={seasons} label="季列表" className="space-y-2">
{(s) => (
<SeasonSection
key={s.seasonNumber}
s={{
seasonNumber: s.seasonNumber,
name: s.name,
airDate: s.airDate,
episodeCount: s.episodeCount,
posterPath: s.posterPath,
overview: null,
summary: (
<StatusBadge tone={s.episodeCount && s.inLibrary >= s.episodeCount ? "success" : "neutral"}>
已入库 {s.inLibrary}/{s.episodeCount}
</StatusBadge>
),
}}
open={open.has(s.seasonNumber)}
onToggle={() => toggle(s.seasonNumber)}
>
{s.episodes.length === 0 ? (
<p className="text-meta text-muted-foreground">TMDB 上这一季还没有集信息。</p>
) : (
<PaginatedList
rows={s.episodes}
as="ul"
compact
label={`第 ${s.seasonNumber} 季集列表`}
className="divide-y divide-border/60"
>
{(e) => (
<EpisodeRow
key={e.episodeNumber}
e={{
key: String(e.episodeNumber),
seasonNumber: s.seasonNumber,
episodeNumber: e.episodeNumber,
name: e.name,
overview: null,
airDate: e.airDate,
runtime: null,
stillPath: null,
voteAverage: null,
status: e.inLibrary ? { label: "已入库", tone: "success" } : { label: "未入库", tone: "neutral" },
extra: originText(s.seasonNumber, e) ? (
<p className="mt-0.5 font-mono text-meta text-muted-foreground">
{originText(s.seasonNumber, e)}
</p>
) : null,
}}
/>
)}
</PaginatedList>
)}
</SeasonSection>
)}
</PaginatedList>
);
}
function resultToast(r: EpisodeGroupSetResult) {
const name = r.current?.name ?? DEFAULT_STRUCTURE_LABEL;
const parts = [`改按「${name}」分季`];
if (r.moved) parts.push(`${r.moved} 集换了季集号`);
if (r.unmatched.length) parts.push(`${r.unmatched.length} 集对不上、已标临时`);
if (r.reresolve.changed) parts.push(`${r.reresolve.changed} 条资源重新换算`);
toast.success(parts.join(","));
}
/**
* 作品页「季与集」顶部的季集结构切换:只有 TMDB 有剧集组(或作品已选了剧集组)时出现。
* 选中当前结构时显示 children(现有的季与集);选中其它结构时显示该结构的季卡片预览与「改用这套结构」。
*/
export function EpisodeStructureSwitcher({ entityId, children }: { entityId: string; children: ReactNode }) {
const groups = useEpisodeGroups(entityId);
const options = structureOptions(groups.data);
const current = groups.data?.current?.groupId ?? null;
const [picked, setPicked] = useState<string | null | undefined>(undefined);
const selected = picked === undefined ? current : picked;
const viewingOther = selected !== current;
const structure = useEpisodeStructure(entityId, selected, viewingOther);
const setGroup = useSetEpisodeGroup(entityId);
const [confirm, setConfirm] = useState(false);
if (!hasStructureChoice(groups.data)) return <>{children}</>;
const target = options.find((o) => o.groupId === selected);
return (
<div className="space-y-3">
<div className="space-y-1.5">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Layers className="h-3.5 w-3.5" aria-hidden />
季集结构
</p>
<StructureTrack options={options} value={selected} onChange={setPicked} />
</div>
{!viewingOther ? (
children
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-dashed border-border px-3 py-2">
<p className="text-caption text-muted-foreground">
预览「{target?.label ?? DEFAULT_STRUCTURE_LABEL}」的分季,已入库按 TMDB 原季集对照。
</p>
<Button size="sm" disabled={setGroup.isPending} onClick={() => setConfirm(true)}>
改用这套结构
</Button>
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<StructurePreview seasons={structure.data?.seasons ?? []} />
)}
</div>
)}
<ConfirmDialog
open={confirm}
onOpenChange={setConfirm}
title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
description={switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}
confirmLabel="改用"
onConfirm={async () => {
try {
resultToast(await setGroup.mutateAsync(selected));
setPicked(undefined);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
}}
/>
</div>
);
}
/**
* 订阅表单的选季(学 MP):作品有剧集组时用轨道 + 季卡片代替下拉;选了非当前结构的季要先确认改作品设置,
* 改完按新结构的同号季单元回填。没有剧集组时返回 null,表单照用下拉。
*/
export function SubscriptionSeasonPicker({
entityId,
value,
onChange,
disabled,
}: {
entityId: string;
/** 选中的季单元 id;"" = 全部季 */
value: string;
onChange: (seasonUnitId: string) => void;
disabled?: boolean;
}) {
const groups = useEpisodeGroups(entityId);
const current = groups.data?.current?.groupId ?? null;
const [picked, setPicked] = useState<string | null | undefined>(undefined);
const selected = picked === undefined ? current : picked;
const structure = useEpisodeStructure(entityId, selected, hasStructureChoice(groups.data));
const setGroup = useSetEpisodeGroup(entityId);
const [ask, setAsk] = useState<EpisodeStructureSeason | null>(null);
const [after, setAfter] = useState<number | null>(null);
const currentData = structure.data?.current ? structure.data : null;
// 改完结构后,按新结构里同号季的季单元回填
useEffect(() => {
if (after === null || !currentData) return;
const hit = currentData.seasons.find((s) => s.seasonNumber === after);
if (hit?.seasonUnitId) onChange(hit.seasonUnitId);
setAfter(null);
}, [after, currentData, onChange]);
if (!hasStructureChoice(groups.data)) return null;
const options = structureOptions(groups.data);
const seasons = structure.data?.seasons ?? [];
const selectedSeason = structure.data?.current
? (seasons.find((s) => s.seasonUnitId && s.seasonUnitId === value)?.seasonNumber ?? null)
: null;
const target = options.find((o) => o.groupId === selected);
const pick = (s: EpisodeStructureSeason) => {
if (structure.data?.current) {
onChange(s.seasonUnitId && s.seasonUnitId !== value ? s.seasonUnitId : "");
return;
}
setAsk(s);
};
return (
<div className="space-y-2">
<StructureTrack options={options} value={selected} onChange={setPicked} />
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
size="sm"
variant="outline"
aria-pressed={value === ""}
disabled={disabled}
className={value === "" ? "pill-selected" : undefined}
onClick={() => onChange("")}
>
全部季
</Button>
{!structure.data?.current && structure.data ? (
<span className="text-meta text-muted-foreground">
不是作品当前的季集结构,选季时会先确认改用「{target?.label}」
</span>
) : null}
</div>
{structure.isPending ? (
<LoadingState rows={2} />
) : structure.error ? (
<ErrorState error={structure.error} onRetry={() => structure.refetch()} />
) : (
<SeasonCardGrid
seasons={seasons.filter((s) => s.seasonNumber > 0 || s.episodeCount > 0)}
selected={selectedSeason}
onPick={pick}
disabled={disabled || setGroup.isPending}
/>
)}
<ConfirmDialog
open={!!ask}
onOpenChange={(o) => !o && setAsk(null)}
title={`改按「${target?.label ?? DEFAULT_STRUCTURE_LABEL}」分季?`}
description={`订阅「${ask ? seasonTitle(ask) : ""}」要先改作品设置。${switchDescription(target?.label ?? DEFAULT_STRUCTURE_LABEL)}`}
confirmLabel="改用并选季"
onConfirm={async () => {
const s = ask;
try {
resultToast(await setGroup.mutateAsync(selected));
setPicked(undefined);
if (s) setAfter(s.seasonNumber);
setAsk(null);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
}}
/>
</div>
);
}