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 (
);
}
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);