import { tmdbImageUrl } from "@umt/contracts"; import type { LucideIcon } from "lucide-react"; import { Clapperboard, Film, Layers, Tv } from "lucide-react"; import { type ReactNode, useState } from "react"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { cn } from "~/lib/utils.ts"; // W3-2 页面内的小零件:区块卡片、标签胶囊、多选胶囊、封面占位。 /** 内容卡:pg-slab + 标题行。卡内子块用 border 分隔,不再叠玻璃 */ export function Panel({ title, description, actions, children, className, bodyClassName, }: { title?: ReactNode; description?: ReactNode; actions?: ReactNode; children: ReactNode; className?: string; bodyClassName?: string; }) { return (
{title || actions ? (
{title ?

{title}

: null} {description ?

{description}

: null}
{actions ?
{actions}
: null}
) : null}
{children}
); } /** 中性小标签(规则摘要、别名等),不是状态 */ export function Chip({ children, className, mono }: { children: ReactNode; className?: string; mono?: boolean }) { return ( {children} ); } export function ChipList({ items, empty = "未设置" }: { items: ReactNode[]; empty?: string }) { if (items.length === 0) return {empty}; return (
{items.map((it, i) => ( {it} ))}
); } /** 语气 + 文案的状态徽章(label 映射表里的 {label,tone}) */ export function Tone({ v, className }: { v: { label: string; tone: StatusTone } | undefined; className?: string }) { if (!v) return 未知; return ( {v.label} ); } /** 多选胶囊:选中用静态 pill-selected;ordered 时显示选择顺序(软偏好"越靠前越好") */ export function ChipToggleGroup({ options, value, onChange, ordered = false, label, }: { options: readonly { value: T; label: string }[]; value: readonly T[]; onChange: (v: T[]) => void; ordered?: boolean; label: string; }) { return (
{options.map((o) => { const idx = value.indexOf(o.value); const on = idx >= 0; return ( ); })}
); } const KIND_ICON: Record = { movie: Film, series: Tv, variety: Clapperboard, collection: Layers }; /** media_entity.metadata 里的最终海报路径(metadata.posterPath.value) */ export function posterPathOf(metadata: unknown): string | null { const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value; return typeof v === "string" && v ? v : null; } /** * 封面:有 TMDB 海报路径时经图片代理显示海报,加载失败或没有海报时回退无彩占位(类型图标 + 首字)。 */ export function CoverPlaceholder({ title, kind, small = false, className, posterPath, }: { title: string; kind: string; /** 列表缩略:只显示首字,不叠类型图标 */ small?: boolean; className?: string; /** TMDB poster_path */ posterPath?: string | null | undefined; }) { const [failed, setFailed] = useState(false); const src = failed ? null : tmdbImageUrl(posterPath, small ? "w154" : "w342"); if (src) { return ( setFailed(true)} className={cn("shrink-0 rounded-lg border border-border bg-foreground/[0.05] object-cover", className)} /> ); } const Icon = KIND_ICON[kind] ?? Film; const glyph = title.replace(/^w32-/, "").trim().charAt(0) || "?"; return (
{glyph} {small ? null : }
); } /** 细线进度(不做实心填充) */ export function ProgressLine({ value, className }: { value: number; className?: string }) { const pct = Math.max(0, Math.min(1, value)) * 100; return (
); } /** 相对时间("3 小时前"),给列表用;详情仍用 formatDateTime */ export function relTime(iso: string | null | undefined): string { if (!iso) return "—"; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return iso; const s = Math.round((Date.now() - t) / 1000); const abs = Math.abs(s); const suffix = s >= 0 ? "前" : "后"; if (abs < 60) return "刚刚"; if (abs < 3600) return `${Math.round(abs / 60)} 分钟${suffix}`; if (abs < 86400) return `${Math.round(abs / 3600)} 小时${suffix}`; if (abs < 86400 * 30) return `${Math.round(abs / 86400)} 天${suffix}`; return new Date(iso).toLocaleDateString("zh-CN"); } /** 逗号/顿号/空白分隔的列表 */ export const splitList = (s: string) => s .split(/[,,、\s]+/) .map((x) => x.trim()) .filter(Boolean);