Files
UHD_Media_Tool/apps/web/app/components/subscriptions/overview-item.tsx
T

250 lines
9.1 KiB
TypeScript

import type { SubscriptionOverviewItem } from "@umt/contracts";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
import { cn } from "~/lib/utils.ts";
import { SubscriptionMenu } from "./actions.tsx";
import {
autoFillText,
foldNames,
progressParts,
progressText,
SUB_KIND_LABEL,
seasonLabel,
slotBadge,
} from "./model.ts";
// 总览卡片 / 列表行(设计 §1.2)。点卡片 = 查看对比。
type Item = SubscriptionOverviewItem;
export const itemTitle = (x: Item) => x.mediaEntityTitle ?? x.titleQuery ?? "未命名";
/** 状态角标:只标 暂停 / 待定 / 洗版中 / 等待新季 */
function Flags({ x }: { x: Item }) {
return (
<>
{x.group === "paused" ? <StatusBadge tone="neutral">已暂停</StatusBadge> : null}
{x.group === "pending" ? (
<StatusBadge tone="warning">{x.mediaEntityId ? "待定" : "不在媒体库"}</StatusBadge>
) : null}
{x.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
{x.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
</>
);
}
/** 进度:数字 + 已入库/在途两段细条;总未知写"持续追踪" */
export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mode">; className?: string }) {
const parts = progressParts(x.progress);
const unknown = x.progress.total === null;
return (
<div className={cn("min-w-0", className)}>
<div className="flex items-baseline justify-between gap-2 text-meta">
<span className="font-medium text-foreground tabular-nums">
{progressText(x.progress)}
{x.progress.inflight > 0 ? (
<span className="font-normal text-muted-foreground"> · 下载中 {x.progress.inflight}</span>
) : null}
</span>
{unknown ? <span className="text-muted-foreground">持续追踪</span> : null}
</div>
<div
className={cn(
"mt-1 flex h-1 w-full gap-px overflow-hidden rounded-full bg-foreground/[0.08]",
unknown &&
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
)}
role="img"
aria-label={`已整理 ${x.progress.have},下载中 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
>
{unknown ? null : (
<>
<div className="h-full bg-foreground/70" style={{ width: `${parts.have * 100}%` }} />
<div
className="h-full bg-[repeating-linear-gradient(135deg,var(--color-foreground)_0_2px,transparent_2px_4px)] opacity-50"
style={{ width: `${parts.inflight * 100}%` }}
/>
</>
)}
</div>
</div>
);
}
/** 只按标题订阅、没关联媒体库作品时的提示:这类订阅不搜索也不下载(L5) */
export const UNBOUND_HINT = "未关联作品,不会自动搜索和下载";
export function SlotBadges({ slots, max = 4, unbound }: { slots: Item["slots"]; max?: number; unbound?: boolean }) {
if (unbound) return <span className="text-meta text-warning">{UNBOUND_HINT}</span>;
if (slots.length === 0) return <span className="text-meta text-muted-foreground">没有收录版本</span>;
return (
<div className="flex min-w-0 flex-wrap gap-1">
{slots.slice(0, max).map((s) => {
const b = slotBadge(s);
return (
<StatusBadge key={s.slotId} tone={b.tone} className="max-w-full">
<span className="max-w-24 truncate text-foreground" title={s.name}>
{s.name}
</span>
<span className="tabular-nums">{b.label}</span>
</StatusBadge>
);
})}
{slots.length > max ? <span className="text-meta text-muted-foreground">+{slots.length - max}</span> : null}
</div>
);
}
function nextSearchText(x: Item) {
if (!x.searchEnabled) return "搜索已关";
if (x.status === "paused" || x.status === "completed") return "—";
return x.nextSearchAt ? relTime(x.nextSearchAt) : "待排期";
}
function Meta({ x }: { x: Item }) {
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-meta">
<dt className="text-muted-foreground">上次更新</dt>
<dd className="truncate text-foreground tabular-nums">{relTime(x.lastUpdatedAt)}</dd>
<dt className="text-muted-foreground">下次搜索</dt>
<dd className="truncate text-foreground tabular-nums">{nextSearchText(x)}</dd>
<dt className="text-muted-foreground">站点</dt>
<dd className="truncate text-foreground" title={x.sites.map((s) => s.name).join("、")}>
{foldNames(x.sites.map((s) => s.name))}
</dd>
<dt className="text-muted-foreground">规则组</dt>
<dd className="truncate text-foreground" title={x.ruleGroups.map((s) => s.name).join("、")}>
{foldNames(
x.ruleGroups.map((s) => s.name),
2,
"未设置",
)}
</dd>
</dl>
);
}
function TitleLine({ x }: { x: Item }) {
const season = seasonLabel(x);
return (
<div className="min-w-0">
<p className="truncate text-body font-semibold text-foreground" title={itemTitle(x)}>
{itemTitle(x)}
</p>
<p className="truncate text-meta text-muted-foreground">
{[x.year, SUB_KIND_LABEL[x.mediaKind], season].filter(Boolean).join(" · ")}
</p>
</div>
);
}
export function SubscriptionCard({
x,
onEdit,
selected,
onSelect,
}: {
x: Item;
onEdit: () => void;
selected?: boolean;
onSelect?: (v: boolean) => void;
}) {
return (
<article
className={cn(
"pg-slab group relative flex min-w-0 flex-col gap-3 rounded-xl p-3.5 transition-shadow duration-micro hover:shadow-md",
selected && "ring-2 ring-foreground/40",
)}
>
<div className="flex min-w-0 items-start gap-3">
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} className="h-20 w-14" />
<div className="min-w-0 flex-1">
<Link
to={consolePath("subscriptions", x.id)}
className="block min-w-0 rounded-md outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring"
>
<TitleLine x={x} />
</Link>
<div className="relative mt-1.5 flex flex-wrap gap-1">
<Flags x={x} />
</div>
</div>
<div className="relative z-10 flex items-center gap-1">
{onSelect ? (
<input
type="checkbox"
aria-label={`选择 ${itemTitle(x)}`}
checked={!!selected}
onChange={(e) => onSelect(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
) : null}
<SubscriptionMenu sub={x} onEdit={onEdit} />
</div>
</div>
<ProgressBlock x={x} />
<SlotBadges slots={x.slots} unbound={!x.mediaEntityId} />
<Meta x={x} />
<p className="text-meta text-muted-foreground">{autoFillText(x.autoFill)}</p>
</article>
);
}
/** 列表视图的一行(桌面表格化,手机折成两行) */
export function SubscriptionRow({
x,
onEdit,
selected,
onSelect,
}: {
x: Item;
onEdit: () => void;
selected?: boolean;
onSelect?: (v: boolean) => void;
}) {
return (
<li
className={cn(
"relative grid min-w-0 grid-cols-[auto_1fr_auto] items-center gap-3 px-3 py-2.5 hover:bg-foreground/[0.03] md:grid-cols-[auto_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,2fr)_7rem_7rem_auto]",
selected && "bg-foreground/[0.05]",
)}
>
<div className="relative z-10 flex items-center gap-2">
{onSelect ? (
<input
type="checkbox"
aria-label={`选择 ${itemTitle(x)}`}
checked={!!selected}
onChange={(e) => onSelect(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-foreground"
/>
) : null}
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} small className="h-10 w-7" />
</div>
<Link
to={consolePath("subscriptions", x.id)}
className="min-w-0 outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"
>
<TitleLine x={x} />
</Link>
<ProgressBlock x={x} className="hidden md:block" />
<div className="relative hidden min-w-0 md:block">
<SlotBadges slots={x.slots} max={3} unbound={!x.mediaEntityId} />
</div>
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{relTime(x.lastUpdatedAt)}</span>
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{nextSearchText(x)}</span>
<div className="relative z-10 flex items-center gap-1">
<span className="hidden flex-wrap gap-1 lg:flex">
<Flags x={x} />
</span>
<SubscriptionMenu sub={x} onEdit={onEdit} />
</div>
<div className="col-span-3 -mt-1 md:hidden">
<ProgressBlock x={x} />
</div>
</li>
);
}