Initial MARD pixel bead pattern web
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.openai
|
||||
node_modules
|
||||
dist
|
||||
mard-source.html
|
||||
npm-debug.log*
|
||||
+41
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
+17
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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; } }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-3200}:3200"
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": []
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`),
|
||||
});
|
||||
Vendored
+5
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+10371
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 it’s 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)),
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user