Release Row
一条资源一行:模切白边的小封面、完整换行的标题、粉色发布者和最多三个彩色标签,然后是大小、做种、时间三列和一直可见的图标方块。
release-row-demo.tsx
import { ReleaseRow, ReleaseRowDateRule, ReleaseRowList } from "@/components/release-row"
import { Badge } from "@/components/ui/badge"
const magnet = (hash: string) => `magnet:?xt=urn:btih:${hash.padEnd(40, "0")}`
export default function ReleaseRowDemo() {
return (
<ReleaseRowList>
<ReleaseRowDateRule
label="今天"
trailing={
<Badge variant="candy" tilt={3} className="anibt-num">
最新
</Badge>
}
/>
<ReleaseRow
href="#release-1"
cover="/demo/covers/yozakura.svg"
title="[喵萌奶茶屋&LoliHouse] 夜樱终电 / Yozakura: After the Last Train - 06 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]"
publisher={{ name: "喵萌奶茶屋", href: "#group-nekomoe" }}
episode="第 6 集"
resolution="1080p"
languages={["简", "繁", "日"]}
subtitleType="内封字幕"
specs={["HEVC", "10bit", "WebRip", "AAC", "MKV"]}
fileSize="783.6 MB"
seeders={16}
leechers={29}
time="今天 18:08"
dateTime="2026-10-06T18:08:00+08:00"
magnet={magnet("5e1f2a")}
torrentHref="#torrent-1"
/>
<ReleaseRow
href="#release-2"
cover="/demo/covers/stardust-line.svg"
title="[ANi] 星屑列车 - 12 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"
publisher={{ name: "ANi", href: "#group-ani" }}
episode="第 12 集"
resolution="1080p"
languages={["繁"]}
subtitleType="内嵌字幕"
specs={["AVC", "WEB-DL", "AAC", "MP4"]}
fileSize="352.4 MB"
seeders={66}
leechers={5}
time="今天 12:00"
dateTime="2026-10-06T12:00:00+08:00"
magnet={magnet("a07c33")}
torrentHref="#torrent-2"
/>
<ReleaseRow
href="#release-3"
cover="/demo/covers/summer-coast.svg"
title="[绿茶字幕组] 八月的七日海岸 / Summer Coast [12][WebRip][1080p][简日内嵌]"
publisher={{ name: "绿茶字幕组", href: "#group-greentea" }}
episode="第 12 集"
resolution="1080p"
languages={["简", "日"]}
subtitleType="内嵌字幕"
specs={["WebRip", "MKV"]}
fileSize="410.4 MB"
seeders={null}
leechers={null}
time="今天 11:47"
dateTime="2026-10-06T11:47:00+08:00"
magnet={magnet("77d0e1")}
torrentHref="#torrent-3"
/>
<ReleaseRowDateRule label="昨天" />
<ReleaseRow
href="#release-4"
cover="/demo/covers/afterschool-band.svg"
title="[北宇治字幕组] 放课后屋顶乐队 / Afterschool Band [01-12 Fin][BDRip 1080p HEVC-10bit FLAC][简繁内封]"
publisher={{ name: "北宇治字幕组", href: "#group-kitauji" }}
category="合集"
episode="01–12"
resolution="1080p"
languages={["简", "繁"]}
subtitleType="内封字幕"
specs={["HEVC", "10bit", "BDRip", "FLAC", "MKV"]}
fileSize="12.1 GB"
seeders={128}
leechers={7}
time="昨天 21:40"
dateTime="2026-10-05T21:40:00+08:00"
magnet={magnet("c3b9aa")}
torrentHref={null}
/>
</ReleaseRowList>
)
}安装
npx shadcn@latest add @anibt/release-row第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/badge、@anibt/button、@anibt/tooltip。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install lucide-react - 3. 它还用到
npx shadcn@latest add @anibt/badge @anibt/button @anibt/tooltip - 4. 把下面的文件放进项目components/release-row.tsx
"use client" import * as React from "react" import { ArrowDownIcon, ArrowUpIcon, CheckIcon, DownloadIcon, MagnetIcon } from "lucide-react" import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" /** * ReleaseRow — one release as one row (AniBT 「贴纸手账」, after anibt.net's * 最新资源 list). * * **Wide form** — when its `ReleaseRowList` is at least 760px wide (a * container query, so a row in a narrow column keeps the phone form): a 40×56 * cover with the die-cut rim that tips under the pointer, the title over the * facts, then size, swarm and age as three columns a reader scans down, then * the 32px icon squares. The title wraps in full and is never cut — the * release name is what a reader picks a version by. * * **The facts line** opens with the publisher in pink, then 第 N 集 and at most * three coloured tags — resolution (blue), the languages folded into one pink * tag, the subtitle type (neutral) — then the remaining specs as plain muted * text from 768px up. * * **Phone form** (narrower lists, and `form="stacked"` at every width): the * cover beside the title and facts, then one line of figures with 40px squares * (44px targets) at its end. **Compact** (`form="compact"`): no cover, no * publisher, the title on one line from 1024, size over age in a 92px column. * * The title link (`href`, or `link={<Link href="…" />}`) is stretched over the * row, so the whole row opens the release; the publisher link and the action * squares sit above it. Everything is a prop — the row fetches and formats * nothing: pass `fileSize="1.21 GB"`, `time="今天 18:08"` with its ISO * `dateTime`, and the swarm figures (`null` is "not measured" and prints a * dash, never a zero). */ type ReleaseRowLinkElement = React.ReactElement<{ className?: string; children?: React.ReactNode }> type ReleaseRowPublisher = { name: string href?: string link?: ReleaseRowLinkElement } type ReleaseRowCover = { src: string srcSet?: string alt?: string } type ReleaseRowProps = Omit<React.ComponentProps<"div">, "title"> & { title: React.ReactNode /** The full title for the browser tooltip; defaults to `title` when it is a string. */ titleTooltip?: string /** `lang` of the title (`ja` for a Japanese release name). */ titleLang?: string /** Opens the release; stretched over the whole row. */ href?: string /** A router link element instead of `href`. */ link?: ReleaseRowLinkElement /** The anime's cover (shown at 40×56; give a ≥120w source for 3× screens). */ cover?: string | ReleaseRowCover | null /** Above the fold: load the cover eagerly. */ priority?: boolean publisher?: string | ReleaseRowPublisher | null /** A category tag in front of the episode (合集, 剧场版…). */ category?: React.ReactNode /** 第 12 集, in the round face. */ episode?: React.ReactNode /** The blue tag: 1080p, 2160p. */ resolution?: string /** Folded into one pink tag: 简中·繁中. */ languages?: string[] /** The neutral tag: 内封字幕, 内嵌字幕. */ subtitleType?: string /** Everything else, printed as plain muted text from 768px: AVC · WEB-DL · AAC · MKV. */ specs?: string[] /** Pre-formatted size: 1.21 GB. */ fileSize?: string /** Seeders; `null` / leaving it out prints a dash. */ seeders?: number | null leechers?: number | null /** Pre-formatted age: 今天 18:08, 3 小时前. */ time?: React.ReactNode /** ISO date of `time`, for `<time dateTime>`. */ dateTime?: string /** The magnet address: draws the copy square. `null` keeps a disabled square (the slot exists, this release has none). */ magnet?: string | null /** The .torrent URL: draws the download square. `null` keeps a disabled square. */ torrentHref?: string | null /** Called after the magnet is copied (show a toast here). */ onMagnetCopied?: () => void /** Your own squares (`ReleaseRowAction`s); replaces the magnet / torrent pair. */ actions?: React.ReactNode /** `wide` follows the list's width; `stacked` holds the phone form; `compact` is the dense panel row. */ form?: "wide" | "stacked" | "compact" } /** The list: a panel with the rows stitched together. `plain` drops the panel. */ function ReleaseRowList({ className, plain = false, children, ...props }: React.ComponentProps<"div"> & { plain?: boolean }) { return ( <TooltipProvider> <div data-slot="release-row-list" className={cn("@container/rr-list w-full min-w-0", plain ? null : "anibt-sheet px-1", className)} {...props} > {children} </div> </TooltipProvider> ) } /** A date inside a list: a small blue date sticker on a stitched rule, with an optional sticker at the end. */ function ReleaseRowDateRule({ label, trailing, className, ...props }: Omit<React.ComponentProps<"div">, "children"> & { label: React.ReactNode; trailing?: React.ReactNode }) { return ( <div data-slot="release-row-date" className={cn("flex items-center gap-3 px-3.5 pt-4 pb-1 md:px-3", className)} {...props} > <Badge variant="sticker" className="shrink-0 text-anibt-time-ink" tilt={-2}> {label} </Badge> <span aria-hidden="true" className="anibt-stitch min-w-0 flex-1" /> {trailing} </div> ) } const STRETCHED = "outline-hidden after:absolute after:inset-0 after:content-[''] focus-visible:after:rounded-[10px] focus-visible:after:outline-2 focus-visible:after:outline-ring focus-visible:after:outline-solid" function ReleaseRow({ title, titleTooltip, titleLang, href, link, cover, priority = false, publisher, category, episode, resolution, languages, subtitleType, specs, fileSize, seeders, leechers, time, dateTime, magnet, torrentHref, onMagnetCopied, actions, form = "wide", className, ...props }: ReleaseRowProps) { const coverInfo = typeof cover === "string" ? { src: cover } : cover const showCover = Boolean(coverInfo) && form !== "compact" const pub = typeof publisher === "string" ? { name: publisher } : publisher const tooltip = titleTooltip ?? (typeof title === "string" ? title : undefined) const titleNode = link ? React.cloneElement(link, { className: cn(STRETCHED, link.props.className), children: title }) : href ? ( <a href={href} className={STRETCHED}> {title} </a> ) : title const squares = actions ?? (magnet !== undefined || torrentHref !== undefined ? ( <> {magnet !== undefined ? <ReleaseRowCopyAction value={magnet} onCopied={onMagnetCopied} /> : null} {torrentHref !== undefined ? ( <ReleaseRowAction label="下载种子" href={torrentHref ?? undefined} disabled={torrentHref === null} download> <DownloadIcon /> </ReleaseRowAction> ) : null} </> ) : null) return ( <div data-slot="release-row" data-form={form} data-cover={showCover ? "" : undefined} className={cn( "group/rr relative grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 px-3.5 py-3 [grid-template-areas:'m_m'_'s_a'] md:px-3", "data-[cover]:grid-cols-[40px_minmax(0,1fr)_auto] data-[cover]:[grid-template-areas:'t_m_m'_'s_s_a']", // Rows of one list are stitched together, inset by the row's padding. "[[data-slot=release-row]+&]:before:pointer-events-none [[data-slot=release-row]+&]:before:absolute [[data-slot=release-row]+&]:before:inset-x-3.5 [[data-slot=release-row]+&]:before:top-0 [[data-slot=release-row]+&]:before:h-px [[data-slot=release-row]+&]:before:bg-(image:--anibt-stitch) [[data-slot=release-row]+&]:before:bg-size-[8px_1px] [[data-slot=release-row]+&]:before:bg-repeat-x [[data-slot=release-row]+&]:before:content-[''] md:[[data-slot=release-row]+&]:before:inset-x-3", // Wide: six tracks once the list is 760px wide. "@min-[760px]/rr-list:data-[form=wide]:grid-cols-[minmax(0,1fr)_80px_96px_76px_auto] @min-[760px]/rr-list:data-[form=wide]:gap-x-4 @min-[760px]/rr-list:data-[form=wide]:[grid-template-areas:none] @min-[760px]/rr-list:data-[form=wide]:data-[cover]:grid-cols-[40px_minmax(0,1fr)_80px_96px_76px_auto]", // Compact: one flex line. "data-[form=compact]:flex data-[form=compact]:min-h-[50px] data-[form=compact]:gap-3 data-[form=compact]:py-1.5 lg:data-[form=compact]:h-[50px]", className )} {...props} > {showCover && coverInfo ? ( <div data-slot="release-row-thumb" className="anibt-rim relative h-14 w-10 self-start overflow-hidden rounded-[6px] bg-muted transition-transform duration-300 ease-anibt-spring [grid-area:t] motion-safe:group-hover/rr:scale-[1.04] motion-safe:group-hover/rr:-rotate-[4deg] @min-[760px]/rr-list:group-data-[form=wide]/rr:self-center @min-[760px]/rr-list:group-data-[form=wide]/rr:[grid-area:auto]" > {/* eslint-disable-next-line @next/next/no-img-element -- a registry component: no framework image */} <img src={coverInfo.src} srcSet={coverInfo.srcSet} sizes={coverInfo.srcSet ? "40px" : undefined} alt={coverInfo.alt ?? ""} width={40} height={56} loading={priority ? "eager" : "lazy"} fetchPriority={priority ? "high" : undefined} decoding="async" className="absolute inset-0 size-full object-cover" /> </div> ) : null} <div data-slot="release-row-main" className="grid min-w-0 gap-1.5 self-start [grid-area:m] group-data-[form=compact]/rr:flex-1 group-data-[form=compact]/rr:gap-0.5 group-data-[form=compact]/rr:self-center @min-[760px]/rr-list:group-data-[form=wide]/rr:self-center @min-[760px]/rr-list:group-data-[form=wide]/rr:[grid-area:auto]" > <h3 data-slot="release-row-title" title={tooltip} lang={titleLang} className="m-0 min-w-0 text-[14px] leading-5 font-semibold [overflow-wrap:anywhere] text-foreground transition-colors duration-150 group-hover/rr:text-accent-foreground group-data-[form=compact]/rr:text-[13px] group-data-[form=compact]/rr:leading-[18px] lg:group-data-[form=compact]/rr:anibt-ell" > {titleNode} </h3> <ReleaseRowFacts publisher={form === "compact" ? null : pub} category={category} episode={episode} resolution={resolution} languages={languages} subtitleType={subtitleType} specs={specs} /> </div> <div data-slot="release-row-stats" className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap [grid-area:s] [&>:empty]:hidden group-data-[form=compact]/rr:w-[92px] group-data-[form=compact]/rr:flex-none group-data-[form=compact]/rr:flex-col group-data-[form=compact]/rr:items-end group-data-[form=compact]/rr:gap-0.5 @min-[760px]/rr-list:group-data-[form=wide]/rr:contents @min-[760px]/rr-list:group-data-[form=wide]/rr:[&>:empty]:block" > <span className="anibt-num text-[13px] text-foreground">{fileSize ?? ""}</span> {form === "compact" ? ( <span /> ) : ( <span>{seeders !== undefined || leechers !== undefined ? <ReleaseRowPeers seeders={seeders} leechers={leechers} /> : null}</span> )} {time ? ( <time dateTime={dateTime} suppressHydrationWarning className="text-[12px] text-muted-foreground"> {time} </time> ) : ( <span /> )} </div> <div data-slot="release-row-actions" className="relative z-10 flex shrink-0 items-center justify-end gap-2 justify-self-end [grid-area:a] @min-[760px]/rr-list:group-data-[form=wide]/rr:[grid-area:auto]" > {squares} </div> </div> ) } function ReleaseRowFacts({ publisher, category, episode, resolution, languages, subtitleType, specs, }: { publisher: ReleaseRowPublisher | null | undefined category?: React.ReactNode episode?: React.ReactNode resolution?: string languages?: string[] subtitleType?: string specs?: string[] }) { const langs = (languages ?? []).filter((value) => value.trim()) const rest = (specs ?? []).filter((value) => value.trim()) const hasFacts = Boolean(category || episode || resolution || langs.length || subtitleType || rest.length) if (!publisher && !hasFacts) return null const publisherClass = "anibt-ell max-w-64 font-semibold text-accent-foreground group-data-[form=compact]/rr:hidden" const publisherNode = publisher ? ( publisher.link ? ( React.cloneElement(publisher.link, { className: cn(publisherClass, "relative z-10 hover:underline hover:underline-offset-2", publisher.link.props.className), children: publisher.name, }) ) : publisher.href ? ( <a href={publisher.href} title={publisher.name} className={cn(publisherClass, "relative z-10 hover:underline hover:underline-offset-2")}> {publisher.name} </a> ) : ( <span title={publisher.name} className={publisherClass}> {publisher.name} </span> ) ) : null return ( <div data-slot="release-row-facts" className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[12px] leading-5 text-anibt-ink2 lg:group-data-[form=compact]/rr:flex-nowrap lg:group-data-[form=compact]/rr:overflow-hidden" > {publisherNode} {publisher && hasFacts ? <i aria-hidden="true" className="size-[3px] flex-none rounded-full bg-muted-foreground" /> : null} {category ? <Badge face="text">{category}</Badge> : null} {episode ? <span className="anibt-num whitespace-nowrap">{episode}</span> : null} {resolution ? <Badge variant="res">{resolution}</Badge> : null} {langs.length ? ( <Badge variant="lang" face="text" title={langs.join(" / ")}> {langs.join("·")} </Badge> ) : null} {subtitleType ? <Badge face="text">{subtitleType}</Badge> : null} {rest.length ? ( <span data-slot="release-row-specs" className="hidden min-w-0 text-muted-foreground md:inline"> {rest.map((value, index) => ( <React.Fragment key={`${value}-${index}`}> {index > 0 ? <span aria-hidden="true"> · </span> : null} <span className="anibt-num">{value}</span> </React.Fragment> ))} </span> ) : null} </div> ) } /** * The swarm: ↑ seeders (mint once a live swarm is measured), ↓ leechers. An * unmeasured figure is a dash, never a zero the page has not read. Each figure * carries its word as its accessible name. */ function ReleaseRowPeers({ seeders, leechers, seedersLabel = "做种", leechersLabel = "下载中", className, }: { seeders?: number | null leechers?: number | null seedersLabel?: string leechersLabel?: string className?: string }) { const show = (value: number | null | undefined) => value === null || value === undefined || !Number.isFinite(value) ? "–" : String(Math.max(0, Math.trunc(value))) const live = typeof seeders === "number" && seeders > 0 return ( <span data-slot="release-row-peers" className={cn("inline-flex items-center gap-2 font-anibt-display text-[13px] font-medium text-muted-foreground", className)} > <span title={`${seedersLabel} ${show(seeders)}`} aria-label={`${seedersLabel} ${show(seeders)}`} className={cn("inline-flex items-center gap-0.5 tabular-nums", live && "text-anibt-ok")} > <ArrowUpIcon className="size-3.5" aria-hidden="true" /> {show(seeders)} </span> <span title={`${leechersLabel} ${show(leechers)}`} aria-label={`${leechersLabel} ${show(leechers)}`} className="inline-flex items-center gap-0.5 tabular-nums" > <ArrowDownIcon className="size-3.5" aria-hidden="true" /> {show(leechers)} </span> </span> ) } const SQUARE = "size-10 rounded-[12px] md:size-8 md:rounded-[10px] group-data-[form=stacked]/rr:md:size-10 group-data-[form=stacked]/rr:md:rounded-[12px] group-data-[form=compact]/rr:size-8 group-data-[form=compact]/rr:rounded-[10px]" /** * One icon square on a row, with its name as a tooltip and as its accessible * name. `href` makes it a link (`download` for a file); otherwise a button. * Always visible — a row never hides its actions until hover. */ function ReleaseRowAction({ label, href, download, disabled = false, className, children, ...props }: Omit<React.ComponentProps<"button">, "children"> & { label: string href?: string download?: boolean | string children: React.ReactNode }) { const square = cn(SQUARE, className) const control = href && !disabled ? ( <Button variant="secondary" size="icon" className={square} {...{ asChild: true }}> <a href={href} download={download === true ? "" : download || undefined} aria-label={label}> {children} </a> </Button> ) : ( <Button variant="secondary" size="icon" className={square} aria-label={label} disabled={disabled} {...props}> {children} </Button> ) if (disabled) return control return ( <Tooltip> <TooltipTrigger {...{ asChild: true }}>{control}</TooltipTrigger> <TooltipContent>{label}</TooltipContent> </Tooltip> ) } /** * The magnet square: copies the address and answers with a mint tick and * 「已复制」 for a moment. `value={null}` is the disabled square of a release * without a magnet. */ function ReleaseRowCopyAction({ value, label = "复制磁力链接", copiedLabel = "已复制", onCopied, className, }: { value: string | null label?: string copiedLabel?: string onCopied?: () => void className?: string }) { const [copied, setCopied] = React.useState(false) const [open, setOpen] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) const copy = async () => { if (!value) return try { await navigator.clipboard.writeText(value) } catch { return } setCopied(true) onCopied?.() clearTimeout(timer.current) timer.current = setTimeout(() => setCopied(false), 1600) } if (value === null) { return ( <ReleaseRowAction label={label} disabled className={className}> <MagnetIcon /> </ReleaseRowAction> ) } return ( // While 「已复制」 shows, the tip stays open even though the press closed it. <Tooltip open={open || copied} onOpenChange={setOpen}> <TooltipTrigger {...{ asChild: true }}> <Button variant="secondary" size="icon" aria-label={label} onClick={copy} className={cn(SQUARE, copied && "text-anibt-ok", className)} > {copied ? <CheckIcon /> : <MagnetIcon />} <span role="status" className="sr-only"> {copied ? copiedLabel : ""} </span> </Button> </TooltipTrigger> <TooltipContent>{copied ? copiedLabel : label}</TooltipContent> </Tooltip> ) } /** A row-shaped placeholder for a loading list. */ function ReleaseRowSkeleton({ cover = true, className }: { cover?: boolean; className?: string }) { const block = "animate-pulse rounded-[6px] bg-anibt-deep2 motion-reduce:animate-none" return ( <div data-slot="release-row" data-form="wide" aria-hidden="true" className={cn( "relative grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 px-3.5 py-3 md:px-3", cover && "grid-cols-[40px_minmax(0,1fr)_auto]", "[[data-slot=release-row]+&]:before:pointer-events-none [[data-slot=release-row]+&]:before:absolute [[data-slot=release-row]+&]:before:inset-x-3.5 [[data-slot=release-row]+&]:before:top-0 [[data-slot=release-row]+&]:before:h-px [[data-slot=release-row]+&]:before:bg-(image:--anibt-stitch) [[data-slot=release-row]+&]:before:bg-size-[8px_1px] [[data-slot=release-row]+&]:before:bg-repeat-x [[data-slot=release-row]+&]:before:content-[''] md:[[data-slot=release-row]+&]:before:inset-x-3", className )} > {cover ? <div className={cn(block, "h-14 w-10")} /> : null} <div className="grid gap-2"> <div className={cn(block, "h-3.5 w-11/12")} /> <div className={cn(block, "h-3 w-1/2")} /> </div> <div className="flex gap-2"> <div className={cn(block, "size-8 rounded-[10px]")} /> <div className={cn(block, "size-8 rounded-[10px]")} /> </div> </div> ) } export { ReleaseRow, ReleaseRowAction, ReleaseRowCopyAction, ReleaseRowDateRule, ReleaseRowList, ReleaseRowPeers, ReleaseRowSkeleton, } export type { ReleaseRowProps, ReleaseRowPublisher, ReleaseRowCover }
这是一个区块(registry:block),CLI 会把它写到 components/release-row.tsx,并一并装好 @anibt/badge、@anibt/button、@anibt/tooltip。
用法
components/latest-releases.tsx
import Link from "next/link"
import { ReleaseRow, ReleaseRowDateRule, ReleaseRowList } from "@/components/release-row"
export function LatestReleases({ releases }: { releases: Release[] }) {
return (
<ReleaseRowList>
<ReleaseRowDateRule label="今天" />
{releases.map((release) => (
<ReleaseRow
key={release.id}
link={<Link href={`/release/${release.id}`} />}
cover={release.coverUrl}
title={release.title}
publisher={{ name: release.group.name, link: <Link href={`/group/${release.group.slug}`} /> }}
episode={`第 ${release.episode} 集`}
resolution="1080p"
languages={["简", "繁"]}
subtitleType="内封字幕"
specs={["HEVC", "10bit", "WebRip", "MKV"]}
fileSize={release.sizeLabel}
seeders={release.seeders}
leechers={release.leechers}
time={release.ageLabel}
dateTime={release.publishedAt}
magnet={release.magnet}
torrentHref={release.torrentUrl}
onMagnetCopied={() => toast.success("已复制磁力链接")}
/>
))}
</ReleaseRowList>
)
}行什么都不取、什么都不格式化:大小、时间、做种数都由你算好传进来。时间放在 <time dateTime> 里,并且 suppressHydrationWarning——相对时间在服务端和浏览器上可能不同。
版式
- 宽版:
ReleaseRowList至少 760px 宽时(容器查询,看的是列表自己的宽度,不是窗口)。40×56 的封面贴纸(指针下会歪一下)、标题和事实行,然后是大小 / 做种 / 时间三列,最后是 32px 的图标方块。 - 标题完整换行,从不截断——读者靠标题挑版本。指针下标题变粉,整行都能点开(标题链接铺满整行),发布者链接和方块浮在上面。
- 事实行:粉色的发布者,第 N 集,最多三个彩色标签——分辨率(蓝)、语言合成一个(粉)、字幕类型(中性)——其余规格是 768px 起才出现的弱色纯文字。
- 做种:↑ 做种(测到活跃时是薄荷色)↓ 下载中;
null表示还没测到,画短横,从不画一个没测过的 0。 - 图标方块一直可见,不靠悬停露出。
magnet画复制磁力的方块(复制后变成薄荷色的勾和「已复制」),torrentHref画下载种子的方块;值为null时画禁用的方块,让一列里每行一样宽。actions可以换成你自己的ReleaseRowAction。
窄栏与紧凑
stacked:窄栏里的手机版式
星空字幕组 · 简繁内封
release-row-forms.tsx
import { ReleaseRow, ReleaseRowList } from "@/components/release-row"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const magnet = "magnet:?xt=urn:btih:9f0e6c1b2a3d4e5f60718293a4b5c6d7e8f90a1b"
export default function ReleaseRowForms() {
return (
<div className="grid w-full items-start gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex flex-col gap-2">
<p className="text-[12px] font-semibold text-muted-foreground">stacked:窄栏里的手机版式</p>
<ReleaseRowList>
<ReleaseRow
form="stacked"
href="#release-s"
cover="/demo/covers/mecha-dawn.svg"
title="[星空字幕组] 机甲黎明 07 / Mecha Dawn [09][1080p][简繁内封]"
publisher="星空字幕组"
episode="第 9 集"
resolution="1080p"
languages={["简", "繁"]}
subtitleType="内封字幕"
fileSize="1.21 GB"
seeders={42}
leechers={3}
time="3 小时前"
magnet={magnet}
torrentHref="#torrent-s"
/>
</ReleaseRowList>
</div>
<Card density="compact" className="pb-1">
<CardHeader stitched>
<CardTitle className="text-[14px]">星空字幕组 · 简繁内封</CardTitle>
</CardHeader>
<CardContent className="px-1">
<ReleaseRowList plain>
{[9, 8, 7].map((episode) => (
<ReleaseRow
key={episode}
form="compact"
href={`#release-c${episode}`}
title={`[星空字幕组] 机甲黎明 07 - 0${episode} [1080p][简繁内封]`}
episode={`第 ${episode} 集`}
resolution="1080p"
fileSize={`${(1.1 + episode / 100).toFixed(2)} GB`}
time={episode === 9 ? "3 小时前" : `${10 - episode} 周前`}
magnet={magnet}
torrentHref={`#torrent-c${episode}`}
/>
))}
</ReleaseRowList>
</CardContent>
</Card>
</div>
)
}- 列表窄于 760px 时是手机版式:封面在标题旁,下面一行数字,末尾是 40px 的方块(44px 触控区)。
form="stacked"在任何宽度都保持这个版式,给抽屉、侧栏这类窄栏。 form="compact"是面板里的密行:没有封面、没有发布者(面板已经写了),从 1024 起标题一行省略,大小和时间叠成 92px 的一列。
ReleaseRowList plain 去掉面板,给已经在卡片里的列表;行与行之间的缝线还在。ReleaseRowDateRule 是列表里的日期:一枚蓝色的小日期贴纸、一条缝线、末尾可选的贴纸。
属性
ReleaseRow
titleReactNode默认 —必填:完整标题,从不截断
href · linkstring · ReactElement默认 —打开这条资源,铺满整行
coverstring | { src, srcSet?, alt? } | null默认 —40×56 的封面;3× 屏给 120w 以上
publisherstring | { name, href?, link? }默认 —粉色的发布者
categoryReactNode默认 —集数前的分类标签(合集、剧场版)
episodeReactNode默认 —第 N 集,圆体
resolutionstring默认 —蓝色标签
languagesstring[]默认 —合成一个粉色标签:简·繁
subtitleTypestring默认 —中性标签:内封字幕
specsstring[]默认 —其余规格,768px 起的弱色纯文字
fileSizestring默认 —格式化好的大小
seeders · leechersnumber | null默认 —null 显示短横
time · dateTimeReactNode · string默认 —格式化好的时间和它的 ISO 值
magnetstring | null默认 —复制磁力的方块;null 为禁用
torrentHrefstring | null默认 —下载种子的方块;null 为禁用
onMagnetCopied() => void默认 —复制成功后(在这里弹 toast)
actionsReactNode默认 —自己的方块,替代磁力 / 种子两个
form"wide" | "stacked" | "compact"默认 "wide"随列表宽度 / 始终手机版式 / 面板密行
priorityboolean默认 false首屏:封面立即加载
ReleaseRowAction
labelstring默认 —必填:提示和无障碍名称
hrefstring默认 —有则是链接,否则是按钮
downloadboolean | string默认 —链接下载文件
disabledboolean默认 false禁用的方块(不显示提示)
还导出 ReleaseRowList(plain)、ReleaseRowDateRule(label、trailing)、ReleaseRowCopyAction、ReleaseRowPeers 和 ReleaseRowSkeleton。
