Initial MARD pixel bead pattern web

This commit is contained in:
wuyanwanwu
2026-08-13 23:00:44 +08:00
commit e3fe03468d
32 changed files with 11811 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.openai
node_modules
dist
mard-source.html
npm-debug.log*
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/.vinext/
/out/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
/dist/
/.wrangler/
/outputs/
/work/
/mard-source.html
+4
View File
@@ -0,0 +1,4 @@
{
"d1": null,
"r2": null
}
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3200
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3200
CMD ["npm", "run", "start", "--", "--hostname", "0.0.0.0", "--port", "3200"]
+100
View File
@@ -0,0 +1,100 @@
# vinext-starter
A clean full-stack starter running on
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
## Prerequisites
- Node.js `>=22.13.0`
## Quick Start
```bash
npm install
npm run dev
npm run build
```
This starter does not use `wrangler.jsonc`.
## Included Shape
- edit site code under `app/`
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
- `vite.config.ts` simulates declared bindings for local development
- `db/schema.ts` starts intentionally empty
- `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed
## Workspace Auth Headers
Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.
The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.
SIWC-authenticated workspace sites may also receive
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
Treat the full name as optional and fall back to email when it is absent:
```tsx
import { headers } from "next/headers";
export default async function Home() {
const requestHeaders = await headers();
const userId = requestHeaders.get("oai-authenticated-user-id");
const email = requestHeaders.get("oai-authenticated-user-email");
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
const fullName =
encodedFullName &&
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
"percent-encoded-utf-8"
? decodeURIComponent(encodedFullName)
: null;
const displayName = fullName ?? email;
// ...
}
```
## Optional Dispatch-Owned ChatGPT Sign-In
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI.
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
anonymous visitors through Sign in with ChatGPT.
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
browser links or actions.
- Pass a same-origin relative `returnTo` path for the destination after sign-in
or sign-out. The helper validates and safely encodes it.
- Mark protected pages with `export const dynamic = "force-dynamic"` because
they depend on per-request identity headers.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
OAuth cookies, and identity header injection. Do not implement app routes for
those reserved paths. Routes that do not import and call the helper remain
anonymous-compatible.
SIWC establishes identity only; it does not prove workspace membership. Use the
Sites hosting platform's access policy controls for workspace-wide restrictions,
or enforce explicit server-side membership or allowlist checks.
Use SIWC for account pages, user-specific dashboards, saved records, and write
actions tied to the current ChatGPT user. Leave public content anonymous.
## Useful Commands
- `npm run dev`: start local development
- `npm run build`: verify the vinext build output
- `npm test`: build the starter and verify its rendered loading skeleton
- `npm run db:generate`: generate Drizzle migrations after schema changes
## Learn More
- [vinext Documentation](https://github.com/cloudflare/vinext)
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
+90
View File
@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+180
View File
@@ -0,0 +1,180 @@
@import "tailwindcss";
:root {
--ink: #202724;
--muted: #69716d;
--paper: #f5f1e8;
--card: #fffdf8;
--line: #d9d5ca;
--green: #1f6b4d;
--green-dark: #164b39;
--lime: #cbe568;
--orange: #ee7b43;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
button, input, select { font: inherit; }
button, label, input[type="range"] { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
.topbar { height: 72px; padding: 0 clamp(20px, 5vw, 80px); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(245, 241, 232, .92); position: sticky; top: 0; z-index: 20; backdrop-filter: blur(12px); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--ink); text-decoration: none; font-size: 20px; font-weight: 800; letter-spacing: .04em; }
.brand-mark { display: grid; grid-template-columns: repeat(2, 8px); gap: 3px; rotate: -8deg; }
.brand-mark i { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: inset -1px -1px 0 rgba(0,0,0,.15); }
.brand-mark i:nth-child(2) { background: var(--orange); }
.brand-mark i:nth-child(3) { background: var(--lime); }
.top-note { color: var(--muted); font-size: 13px; letter-spacing: .08em; }
.hero { max-width: 1560px; margin: 0 auto; padding: clamp(42px, 7vw, 92px) clamp(20px, 5vw, 80px) clamp(38px, 6vw, 72px); display: flex; align-items: flex-end; justify-content: space-between; gap: 48px; }
.eyebrow, .section-kicker { margin: 0 0 14px; font-size: 11px; color: var(--green); font-weight: 800; letter-spacing: .2em; }
.hero h1 { margin: 0; font-family: Georgia, "Songti SC", serif; font-size: clamp(40px, 6.3vw, 88px); line-height: 1.04; letter-spacing: -.055em; font-weight: 500; }
.hero h1 em { color: var(--green); font-style: normal; }
.hero-copy { max-width: 720px; margin: 28px 0 0; color: var(--muted); font-size: clamp(15px, 1.5vw, 18px); line-height: 1.8; }
.hero-badge { flex: 0 0 auto; width: 150px; height: 150px; border: 1px solid var(--ink); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; rotate: 6deg; background: var(--lime); box-shadow: 6px 6px 0 var(--ink); }
.hero-badge strong { font-family: Georgia, serif; font-size: 26px; }
.hero-badge span { margin-top: 5px; font-size: 11px; }
.workspace { max-width: 1760px; margin: 0 auto 80px; display: grid; grid-template-columns: minmax(240px, 300px) minmax(500px, 1fr) minmax(260px, 330px); border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); background: var(--card); }
.control-panel, .color-panel { min-width: 0; padding: 28px; }
.control-panel { border-right: 1px solid var(--line); }
.color-panel { border-left: 1px solid var(--line); display: flex; flex-direction: column; max-height: 790px; }
.panel-heading { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 24px; }
.panel-heading > span { width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid var(--ink); border-radius: 50%; font: 700 11px Georgia, serif; }
.panel-heading h2, .pattern-toolbar h2 { margin: 0; font-size: 18px; }
.panel-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
.upload-card { height: 170px; display: block; position: relative; overflow: hidden; cursor: pointer; border: 1px dashed #a5a59e; background: #ebe7dd; }
.upload-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.upload-card input { position: absolute; opacity: 0; pointer-events: none; }
.upload-overlay { position: absolute; right: 10px; bottom: 10px; padding: 7px 10px; color: white; background: rgba(22, 75, 57, .86); font-size: 12px; }
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; }
.field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; }
.field-row label, .field { display: flex; flex-direction: column; gap: 8px; }
.field-row label span, .field > span { color: var(--muted); font-size: 12px; }
.field-row input, .field select { width: 100%; height: 43px; border: 1px solid var(--line); border-radius: 0; background: white; padding: 0 10px; outline: none; }
.field-row input:focus, .field select:focus, .search-box:focus-within { border-color: var(--green); box-shadow: 0 0 0 2px rgba(31,107,77,.11); }
.times { padding-bottom: 11px; color: var(--muted); }
.field { margin-top: 22px; }
.field > span { display: flex; justify-content: space-between; }
.field b { color: var(--green); }
input[type="range"] { accent-color: var(--green); }
.primary-button { width: 100%; min-height: 48px; margin-top: 24px; border: 1px solid var(--ink); background: var(--green); color: white; cursor: pointer; font-weight: 700; box-shadow: 3px 3px 0 var(--ink); transition: transform .15s, box-shadow .15s; }
.primary-button:hover { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
.privacy-note, .palette-note { margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
.pattern-panel { min-width: 0; display: flex; flex-direction: column; background-color: #f1eee6; background-image: radial-gradient(#cbc6ba 0.7px, transparent .7px); background-size: 12px 12px; }
.pattern-toolbar { min-height: 90px; padding: 22px 28px; display: flex; justify-content: space-between; align-items: center; gap: 24px; background: var(--card); border-bottom: 1px solid var(--line); }
.pattern-toolbar .section-kicker { margin-bottom: 5px; }
.zoom-control { display: flex; align-items: center; gap: 7px; }
.zoom-control button { width: 36px; height: 36px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 20px; }
.zoom-control input { width: 95px; }
.zoom-control output { width: 38px; color: var(--muted); font-size: 11px; text-align: right; }
.canvas-stage { min-height: 610px; flex: 1; overflow: auto; display: grid; place-items: center; padding: 36px; touch-action: pan-x pan-y; }
.canvas-wrap { width: max-content; line-height: 0; position: relative; border: 1px solid var(--ink); box-shadow: 10px 10px 0 rgba(32,39,36,.13); background: white; }
.canvas-wrap canvas { cursor: crosshair; image-rendering: pixelated; }
.pixel-tooltip { position: absolute; z-index: 5; width: max-content; max-width: 260px; padding: 9px 11px; display: grid; grid-template-columns: 14px auto; align-items: center; gap: 3px 8px; color: white; background: rgba(22, 30, 27, .94); border: 1px solid rgba(255,255,255,.2); box-shadow: 4px 4px 0 rgba(0,0,0,.2); border-radius: 3px; line-height: 1.35; pointer-events: none; }
.tooltip-swatch { width: 14px; height: 14px; grid-row: 1 / 3; border-radius: 50%; border: 1px solid rgba(255,255,255,.6); }
.pixel-tooltip strong { font-size: 11px; }
.pixel-tooltip small { color: #dce4df; font-size: 10px; }
.pattern-footer { min-height: 62px; padding: 12px 22px; background: var(--card); border-top: 1px solid var(--line); display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 11px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(31,107,77,.14); }
.view-options { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.view-options label { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.view-options button { padding: 8px 11px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-weight: 700; }
.view-options .save-button { background: white; }
.pattern-legend { padding: 18px 22px 22px; background: var(--card); border-top: 1px solid var(--line); }
.legend-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.legend-heading strong { font-size: 13px; }
.legend-heading span { color: var(--muted); font-size: 10px; }
.legend-list { display: flex; flex-wrap: wrap; gap: 7px; }
.legend-list button { min-height: 34px; padding: 5px 8px 5px 6px; display: flex; align-items: center; gap: 5px; border: 1px solid var(--line); background: white; cursor: pointer; font-size: 10px; }
.legend-list button.active { border-color: var(--ink); background: #f0f6df; box-shadow: 2px 2px 0 var(--ink); }
.legend-list i { width: 20px; height: 20px; border-radius: 3px; border: 1px solid rgba(32,39,36,.25); }
.legend-list span { color: var(--muted); }
.legend-list b { padding-left: 5px; border-left: 1px solid var(--line); }
.search-box { height: 43px; display: flex; align-items: center; gap: 8px; padding: 0 12px; border: 1px solid var(--line); background: white; }
.search-box span { font-size: 22px; }
.search-box input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; font-size: 12px; }
.selection-tools { min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--line); }
.switch-row { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 11px; }
.switch-row input { position: absolute; opacity: 0; }
.switch { width: 30px; height: 17px; border-radius: 12px; background: #bbb; position: relative; transition: .2s; }
.switch::after { content: ""; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: white; transition: .2s; }
.switch-row input:checked + .switch { background: var(--green); }
.switch-row input:checked + .switch::after { translate: 13px 0; }
.selection-tools button { border: 0; background: transparent; color: var(--green); cursor: pointer; font-size: 11px; }
.color-list { min-height: 220px; overflow-y: auto; margin: 8px -8px 0; padding: 0 8px; scrollbar-width: thin; }
.color-item { width: 100%; min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 8px; border: 1px solid transparent; border-bottom-color: #ebe7dd; background: transparent; cursor: pointer; text-align: left; }
.color-item:hover { background: #f7f4ed; }
.color-item.active { border-color: var(--ink); background: #f0f6df; box-shadow: 2px 2px 0 var(--ink); }
.swatch { width: 30px; height: 30px; border: 1px solid rgba(32,39,36,.28); border-radius: 50%; box-shadow: inset -2px -2px 0 rgba(0,0,0,.08); }
.color-meta { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.color-meta strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.color-meta small { color: var(--muted); font: 9px ui-monospace, monospace; }
.color-item > b { font: 700 16px Georgia, serif; text-align: right; }
.color-item > b small { margin-left: 2px; color: var(--muted); font: 9px sans-serif; }
.history-section { max-width: 1560px; margin: 0 auto 90px; padding: 0 clamp(20px, 5vw, 80px); }
.history-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; padding-bottom: 22px; border-bottom: 1px solid var(--ink); }
.history-heading h2 { margin: 0; font: 500 clamp(30px, 4vw, 50px) Georgia, "Songti SC", serif; }
.history-heading p:not(.section-kicker) { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
.history-heading .search-box { width: min(330px, 100%); }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; padding-top: 18px; }
.history-card { min-width: 0; padding: 16px; display: grid; grid-template-columns: 58px minmax(0,1fr); gap: 12px; border: 1px solid var(--line); background: var(--card); }
.history-swatches { width: 58px; height: 58px; display: flex; overflow: hidden; border: 1px solid var(--ink); }
.history-swatches i { min-width: 3px; }
.history-card h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 3px 0 7px; font-size: 14px; }
.history-card p { margin: 0; color: var(--muted); font-size: 10px; }
.history-actions { grid-column: 1 / -1; display: flex; gap: 7px; justify-content: flex-end; }
.history-actions button { min-height: 34px; padding: 0 14px; border: 1px solid var(--ink); background: white; cursor: pointer; font-size: 11px; }
.history-actions button:first-child { background: var(--lime); font-weight: 700; }
.history-empty { margin-top: 18px; padding: 44px 20px; color: var(--muted); text-align: center; border: 1px dashed var(--line); background: rgba(255,253,248,.5); font-size: 13px; }
@media (max-width: 1160px) {
.workspace { grid-template-columns: 260px minmax(500px, 1fr); }
.color-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--ink); max-height: none; }
.color-list { display: grid; grid-template-columns: repeat(3, 1fr); max-height: 300px; gap: 0 12px; }
.history-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 760px) {
.topbar { height: 60px; padding: 0 18px; }
.top-note { display: none; }
.hero { padding: 38px 20px 44px; align-items: flex-start; }
.hero h1 { font-size: clamp(39px, 12vw, 58px); letter-spacing: -.06em; }
.hero-copy { margin-top: 20px; }
.hero-badge { display: none; }
.workspace { margin-bottom: 0; grid-template-columns: 1fr; border-bottom: 0; }
.control-panel { border-right: 0; border-bottom: 1px solid var(--ink); padding: 24px 20px; }
.upload-card { height: 210px; }
.pattern-panel { min-height: 620px; }
.pattern-toolbar { align-items: flex-start; flex-direction: column; padding: 20px; }
.zoom-control { width: 100%; }
.zoom-control input { flex: 1; }
.canvas-stage { min-height: 440px; display: block; padding: 24px; }
.canvas-wrap { margin: auto; }
.pattern-footer { align-items: flex-start; flex-wrap: wrap; padding: 14px 18px; }
.view-options { width: 100%; margin: 5px 0 0; justify-content: space-between; }
.view-options button { min-height: 40px; }
.color-panel { grid-column: auto; padding: 24px 20px 38px; }
.color-list { display: block; max-height: 430px; }
.legend-heading, .history-heading { align-items: stretch; flex-direction: column; }
.legend-list { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 7px; }
.legend-list button { flex: 0 0 auto; }
.history-section { margin: 52px auto; }
.history-heading .search-box { width: 100%; }
.history-grid { grid-template-columns: 1fr; }
input, select, button { font-size: 16px; }
.search-box input, .color-item, .selection-tools button { font-size: 12px; }
}
@media (max-width: 370px) {
.view-options { gap: 6px; }
.view-options label { font-size: 10px; }
.canvas-stage { padding: 16px; }
}
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "豆格工坊|图片转拼豆图纸",
description: "在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="zh-CN"><body>{children}</body></html>;
}
+16
View File
@@ -0,0 +1,16 @@
// MARD 221 基础色卡(A/B/C/D/E/F/G/H/M 系列)。
// HEX 为屏幕匹配用近似值,实体颜色会受到批次、光线与屏幕影响。
const SERIES: Record<string, string> = {
A: "#FAF4C8 #FFFFD5 #FEFF8B #FBED56 #F4D738 #FEAC4C #FE8B4C #FFDA45 #FF995B #F77C31 #FFDD99 #FE9F72 #FFC365 #FD543D #FFF365 #FFFF9F #FFE36E #FEBE7D #FD7C72 #FFD568 #FFE395 #F4F57D #E6C9B7 #F7F8A2 #FFD67D #FFC830",
B: "#E6EE31 #63F347 #9EF780 #5DE035 #35E352 #65E2A6 #3DAF80 #1C9C4F #27523A #95D3C2 #5D722A #166F41 #CAEB7B #ADE946 #2E5132 #C5ED9C #9BB13A #E6EE49 #24B88C #C2F0CC #156A6B #0B3C43 #303A21 #EEFCA5 #4E846D #8D7A35 #CCE1AF #9EE5B9 #C5E254 #E2FCB1 #B0E792 #9CAB5A",
C: "#E8FFE7 #A9F9FC #A0E2FB #41CCFF #01ACEB #50AAF0 #3677D2 #0F54C0 #324BCA #3EBCE2 #28DDDE #1C334D #CDE8FF #D5FDFF #22C4C6 #1557A8 #04D1F6 #1D3344 #1887A2 #176DAF #BEDDFF #67B4BE #C8E2FF #7CC4FF #A9E5E5 #3CAED8 #D3DFFA #BBCFED #34488E",
D: "#AEB4F2 #858EDD #2F54AF #182A84 #B843C5 #AC7BDE #8854B3 #E2D3FF #D5B9F8 #361851 #B9BAE1 #DE9AD4 #B90095 #8B279B #2F1F90 #E3E1EE #C4D4F6 #A45EC7 #D8C3D7 #9C32B2 #9A009B #333A95 #EBDAFC #7786E5 #494FC7 #DFC2F8",
E: "#FDD3CC #FEC0DF #FFB7E7 #E8649E #F551A2 #F13D74 #C63478 #FFDBE9 #E970CC #D33793 #FCDDD2 #F78FC3 #B5006D #FFD1BA #F8C7C9 #FFF3EB #FFE2EA #FFC7DB #FEBAD5 #D8C7D1 #BD9DA1 #B785A1 #937A8D #E1BCE8",
F: "#FD957B #FC3D46 #F74941 #FC283C #E7002F #943630 #971937 #BC0028 #E2677A #8A4526 #5A2121 #FD4E6A #F35744 #FFA9AD #D30022 #FEC2A6 #E69C79 #D37C46 #C1444A #CD9391 #F7B4C6 #FDC0D0 #F67E66 #E698AA #E54B4F",
G: "#FFE2CE #FFC4AA #F4C3A5 #E1B383 #EDB045 #E99C17 #9D5B3E #753832 #E6B483 #D98C39 #E0C593 #FFC890 #B7714A #8D614C #FCF9E0 #F2D9BA #78524B #FFE4CC #E07935 #A94023 #B88558",
H: "#FDFBFF #FEFFFF #B6B1BA #89858C #48464E #2F2B2F #000000 #E7D6DB #EDEDED #EEE9EA #CECDD5 #FFF5ED #F5ECD2 #CFD7D3 #98A6A8 #1D1414 #F1EDED #FFFDF0 #F6EFE2 #949FA3 #FFFBE1 #CACAD4 #9A9D94",
M: "#BCC6B8 #8AA386 #697D80 #E3D2BC #D0CCAA #B0A782 #B4A497 #B38281 #A58767 #C5B2BC #9F7594 #644749 #D19066 #C77362 #757D78",
};
export const MARD_221: ReadonlyArray<readonly [string, string]> = Object.entries(SERIES)
.flatMap(([series, colors]) => colors.split(" ").map((hex, index) => [series + (index + 1), hex] as const));
+487
View File
@@ -0,0 +1,487 @@
"use client";
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { MARD_221 } from "./mard-palette";
type BeadColor = {
code: string;
name: string;
hex: string;
rgb: [number, number, number];
};
type Pixel = {
color: BeadColor;
};
type HoveredPixel = {
row: number;
column: number;
color: BeadColor;
left: number;
top: number;
};
type SavedPattern = {
id: string;
name: string;
savedAt: string;
width: number;
height: number;
colorLimit: number;
codes: string[];
};
const MARD_SERIES_NAMES: Record<string, string> = {
A: "黄橙系", B: "绿色系", C: "蓝青系", D: "紫蓝系", E: "粉红系",
F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系",
};
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => ({
code,
name: `MARD ${MARD_SERIES_NAMES[code[0]]}`,
hex,
rgb: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)],
}));
const hexToRgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
function colorDistance(a: [number, number, number], b: [number, number, number]) {
const meanR = (a[0] + b[0]) / 2;
const r = a[0] - b[0];
const g = a[1] - b[1];
const blue = a[2] - b[2];
return (2 + meanR / 256) * r * r + 4 * g * g + (2 + (255 - meanR) / 256) * blue * blue;
}
function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
let closest = palette[0];
let smallest = Number.POSITIVE_INFINITY;
for (const color of palette) {
const distance = colorDistance(rgb, color.rgb);
if (distance < smallest) {
smallest = distance;
closest = color;
}
}
return closest;
}
function makeDemoImage() {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 640;
const ctx = canvas.getContext("2d")!;
const sky = ctx.createLinearGradient(0, 0, 0, 640);
sky.addColorStop(0, "#bfe8f1");
sky.addColorStop(0.65, "#fff2ce");
sky.addColorStop(1, "#f6c783");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, 640, 640);
ctx.fillStyle = "#f6c742";
ctx.beginPath(); ctx.arc(488, 134, 70, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#58a95b";
ctx.beginPath(); ctx.moveTo(0, 455); ctx.quadraticCurveTo(135, 310, 300, 455); ctx.quadraticCurveTo(480, 285, 640, 435); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
ctx.fillStyle = "#27865a";
ctx.beginPath(); ctx.moveTo(0, 535); ctx.quadraticCurveTo(180, 390, 350, 520); ctx.quadraticCurveTo(505, 410, 640, 505); ctx.lineTo(640, 640); ctx.lineTo(0, 640); ctx.fill();
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(245, 390, 152, 126);
ctx.fillStyle = "#d9383a";
ctx.beginPath(); ctx.moveTo(218, 405); ctx.lineTo(321, 323); ctx.lineTo(424, 405); ctx.closePath(); ctx.fill();
ctx.fillStyle = "#754a32";
ctx.fillRect(300, 447, 43, 69);
ctx.fillStyle = "#79cbe1";
ctx.fillRect(258, 415, 39, 37); ctx.fillRect(350, 415, 34, 37);
return canvas.toDataURL("image/png");
}
export default function Home() {
const [sourceUrl, setSourceUrl] = useState("");
const [sourceName, setSourceName] = useState("示例:田野小屋");
const [gridWidth, setGridWidth] = useState(32);
const [gridHeight, setGridHeight] = useState(32);
const [colorLimit, setColorLimit] = useState(18);
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false);
const [zoom, setZoom] = useState(24);
const [showGrid, setShowGrid] = useState(true);
const [showCodes, setShowCodes] = useState(true);
const [query, setQuery] = useState("");
const [hoveredPixel, setHoveredPixel] = useState<HoveredPixel | null>(null);
const [history, setHistory] = useState<SavedPattern[]>([]);
const [historyQuery, setHistoryQuery] = useState("");
const [status, setStatus] = useState("示例图已准备好,可以直接转换");
const sourceImageRef = useRef<HTMLImageElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const saved = localStorage.getItem("bead-pattern-history");
if (!saved) return;
queueMicrotask(() => {
try { setHistory(JSON.parse(saved)); } catch { localStorage.removeItem("bead-pattern-history"); }
});
}, []);
const convertImage = (image = sourceImageRef.current) => {
if (!image) return;
const width = Math.max(8, Math.min(128, gridWidth));
const height = Math.max(8, Math.min(128, gridHeight));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, width, height);
const imageRatio = image.naturalWidth / image.naturalHeight;
const boxRatio = width / height;
let drawWidth = width;
let drawHeight = height;
if ((fitMode === "cover" && imageRatio > boxRatio) || (fitMode === "contain" && imageRatio < boxRatio)) {
drawHeight = height;
drawWidth = height * imageRatio;
} else {
drawWidth = width;
drawHeight = width / imageRatio;
}
ctx.drawImage(image, (width - drawWidth) / 2, (height - drawHeight) / 2, drawWidth, drawHeight);
const data = ctx.getImageData(0, 0, width, height).data;
const initialMatches: BeadColor[] = [];
const counts = new Map<string, number>();
for (let i = 0; i < data.length; i += 4) {
const match = nearestColor([data[i], data[i + 1], data[i + 2]], PALETTE);
initialMatches.push(match);
counts.set(match.code, (counts.get(match.code) ?? 0) + 1);
}
const limitedPalette = [...PALETTE]
.sort((a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0))
.slice(0, Math.max(2, Math.min(colorLimit, PALETTE.length)));
const converted = initialMatches.map((match, index) => {
const rgb: [number, number, number] = [data[index * 4], data[index * 4 + 1], data[index * 4 + 2]];
return { color: limitedPalette.some((c) => c.code === match.code) ? match : nearestColor(rgb, limitedPalette) };
});
setGridWidth(width);
setGridHeight(height);
setPixels(converted);
setSelectedCodes(new Set());
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆`);
};
useEffect(() => {
const demo = makeDemoImage();
const image = new Image();
image.onload = () => {
sourceImageRef.current = image;
setSourceUrl(demo);
convertImage(image);
};
image.src = demo;
// This intentionally runs once to create the initial example.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const usedColors = useMemo(() => {
const counts = new Map<string, number>();
pixels.forEach(({ color }) => counts.set(color.code, (counts.get(color.code) ?? 0) + 1));
return PALETTE.filter((color) => counts.has(color.code))
.map((color) => ({ ...color, count: counts.get(color.code)! }))
.sort((a, b) => b.count - a.count);
}, [pixels]);
const filteredColors = useMemo(() => {
const key = query.trim().toLowerCase();
return usedColors.filter((color) => !key || color.code.toLowerCase().includes(key) || color.name.includes(key) || color.hex.toLowerCase().includes(key));
}, [query, usedColors]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || pixels.length === 0) return;
const cell = zoom;
const ruler = Math.max(22, cell);
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = (gridWidth * cell + ruler) * ratio;
canvas.height = (gridHeight * cell + ruler) * ratio;
canvas.style.width = `${gridWidth * cell + ruler}px`;
canvas.style.height = `${gridHeight * cell + ruler}px`;
const ctx = canvas.getContext("2d")!;
ctx.scale(ratio, ratio);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, gridWidth * cell + ruler, gridHeight * cell + ruler);
ctx.fillStyle = "#f0eee7";
ctx.fillRect(ruler, 0, gridWidth * cell, ruler);
ctx.fillRect(0, ruler, ruler, gridHeight * cell);
ctx.strokeStyle = "rgba(32,38,36,.25)";
ctx.lineWidth = 0.6;
ctx.font = `600 ${Math.max(7, Math.min(10, cell * 0.35))}px Arial`;
ctx.fillStyle = "#56605b";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let column = 0; column < gridWidth; column++) {
const x = ruler + column * cell;
ctx.strokeRect(x + 0.3, 0.3, cell - 0.6, ruler - 0.6);
ctx.fillText(String(column + 1), x + cell / 2, ruler / 2);
}
for (let row = 0; row < gridHeight; row++) {
const y = ruler + row * cell;
ctx.strokeRect(0.3, y + 0.3, ruler - 0.6, cell - 0.6);
ctx.fillText(String(row + 1), ruler / 2, y + cell / 2);
}
pixels.forEach(({ color }, index) => {
const x = ruler + (index % gridWidth) * cell;
const y = ruler + Math.floor(index / gridWidth) * cell;
const hasSelection = selectedCodes.size > 0;
const isSelected = selectedCodes.has(color.code);
if (onlySelected && hasSelection && !isSelected) {
ctx.fillStyle = "#ffffff";
} else {
ctx.fillStyle = color.hex;
ctx.globalAlpha = hasSelection && !isSelected ? 0.12 : 1;
ctx.fillRect(x, y, cell, cell);
ctx.globalAlpha = 1;
}
if (showGrid) {
ctx.strokeStyle = hasSelection && !isSelected ? "rgba(32,38,36,.07)" : "rgba(32,38,36,.22)";
ctx.lineWidth = 0.6;
ctx.strokeRect(x + 0.3, y + 0.3, cell - 0.6, cell - 0.6);
}
if (showCodes && cell >= 22 && (!hasSelection || isSelected)) {
const [r, g, b] = hexToRgb(color.hex);
ctx.fillStyle = r * 0.299 + g * 0.587 + b * 0.114 > 160 ? "#1f2925" : "#ffffff";
ctx.font = `600 ${Math.max(7, cell * 0.28)}px Arial`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(color.code, x + cell / 2, y + cell / 2);
}
});
}, [pixels, gridWidth, gridHeight, zoom, selectedCodes, onlySelected, showGrid, showCodes]);
const handleUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setStatus("请选择 JPG、PNG 或 WebP 图片");
return;
}
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
if (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl);
sourceImageRef.current = image;
setSourceUrl(url);
setSourceName(file.name);
setStatus("图片已载入,点击“重新转换”生成图纸");
convertImage(image);
};
image.src = url;
};
const toggleColor = (code: string) => {
setSelectedCodes((current) => {
const next = new Set(current);
if (next.has(code)) next.delete(code); else next.add(code);
return next;
});
};
const handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ruler = Math.max(22, zoom);
const x = Math.floor((event.clientX - rect.left - ruler) / zoom);
const y = Math.floor((event.clientY - rect.top - ruler) / zoom);
const pixel = pixels[y * gridWidth + x];
if (x >= 0 && y >= 0 && x < gridWidth && y < gridHeight && pixel) toggleColor(pixel.color.code);
};
const handleCanvasMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const ruler = Math.max(22, zoom);
const column = Math.floor((event.clientX - rect.left - ruler) / zoom);
const row = Math.floor((event.clientY - rect.top - ruler) / zoom);
const pixel = pixels[row * gridWidth + column];
if (column < 0 || row < 0 || column >= gridWidth || row >= gridHeight || !pixel) {
setHoveredPixel(null);
return;
}
setHoveredPixel({ row: row + 1, column: column + 1, color: pixel.color, left: event.clientX - rect.left + 14, top: event.clientY - rect.top + 14 });
};
const persistHistory = (next: SavedPattern[]) => {
setHistory(next);
localStorage.setItem("bead-pattern-history", JSON.stringify(next));
};
const savePattern = () => {
if (!pixels.length) return;
const entry: SavedPattern = {
id: crypto.randomUUID(),
name: sourceName.replace(/\.[^.]+$/, "") || "未命名图纸",
savedAt: new Date().toISOString(),
width: gridWidth,
height: gridHeight,
colorLimit,
codes: pixels.map((pixel) => pixel.color.code),
};
persistHistory([entry, ...history].slice(0, 30));
setStatus("图纸已保存到本机历史记录");
};
const restorePattern = (entry: SavedPattern) => {
const colorMap = new Map(PALETTE.map((color) => [color.code, color]));
setPixels(entry.codes.map((code) => ({ color: colorMap.get(code) ?? PALETTE[0] })));
setGridWidth(entry.width);
setGridHeight(entry.height);
setColorLimit(entry.colorLimit);
setSourceName(entry.name);
setSelectedCodes(new Set());
setStatus(`已恢复历史图纸:${entry.name}`);
document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth" });
};
const removePattern = (id: string) => persistHistory(history.filter((entry) => entry.id !== id));
const filteredHistory = useMemo(() => {
const key = historyQuery.trim().toLowerCase();
return history.filter((entry) => !key || entry.name.toLowerCase().includes(key) || `${entry.width}x${entry.height}`.includes(key));
}, [history, historyQuery]);
const exportPng = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const link = document.createElement("a");
link.download = `拼豆图纸-${gridWidth}x${gridHeight}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
return (
<main>
<header className="topbar">
<a className="brand" href="#top" aria-label="豆格工坊首页">
<span className="brand-mark" aria-hidden="true"><i /><i /><i /><i /></span>
<span></span>
</a>
<span className="top-note"> · </span>
</header>
<section className="hero" id="top">
<div>
<p className="eyebrow">PIXEL BEAD STUDIO</p>
<h1><br /><em></em></h1>
<p className="hero-copy"></p>
</div>
<div className="hero-badge"><strong>{gridWidth} × {gridHeight}</strong><span></span></div>
</section>
<section className="workspace" aria-label="拼豆图纸转换工具">
<aside className="control-panel">
<div className="panel-heading"><span>01</span><div><h2></h2><p></p></div></div>
<label className="upload-card">
{/* The source may be a local blob URL, so framework image optimization is not applicable. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{sourceUrl ? <img src={sourceUrl} alt="待转换的原图预览" /> : <span className="upload-placeholder"></span>}
<span className="upload-overlay"></span>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleUpload} />
</label>
<p className="file-name" title={sourceName}>{sourceName}</p>
<div className="field-row">
<label><span></span><input type="number" min="8" max="128" value={gridWidth} onChange={(e) => setGridWidth(Number(e.target.value))} /></label>
<span className="times">×</span>
<label><span></span><input type="number" min="8" max="128" value={gridHeight} onChange={(e) => setGridHeight(Number(e.target.value))} /></label>
</div>
<label className="field"><span>使 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>
<label className="field"><span></span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="cover"></option><option value="contain"></option></select></label>
<button className="primary-button" onClick={() => convertImage()}></button>
<p className="privacy-note"></p>
</aside>
<section className="pattern-panel" id="pattern-preview">
<div className="pattern-toolbar">
<div><p className="section-kicker">02 / </p><h2></h2></div>
<div className="zoom-control" aria-label="图纸缩放">
<button onClick={() => setZoom((z) => Math.max(8, z - 2))} aria-label="缩小"></button>
<input aria-label="缩放比例" type="range" min="8" max="42" value={zoom} onChange={(e) => setZoom(Number(e.target.value))} />
<button onClick={() => setZoom((z) => Math.min(42, z + 2))} aria-label="放大"></button>
<output>{zoom}px</output>
</div>
</div>
<div className="canvas-stage">
<div className="canvas-wrap">
<canvas ref={canvasRef} onClick={handleCanvasClick} onMouseMove={handleCanvasMove} onMouseLeave={() => setHoveredPixel(null)} aria-label="转换后的拼豆像素图,点击格子可选择颜色" />
{hoveredPixel && <div className="pixel-tooltip" style={{ left: hoveredPixel.left, top: hoveredPixel.top }}>
<span className="tooltip-swatch" style={{ backgroundColor: hoveredPixel.color.hex }} />
<strong> {hoveredPixel.row} · {hoveredPixel.column} </strong>
<small>{hoveredPixel.color.code} · {hoveredPixel.color.name} · {hoveredPixel.color.hex}</small>
</div>}
</div>
</div>
<div className="pattern-footer">
<span className="status-dot" /> <span>{status}</span>
<div className="view-options">
<label><input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} /> </label>
<label><input type="checkbox" checked={showCodes} onChange={(e) => setShowCodes(e.target.checked)} /> </label>
<button className="save-button" onClick={savePattern}></button>
<button onClick={exportPng}> PNG</button>
</div>
</div>
<div className="pattern-legend">
<div className="legend-heading"><strong></strong><span> {usedColors.length} </span></div>
<div className="legend-list">
{usedColors.map((color) => <button key={color.code} className={selectedCodes.has(color.code) ? "active" : ""} onClick={() => toggleColor(color.code)}>
<i style={{ backgroundColor: color.hex }} /><strong>{color.code}</strong><span>{color.name}</span><b>{color.count} </b>
</button>)}
</div>
</div>
</section>
<aside className="color-panel">
<div className="panel-heading"><span>03</span><div><h2></h2><p>{usedColors.length} · {pixels.length} </p></div></div>
<div className="search-box"><span></span><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="输入 A6、MARD A 或 #FEAC4C" /></div>
<div className="selection-tools">
<label className="switch-row"><input type="checkbox" checked={onlySelected} onChange={(e) => setOnlySelected(e.target.checked)} /><span className="switch" /><span></span></label>
{selectedCodes.size > 0 && <button onClick={() => setSelectedCodes(new Set())}> {selectedCodes.size} </button>}
</div>
<div className="color-list">
{filteredColors.map((color) => {
const active = selectedCodes.has(color.code);
return <button key={color.code} className={`color-item ${active ? "active" : ""}`} onClick={() => toggleColor(color.code)} aria-pressed={active}>
<span className="swatch" style={{ backgroundColor: color.hex }} />
<span className="color-meta"><strong>{color.code} · {color.name}</strong><small>{color.hex}</small></span>
<b>{color.count}<small></small></b>
</button>;
})}
</div>
<p className="palette-note"><strong>MARD 221 </strong>使 A/B/C/D/E/F/G/H/M 线</p>
</aside>
</section>
<section className="history-section" aria-labelledby="history-title">
<div className="history-heading"><div><p className="section-kicker">LOCAL PATTERN ARCHIVE</p><h2 id="history-title"></h2><p> 30 </p></div>
<div className="search-box"><span></span><input value={historyQuery} onChange={(e) => setHistoryQuery(e.target.value)} placeholder="按名称或 32x32 查询" /></div>
</div>
{filteredHistory.length ? <div className="history-grid">{filteredHistory.map((entry) => {
const colorCounts = new Map<string, number>();
entry.codes.forEach((code) => colorCounts.set(code, (colorCounts.get(code) ?? 0) + 1));
const topCodes = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
return <article className="history-card" key={entry.id}>
<div className="history-swatches">{topCodes.map(([code, count]) => <i key={code} style={{ backgroundColor: PALETTE.find((color) => color.code === code)?.hex, flexGrow: count }} />)}</div>
<div><h3>{entry.name}</h3><p>{entry.width} × {entry.height} · {new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</p></div>
<div className="history-actions"><button onClick={() => restorePattern(entry)}></button><button onClick={() => removePattern(entry.id)}></button></div>
</article>;
})}</div> : <div className="history-empty"></div>}
</section>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}
+13
View File
@@ -0,0 +1,13 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}
+4
View File
@@ -0,0 +1,4 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};
+6
View File
@@ -0,0 +1,6 @@
services:
web:
build: .
restart: unless-stopped
ports:
- "${APP_PORT:-3200}:3200"
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});
+5
View File
@@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}
+41
View File
@@ -0,0 +1,41 @@
import { defineConfig, globalIgnores } from "eslint/config";
import eslint from "@eslint/js";
import next from "@next/eslint-plugin-next";
import jsxA11y from "eslint-plugin-jsx-a11y";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([
globalIgnores([
".next/**",
"dist/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
eslint.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
next.configs["core-web-vitals"],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.serviceworker,
},
},
settings: {
react: {
version: "detect",
},
},
},
]);
export default eslintConfig;
+58
View File
@@ -0,0 +1,58 @@
import { desc } from "drizzle-orm";
import { getDb } from "../../../../../db";
import { notes } from "../../../db/schema";
function toRouteErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "Unexpected error";
const detail =
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
const combined = `${message}\n${detail}`;
if (combined.includes("no such table") || combined.includes('from "notes"')) {
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
}
return message;
}
export async function GET() {
try {
const db = getDb();
const rows = await db
.select()
.from(notes)
.orderBy(desc(notes.createdAt), desc(notes.id))
.limit(20);
return Response.json({ notes: rows });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as {
title?: string;
content?: string;
};
const title = payload.title?.trim() ?? "";
const content = payload.content?.trim() ?? "";
if (!title) {
return Response.json({ error: "title is required" }, { status: 400 });
}
const db = getDb();
const [note] = await db.insert(notes).values({ title, content }).returning();
return Response.json({ note }, { status: 201 });
} catch (error) {
return Response.json(
{ error: toRouteErrorMessage(error) },
{ status: 500 }
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
+5
View File
@@ -0,0 +1,5 @@
import "vinext/types";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+10371
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "pixel-bead-pattern",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@eslint/js": "9.39.4",
"@next/eslint-plugin-next": "16.2.6",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "16.4.0",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.59.3",
"vinext": "1.0.0-beta.2",
"vite": "8.0.13",
"wrangler": "4.92.0"
},
"type": "module"
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises";
import test from "node:test";
const developmentPreviewMeta =
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
const templateRoot = new URL("../", import.meta.url);
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href);
return worker.fetch(
new Request("http://localhost/", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async () => new Response("Not found", { status: 404 }),
},
},
{
waitUntil() {},
passThroughOnException() {},
},
);
}
test("server-renders the starter loading skeleton", async () => {
const response = await render();
assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text();
assert.match(html, developmentPreviewMeta);
assert.match(html, /<title>Your site is taking shape<\/title>/i);
assert.match(html, /Building your site/);
assert.match(html, /Your site is taking shape/);
assert.match(
html,
/Your first version will appear here automatically when its ready\./,
);
assert.doesNotMatch(html, /Codex/);
assert.match(html, /react-loading-skeleton/);
assert.match(html, /role="status"/);
});
test("keeps the loading skeleton scoped and disposable", async () => {
const [preview, css, page, layout, packageJson, files] = await Promise.all([
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
readFile(new URL("preview.css", previewRoot), "utf8"),
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readdir(previewRoot),
]);
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
assert.match(preview, /from "react-loading-skeleton"/);
assert.match(preview, /baseColor="#eceae7"/);
assert.match(preview, /highlightColor="#f9f8f6"/);
assert.match(preview, /duration=\{2\.8\}/);
assert.match(preview, /sites-skeleton-search-placeholder/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
assert.match(css, /position:\s*fixed/);
assert.match(css, /inset:\s*0/);
assert.match(css, /opacity:\s*0\.52/);
assert.match(css, /prefers-reduced-motion:\s*reduce/);
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
assert.doesNotMatch(
preview,
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
);
assert.match(page, /export const metadata:\s*Metadata/);
assert.match(page, /"codex-preview": "development"/);
assert.match(page, /<SkeletonPreview \/>/);
assert.match(layout, /title:\s*"Starter Project"/);
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
await assert.rejects(
access(new URL("public/_sites-preview", templateRoot)),
);
});
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+59
View File
@@ -0,0 +1,59 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});
+47
View File
@@ -0,0 +1,47 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;