GET /img/tmdb/{size}/{file} ported from MP ImageHelper / fetch_image
(sanitized cache path under DATA_DIR/cache/images, GLOBAL_IMAGE_CACHE
read switch, md5 ETag / 304, day-based cleanup job). Cache module adds
cache.sweep / cache.images_clean jobs and /cache/tmdb, /cache/images
list and clear endpoints. Library list/detail and the search palette
show posters with the neutral placeholder as fallback; metadata settings
get TMDB cache and image cache cards. Runbook docs/runbook/cache.md and
porting notes docs/porting/moviepilot-tmdb-cache.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
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 (
|
|
<section className={cn("pg-slab min-w-0 rounded-xl", className)}>
|
|
{title || actions ? (
|
|
<header className="flex flex-wrap items-start justify-between gap-2 border-b border-border/60 px-4 py-3 sm:px-5">
|
|
<div className="min-w-0">
|
|
{title ? <h2 className="text-body font-semibold text-foreground">{title}</h2> : null}
|
|
{description ? <p className="mt-0.5 text-meta text-muted-foreground">{description}</p> : null}
|
|
</div>
|
|
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
|
</header>
|
|
) : null}
|
|
<div className={cn("px-4 py-4 sm:px-5", bodyClassName)}>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** 中性小标签(规则摘要、别名等),不是状态 */
|
|
export function Chip({ children, className, mono }: { children: ReactNode; className?: string; mono?: boolean }) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-foreground/[0.03] px-2 py-0.5 text-meta break-all text-foreground",
|
|
mono && "font-mono",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ChipList({ items, empty = "未设置" }: { items: ReactNode[]; empty?: string }) {
|
|
if (items.length === 0) return <span className="text-meta text-muted-foreground">{empty}</span>;
|
|
return (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{items.map((it, i) => (
|
|
<Chip key={i}>{it}</Chip>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 语气 + 文案的状态徽章(label 映射表里的 {label,tone}) */
|
|
export function Tone({ v, className }: { v: { label: string; tone: StatusTone } | undefined; className?: string }) {
|
|
if (!v) return <StatusBadge className={className}>未知</StatusBadge>;
|
|
return (
|
|
<StatusBadge tone={v.tone} className={className}>
|
|
{v.label}
|
|
</StatusBadge>
|
|
);
|
|
}
|
|
|
|
/** 多选胶囊:选中用静态 pill-selected;ordered 时显示选择顺序(软偏好"越靠前越好") */
|
|
export function ChipToggleGroup<T extends string>({
|
|
options,
|
|
value,
|
|
onChange,
|
|
ordered = false,
|
|
label,
|
|
}: {
|
|
options: readonly { value: T; label: string }[];
|
|
value: readonly T[];
|
|
onChange: (v: T[]) => void;
|
|
ordered?: boolean;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<fieldset aria-label={label} className="m-0 flex min-w-0 flex-wrap gap-1.5 border-0 p-0">
|
|
{options.map((o) => {
|
|
const idx = value.indexOf(o.value);
|
|
const on = idx >= 0;
|
|
return (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
aria-pressed={on}
|
|
onClick={() => onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])}
|
|
className={cn(
|
|
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-meta font-medium transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
|
|
on
|
|
? "pill-selected text-foreground"
|
|
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
|
)}
|
|
>
|
|
{ordered && on ? <span className="tabular-nums text-muted-foreground">{idx + 1}.</span> : null}
|
|
{o.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
const KIND_ICON: Record<string, LucideIcon> = { 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 (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
loading="lazy"
|
|
onError={() => 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 (
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
"relative grid shrink-0 place-items-center overflow-hidden rounded-lg border border-border bg-foreground/[0.05] text-muted-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
<span className={cn("font-semibold text-foreground/35", small ? "text-lead" : "text-title")}>{glyph}</span>
|
|
{small ? null : <Icon className="absolute right-1.5 bottom-1.5 h-3.5 w-3.5 opacity-60" />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 细线进度(不做实心填充) */
|
|
export function ProgressLine({ value, className }: { value: number; className?: string }) {
|
|
const pct = Math.max(0, Math.min(1, value)) * 100;
|
|
return (
|
|
<div className={cn("h-1 w-full overflow-hidden rounded-full bg-foreground/[0.08]", className)}>
|
|
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 相对时间("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);
|