Show on-device HTML UI mocks as firmware card previews.

Prefer media/preview.html over raster covers so catalog cards mirror each LVGL screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 22:10:05 +08:00
parent 8ae9501263
commit 937c0090d5
8 changed files with 108 additions and 73 deletions

View File

@@ -47,9 +47,11 @@ CYD_FIRMWARE_ROOT=C:\Users\dsx2016\Desktop\codes\cyd
After rebuilding a project, run its `firmware\export_from_build.ps1`, then click **Refresh**.
## Extending a firmware (media / docs)
## Extending a firmware (HTML UI preview / docs)
Create `meta.json` beside the `.bin` files:
Preferred card preview: **`media/preview.html`** — HTML recreation of the on-device LVGL UI (320×240). Auto-detected if present.
Optional `meta.json` beside the `.bin` files:
```json
{
@@ -59,19 +61,18 @@ Create `meta.json` beside the `.bin` files:
"description": "Longer detail text",
"tags": ["pomodoro", "touch"],
"media": {
"cover": "media/cover.webp",
"animation": "media/preview.gif",
"preview": "media/preview.html",
"video": "media/demo.mp4",
"docs": "docs/README.md"
}
}
```
- Relative media paths are served via `/api/firmware/.../media/...` (only if the file exists).
- Media paths are served via `/api/firmware/.../media/...` (only if the file exists).
- `docs` may also be an `https://` URL.
- `boardId` values: `esp32-2432s028` (active), `esp32-2432s024`, `esp32-3248s035`, `esp32-s3-touch` (UI reserved).
Without `meta.json`, the catalog still works from `VERSION.txt`.
Without `meta.json`, the catalog still works from `VERSION.txt` + auto `media/preview.html`.
## Public deploy (Docker + Caddy)

View File

@@ -1,48 +1,10 @@
"use client";
import { Film, FileText, ImageIcon } from "lucide-react";
import { FileText, LayoutTemplate } 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>
);
}
import { FirmwarePreview } from "@/components/firmware-preview";
export function FirmwareCard({
fw,
@@ -53,9 +15,8 @@ export function FirmwareCard({
active: boolean;
onSelect: () => void;
}) {
const hasVideo = Boolean(fw.media.video);
const hasPreview = Boolean(fw.media.preview);
const hasDocs = Boolean(fw.media.docs);
const hasCover = Boolean(fw.media.cover || fw.media.animation);
return (
<button
@@ -71,16 +32,11 @@ export function FirmwareCard({
)}
>
<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 && (
<FirmwarePreview fw={fw} />
<div className="pointer-events-none absolute bottom-2 left-2 flex gap-1">
{hasPreview && (
<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 />
<LayoutTemplate className="size-3" aria-hidden />
</span>
)}
{hasDocs && (
@@ -91,7 +47,7 @@ export function FirmwareCard({
</div>
<Badge
variant="muted"
className="absolute bottom-2 right-2 bg-black/55 text-[0.65rem] backdrop-blur-sm"
className="pointer-events-none absolute bottom-2 right-2 bg-black/55 text-[0.65rem] backdrop-blur-sm"
>
{formatBytes(fw.size)}
</Badge>

View File

@@ -6,6 +6,7 @@ import type { FirmwareInfo } from "@/lib/firmware";
import { formatBytes } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { EspWebInstall } from "@/components/esp-web-install";
import { FirmwarePreview } from "@/components/firmware-preview";
export function FirmwareDetail({
fw,
@@ -24,8 +25,6 @@ export function FirmwareDetail({
);
}
const preview = fw.media.video || fw.media.animation || fw.media.cover;
return (
<aside className="sticky top-4 flex flex-col overflow-hidden rounded-[14px] border border-[var(--line)] bg-[color-mix(in_srgb,var(--bg-elev)_92%,transparent)] backdrop-blur-sm">
<div className="border-b border-[var(--line)] px-4 py-3">
@@ -37,32 +36,26 @@ export function FirmwareDetail({
</h3>
</div>
<div className="aspect-video w-full border-b border-[var(--line)] bg-black/40">
{fw.media.video ? (
<div className="aspect-[4/3] w-full border-b border-[var(--line)] bg-black/40">
{fw.media.preview ? (
<FirmwarePreview fw={fw} />
) : fw.media.video ? (
<video
key={fw.media.video}
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
poster={fw.media.cover || undefined}
>
<source src={fw.media.video} />
</video>
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 px-4 text-center">
<p className="m-0 text-sm text-[var(--muted)]">
{t("firmware.mediaPlaceholder")}
</p>
<p className="m-0 text-xs text-[var(--muted)] opacity-70">
meta.json → media.cover / animation / video
media/preview.html
</p>
</div>
)}

View File

@@ -0,0 +1,52 @@
"use client";
import type { FirmwareInfo } from "@/lib/firmware";
import { cn } from "@/lib/utils";
/** Renders HTML on-device UI mock (preferred) — never uses raster covers. */
export function FirmwarePreview({
fw,
className,
title,
}: {
fw: FirmwareInfo;
className?: string;
title?: string;
}) {
if (fw.media.preview) {
return (
<iframe
title={title || `${fw.name} UI preview`}
src={fw.media.preview}
className={cn("h-full w-full border-0 bg-[#0A1016]", className)}
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
/>
);
}
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)]",
className
)}
>
<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)]">
HTML preview soon
</span>
</div>
);
}

27
components/ui-preview.tsx Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import { cn } from "@/lib/utils";
/** Scales an on-device HTML mock (320×240) into the card / detail media slot. */
export function UiPreview({
src,
className,
title,
}: {
src: string;
className?: string;
title?: string;
}) {
return (
<div className={cn("relative h-full w-full overflow-hidden bg-[#0a0806]", className)}>
<iframe
src={src}
title={title || "Firmware UI preview"}
className="pointer-events-none absolute inset-0 h-full w-full border-0"
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
/>
</div>
);
}

View File

@@ -18,6 +18,8 @@ export const FLASH_PARTS = [
/** Optional rich metadata: place meta.json next to the .bin files. */
export type FirmwareMedia = {
/** HTML UI mock of the on-device screen (preferred card preview). */
preview: string | null;
cover: string | null;
animation: string | null;
video: string | null;
@@ -54,6 +56,7 @@ type MetaJson = {
board?: string;
flash_size?: string;
media?: {
preview?: string;
cover?: string;
animation?: string;
video?: string;
@@ -165,6 +168,9 @@ function buildFirmwareInfo(
Object.entries(versionMeta).filter(([k]) => !reserved.has(k))
),
media: {
preview:
mediaUrl(project, variant, dir, json?.media?.preview) ||
mediaUrl(project, variant, dir, "media/preview.html"),
cover: mediaUrl(project, variant, dir, json?.media?.cover),
animation: mediaUrl(project, variant, dir, json?.media?.animation),
video: mediaUrl(project, variant, dir, json?.media?.video),

View File

@@ -36,7 +36,7 @@
"noneSelected": "Select a firmware card to see details and flash",
"selectedLabel": "Selected",
"docs": "Documentation",
"mediaPlaceholder": "Animation / video / cover will appear here"
"mediaPlaceholder": "On-device HTML UI preview will appear here"
},
"install": {
"cta": "Connect & flash",

View File

@@ -36,7 +36,7 @@
"noneSelected": "点选固件卡片查看详情并刷写",
"selectedLabel": "已选",
"docs": "文档",
"mediaPlaceholder": "动画 / 视频 / 封面将显示在这里"
"mediaPlaceholder": "设备界面 HTML 预览将显示在这里"
},
"install": {
"cta": "连接并刷写",