Add CYD browser flasher with Next.js, i18n, and Web Serial install.

Replace the starter app with a public/local flasher that discovers sibling firmwares, supports en/zh, and documents HTTPS deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 19:38:53 +08:00
parent 0bf10091db
commit ae63378fdb
39 changed files with 2889 additions and 196 deletions

113
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server";
import { DM_Sans, Syne } from "next/font/google";
import { routing } from "@/i18n/routing";
import { LanguageSwitcher } from "@/components/language-switcher";
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});
const syne = Syne({
subsets: ["latin"],
variable: "--font-display",
weight: ["600", "700", "800"],
display: "swap",
});
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
const languages = Object.fromEntries(
routing.locales.map((l) => [l, `/${l}`])
);
return {
title: t("title"),
description: t("description"),
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"
),
alternates: {
canonical: `/${locale}`,
languages,
},
openGraph: {
title: t("title"),
description: t("description"),
locale: locale === "zh" ? "zh_CN" : "en_US",
type: "website",
siteName: "CYD Flasher",
},
twitter: {
card: "summary_large_image",
title: t("title"),
description: t("description"),
},
robots: {
index: true,
follow: true,
},
};
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
const t = await getTranslations("brand");
const tf = await getTranslations("footer");
return (
<html lang={locale} className={`${dmSans.variable} ${syne.variable}`}>
<body className="antialiased">
<NextIntlClientProvider messages={messages}>
<div className="bg-grid" aria-hidden />
<div className="bg-glow" aria-hidden />
<header className="animate-rise relative z-10 mx-auto flex max-w-[920px] flex-wrap items-center justify-between gap-4 px-[clamp(1.25rem,4vw,3rem)] pt-6">
<div>
<div className="flex items-center gap-2.5">
<span
className="size-[1.15rem] rounded-[3px] bg-[linear-gradient(145deg,var(--accent-hot),#c98900)] shadow-[0_0_0_3px_rgba(240,197,10,0.2)]"
aria-hidden
/>
<span className="font-[family-name:var(--font-display)] text-[clamp(1.6rem,4vw,2.1rem)] font-extrabold leading-none tracking-tight">
{t("name")}
</span>
</div>
<p className="mt-1.5 text-[0.95rem] text-[var(--muted)]">
{t("tagline")}
</p>
</div>
<LanguageSwitcher />
</header>
<main className="relative z-10 mx-auto max-w-[920px] px-[clamp(1.25rem,4vw,3rem)] py-7 pb-12">
{children}
</main>
<footer className="relative z-10 mx-auto flex max-w-[920px] flex-wrap gap-x-6 gap-y-2 px-[clamp(1.25rem,4vw,3rem)] pb-10 text-xs text-[var(--muted)] opacity-85">
<span>{tf("offsets")}</span>
<span>{tf("stack")}</span>
</footer>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,10 @@
export default function NotFound() {
return (
<div className="py-16 text-center">
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">
404
</h1>
<p className="mt-2 text-[var(--muted)]">Page not found</p>
</div>
);
}

23
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import { FlasherPanel } from "@/components/flasher-panel";
type Props = { params: Promise<{ locale: string }> };
export default async function HomePage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("hero");
return (
<div className="flex flex-col gap-5">
<section className="animate-rise">
<h1 className="m-0 max-w-[18ch] font-[family-name:var(--font-display)] text-[clamp(1.45rem,3.5vw,1.85rem)] font-bold tracking-tight">
{t("title")}
</h1>
<p className="mt-2 max-w-[42rem] text-[var(--muted)]">{t("lede")}</p>
</section>
<FlasherPanel />
</div>
);
}

View File

@@ -0,0 +1,37 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { REQUIRED_BINS, resolveFirmwareDir } from "@/lib/firmware";
export const dynamic = "force-dynamic";
type Params = {
params: Promise<{ project: string; variant: string; file: string }>;
};
export async function GET(_req: Request, { params }: Params) {
const { project, variant, file } = await params;
if (!(REQUIRED_BINS as readonly string[]).includes(file)) {
return NextResponse.json({ error: "invalid file" }, { status: 400 });
}
const dir = resolveFirmwareDir(project, variant);
if (!dir) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
const abs = path.join(dir, file);
if (!fs.existsSync(abs)) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
const buf = fs.readFileSync(abs);
return new NextResponse(buf, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(buf.length),
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="${file}"`,
},
});
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import {
FLASH_PARTS,
readFirmwareMeta,
resolveFirmwareDir,
} from "@/lib/firmware";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ project: string; variant: string }> };
export async function GET(_req: Request, { params }: Params) {
const { project, variant } = await params;
const dir = resolveFirmwareDir(project, variant);
if (!dir) {
return NextResponse.json({ error: "firmware not found" }, { status: 404 });
}
const meta = readFirmwareMeta(dir);
const name = meta.name || `${project} (${variant})`;
const version =
[meta.date, meta.git].filter(Boolean).join(" / ") || "local";
const base = `/api/firmware/${encodeURIComponent(project)}/${encodeURIComponent(variant)}`;
return NextResponse.json({
name,
version,
new_install_prompt_erase: true,
builds: [
{
chipFamily: "ESP32",
parts: FLASH_PARTS.map((p) => ({
path: `${base}/${p.file}`,
offset: p.offset,
})),
},
],
});
}

13
app/api/firmware/route.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { discoverFirmware } from "@/lib/firmware";
export const dynamic = "force-dynamic";
export async function GET() {
const firmwares = discoverFirmware();
return NextResponse.json({
chipFamily: "ESP32",
count: firmwares.length,
firmwares,
});
}

View File

@@ -1,26 +1,96 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--bg: #12100c;
--bg-elev: #1c1812;
--ink: #f4efe4;
--muted: #9a9080;
--line: rgba(244, 239, 228, 0.12);
--accent: #f0c50a;
--accent-hot: #ffd54a;
--accent-ink: #1a1408;
--warn: #e8a45a;
--background: var(--bg);
--foreground: var(--ink);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--font-sans: var(--font-body);
--font-display: var(--font-display);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body), "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
system-ui, sans-serif;
min-height: 100vh;
}
.bg-grid {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(240, 197, 10, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(240, 197, 10, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 20%, #000 20%, transparent 75%);
}
.bg-glow {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(ellipse 50% 40% at 70% -10%, rgba(240, 197, 10, 0.22), transparent 55%),
radial-gradient(ellipse 40% 35% at 10% 80%, rgba(180, 90, 20, 0.12), transparent 50%);
}
.cta-btn {
appearance: none;
border: none;
font: inherit;
font-family: var(--font-display), system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.01em;
font-size: 1.05rem;
padding: 0.85rem 1.5rem;
border-radius: 999px;
cursor: pointer;
color: var(--accent-ink);
background: linear-gradient(180deg, var(--accent-hot), var(--accent));
box-shadow: 0 8px 24px rgba(240, 197, 10, 0.25);
transition: transform 0.15s, filter 0.15s, opacity 0.15s;
}
.cta-btn:hover:not(:disabled) {
filter: brightness(1.05);
transform: translateY(-1px);
}
.cta-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-rise {
animation: rise 0.65s ease-out both;
}

View File

@@ -1,34 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { ReactNode } from "react";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
/** Root layout — locale-specific html/lang lives under [locale]. */
export default function RootLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -1,103 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>
);
}

12
app/robots.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${base}/sitemap.xml`,
};
}

17
app/sitemap.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { routing } from "@/i18n/routing";
export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
return routing.locales.map((locale) => ({
url: `${base}/${locale}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `${base}/${l}`])
),
},
}));
}