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:
113
app/[locale]/layout.tsx
Normal file
113
app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
app/[locale]/not-found.tsx
Normal file
10
app/[locale]/not-found.tsx
Normal 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
23
app/[locale]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/api/firmware/[project]/[variant]/[file]/route.ts
Normal file
37
app/api/firmware/[project]/[variant]/[file]/route.ts
Normal 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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
39
app/api/firmware/[project]/[variant]/manifest.json/route.ts
Normal file
39
app/api/firmware/[project]/[variant]/manifest.json/route.ts
Normal 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
13
app/api/firmware/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
103
app/page.tsx
103
app/page.tsx
@@ -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
12
app/robots.ts
Normal 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
17
app/sitemap.ts
Normal 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}`])
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user