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:
13
README.md
13
README.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
52
components/firmware-preview.tsx
Normal file
52
components/firmware-preview.tsx
Normal 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
27
components/ui-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"noneSelected": "点选固件卡片查看详情并刷写",
|
||||
"selectedLabel": "已选",
|
||||
"docs": "文档",
|
||||
"mediaPlaceholder": "动画 / 视频 / 封面将显示在这里"
|
||||
"mediaPlaceholder": "设备界面 HTML 预览将显示在这里"
|
||||
},
|
||||
"install": {
|
||||
"cta": "连接并刷写",
|
||||
|
||||
Reference in New Issue
Block a user