Board chips reserve future models; firmware uses searchable cards with detail pane and optional meta.json media/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { Film, FileText, ImageIcon } from "lucide-react";
|
|
import type { FirmwareInfo } from "@/lib/firmware";
|
|
import { formatBytes, cn } from "@/lib/utils";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
function MediaSlot({ fw, active }: { fw: FirmwareInfo; active: boolean }) {
|
|
const src = fw.media.animation || fw.media.cover;
|
|
if (src) {
|
|
return (
|
|
// Dynamic firmware media URLs — next/image needs remotePatterns per host
|
|
// eslint-disable-next-line @next/next/no-img-element -- local API / arbitrary media
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const initials = fw.name
|
|
.replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, " ")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.slice(0, 2)
|
|
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
.join("");
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex h-full w-full flex-col items-center justify-center gap-2 bg-[radial-gradient(ellipse_at_30%_20%,rgba(240,197,10,0.18),transparent_55%),linear-gradient(160deg,#1a1610,#0e0c09)]",
|
|
active && "from-[rgba(240,197,10,0.22)]"
|
|
)}
|
|
>
|
|
<span className="font-[family-name:var(--font-display)] text-3xl font-extrabold tracking-tight text-[var(--accent-hot)]/80">
|
|
{initials || "FW"}
|
|
</span>
|
|
<span className="text-[0.65rem] uppercase tracking-[0.14em] text-[var(--muted)]">
|
|
preview soon
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FirmwareCard({
|
|
fw,
|
|
active,
|
|
onSelect,
|
|
}: {
|
|
fw: FirmwareInfo;
|
|
active: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const hasVideo = Boolean(fw.media.video);
|
|
const hasDocs = Boolean(fw.media.docs);
|
|
const hasCover = Boolean(fw.media.cover || fw.media.animation);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
"group flex flex-col overflow-hidden rounded-[14px] border text-left transition-all duration-200",
|
|
active
|
|
? "border-[var(--accent)] bg-[rgba(240,197,10,0.08)] shadow-[0_0_0_1px_rgba(240,197,10,0.3),0_12px_32px_rgba(0,0,0,0.35)]"
|
|
: "border-[var(--line)] bg-[rgba(0,0,0,0.22)] hover:-translate-y-0.5 hover:border-[rgba(240,197,10,0.35)] hover:bg-[rgba(240,197,10,0.04)]"
|
|
)}
|
|
>
|
|
<div className="relative aspect-[16/10] w-full overflow-hidden border-b border-[var(--line)]">
|
|
<MediaSlot fw={fw} active={active} />
|
|
<div className="absolute bottom-2 left-2 flex gap-1">
|
|
{hasCover && (
|
|
<span className="rounded bg-black/55 p-1 text-[var(--ink)] backdrop-blur-sm">
|
|
<ImageIcon className="size-3" aria-hidden />
|
|
</span>
|
|
)}
|
|
{hasVideo && (
|
|
<span className="rounded bg-black/55 p-1 text-[var(--ink)] backdrop-blur-sm">
|
|
<Film className="size-3" aria-hidden />
|
|
</span>
|
|
)}
|
|
{hasDocs && (
|
|
<span className="rounded bg-black/55 p-1 text-[var(--ink)] backdrop-blur-sm">
|
|
<FileText className="size-3" aria-hidden />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge
|
|
variant="muted"
|
|
className="absolute bottom-2 right-2 bg-black/55 text-[0.65rem] backdrop-blur-sm"
|
|
>
|
|
{formatBytes(fw.size)}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-2 p-3.5">
|
|
<h3 className="m-0 line-clamp-2 font-semibold leading-snug">{fw.name}</h3>
|
|
{fw.summary && (
|
|
<p className="m-0 line-clamp-2 text-xs leading-relaxed text-[var(--muted)]">
|
|
{fw.summary}
|
|
</p>
|
|
)}
|
|
<div className="mt-auto flex flex-wrap gap-1.5 pt-1">
|
|
{fw.tags.slice(0, 3).map((tag) => (
|
|
<Badge key={tag} variant="default" className="max-w-full truncate">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
{fw.date && (
|
|
<Badge variant="muted" className="tabular-nums">
|
|
{fw.date}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|