This commit is contained in:
2026-09-13 14:58:27 +05:30
parent 4f80c16043
commit 9c7a16b57c
24 changed files with 2467 additions and 80 deletions
+147
View File
@@ -0,0 +1,147 @@
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough, Readable } from "node:stream";
import { createZipArchive } from "@/lib/create-zip";
import { drawIdText } from "@/lib/draw-id";
import { isPresetFont } from "@/lib/fonts";
import {
CUSTOM_FONT_FAMILY,
formatUserId,
MAX_FONT_BYTES,
MAX_IMAGE_BYTES,
parseGenerateConfig,
sanitizeFilename,
toIdDrawStyle,
} from "@/lib/id-config";
import { registerPresetFonts } from "@/lib/register-fonts";
export const runtime = "nodejs";
export const maxDuration = 300;
export const dynamic = "force-dynamic";
function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status });
}
export async function POST(req: Request): Promise<Response> {
let tmpDir: string | null = null;
try {
const form = await req.formData();
const imageFile = form.get("image");
const fontFile = form.get("font");
const configRaw = form.get("config");
if (!(imageFile instanceof File)) {
return jsonError("A base image is required");
}
if (imageFile.size === 0) {
return jsonError("The base image is empty");
}
if (imageFile.size > MAX_IMAGE_BYTES) {
return jsonError("Base image must be 10MB or smaller");
}
if (typeof configRaw !== "string") {
return jsonError("Missing generate config");
}
let config;
try {
config = parseGenerateConfig(JSON.parse(configRaw));
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid generate config";
return jsonError(message);
}
if (config.useCustomFont) {
if (!(fontFile instanceof File) || fontFile.size === 0) {
return jsonError("Custom font file is required when using a custom font");
}
if (fontFile.size > MAX_FONT_BYTES) {
return jsonError("Custom font must be 5MB or smaller");
}
} else if (!isPresetFont(config.fontFamily)) {
return jsonError("Unknown font family");
}
tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
registerPresetFonts();
const style = toIdDrawStyle(config);
if (config.useCustomFont && fontFile instanceof File) {
const ext = fontFile.name.toLowerCase().endsWith(".otf") ? ".otf" : ".ttf";
const fontPath = path.join(tmpDir, `custom${ext}`);
await writeFile(fontPath, Buffer.from(await fontFile.arrayBuffer()));
GlobalFonts.registerFromPath(fontPath, CUSTOM_FONT_FAMILY);
}
const image = await loadImage(
Buffer.from(await imageFile.arrayBuffer()),
);
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
const passThrough = new PassThrough();
const archive = createZipArchive();
archive.on("error", (error: Error) => {
passThrough.destroy(error);
});
archive.pipe(passThrough);
const usedNames = new Set<string>();
const generate = async () => {
try {
for (let i = 0; i < config.count; i += 1) {
const id = formatUserId(
config.prefix,
config.start + i,
config.pad,
config.suffix,
);
let name = `${sanitizeFilename(id)}.png`;
if (usedNames.has(name)) {
name = `${sanitizeFilename(id)}-${i}.png`;
}
usedNames.add(name);
ctx.drawImage(image, 0, 0);
drawIdText(ctx, id, image.width, image.height, style);
const png = await canvas.encode("png");
archive.append(Buffer.from(png), { name });
}
await archive.finalize();
} catch (error) {
archive.abort();
passThrough.destroy(
error instanceof Error ? error : new Error("Failed to generate zip"),
);
} finally {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
};
void generate();
return new Response(Readable.toWeb(passThrough) as ReadableStream, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": 'attachment; filename="ids.zip"',
"Cache-Control": "no-store",
},
});
} catch (error) {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
const message =
error instanceof Error ? error.message : "Failed to generate images";
return jsonError(message, 500);
}
}
+860
View File
@@ -0,0 +1,860 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import {
drawIdSelection,
drawIdText,
hitTestTextBox,
measureIdTextBox,
} from "@/lib/draw-id";
import { PRESET_FONTS } from "@/lib/fonts";
import {
CUSTOM_FONT_FAMILY,
DEFAULT_CONFIG,
formatUserId,
MAX_COUNT,
MAX_FONT_BYTES,
MAX_IMAGE_BYTES,
toIdDrawStyle,
type GenerateConfig,
type TextAlign,
} from "@/lib/id-config";
const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf";
function canvasPoint(
canvas: HTMLCanvasElement,
clientX: number,
clientY: number,
): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * canvas.width,
y: ((clientY - rect.top) / rect.height) * canvas.height,
};
}
export function Editor() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fontInputRef = useRef<HTMLInputElement>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(0);
const [customFontFile, setCustomFontFile] = useState<File | null>(null);
const [customFontName, setCustomFontName] = useState<string | null>(null);
const [config, setConfig] = useState<GenerateConfig>(DEFAULT_CONFIG);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
"default",
);
const dragRef = useRef<{
active: boolean;
offsetX: number;
offsetY: number;
}>({ active: false, offsetX: 0, offsetY: 0 });
const sampleId = useMemo(
() =>
formatUserId(config.prefix, config.start, config.pad, config.suffix),
[config.prefix, config.start, config.pad, config.suffix],
);
const lastId = useMemo(
() =>
formatUserId(
config.prefix,
config.start + config.count - 1,
config.pad,
config.suffix,
),
[config.prefix, config.start, config.pad, config.suffix, config.count],
);
const drawStyle = useMemo(() => toIdDrawStyle(config), [config]);
const patch = useCallback((partial: Partial<GenerateConfig>) => {
setConfig((current) => ({ ...current, ...partial }));
}, []);
const revokeImageUrl = useCallback((url: string | null) => {
if (url) URL.revokeObjectURL(url);
}, []);
const loadImageFile = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) {
setError("Please choose a PNG, JPG, or WebP image");
return;
}
if (file.size > MAX_IMAGE_BYTES) {
setError("Base image must be 10MB or smaller");
return;
}
setError(null);
setImageFile(file);
setImageUrl((previous) => {
revokeImageUrl(previous);
return URL.createObjectURL(file);
});
},
[revokeImageUrl],
);
useEffect(() => {
return () => revokeImageUrl(imageUrl);
}, [imageUrl, revokeImageUrl]);
useEffect(() => {
if (!imageUrl) {
imageRef.current = null;
return;
}
const image = new Image();
image.onload = () => {
imageRef.current = image;
setImageReady((value) => value + 1);
};
image.onerror = () => {
setError("Could not read that image");
imageRef.current = null;
};
image.src = imageUrl;
}, [imageUrl]);
const redraw = useCallback(async () => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image) return;
await document.fonts.ready;
if (config.useCustomFont) {
await document.fonts.load(drawStyle.fontSize + "px " + CUSTOM_FONT_FAMILY);
} else {
await document.fonts.load(
`${config.bold ? "700" : "400"} ${config.fontSize}px "${config.fontFamily}"`,
);
}
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(image, 0, 0);
drawIdText(ctx, sampleId, image.naturalWidth, image.naturalHeight, drawStyle);
const box = measureIdTextBox(
ctx,
sampleId,
image.naturalWidth,
image.naturalHeight,
drawStyle,
);
drawIdSelection(ctx, box, image.naturalWidth);
}, [config.bold, config.fontFamily, config.fontSize, config.useCustomFont, drawStyle, sampleId]);
useEffect(() => {
void redraw();
}, [redraw, imageReady]);
const loadCustomFont = useCallback(
async (file: File) => {
const name = file.name.toLowerCase();
if (!name.endsWith(".ttf") && !name.endsWith(".otf")) {
setError("Please choose a TTF or OTF font");
return;
}
if (file.size > MAX_FONT_BYTES) {
setError("Custom font must be 5MB or smaller");
return;
}
try {
const buffer = await file.arrayBuffer();
const face = new FontFace(CUSTOM_FONT_FAMILY, buffer);
await face.load();
document.fonts.add(face);
setCustomFontFile(file);
setCustomFontName(file.name);
patch({ useCustomFont: true, fontFamily: CUSTOM_FONT_FAMILY });
setError(null);
} catch {
setError("Could not load that font file");
}
},
[patch],
);
const onPointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const point = canvasPoint(canvas, event.clientX, event.clientY);
const box = measureIdTextBox(
ctx,
sampleId,
image.naturalWidth,
image.naturalHeight,
drawStyle,
);
let anchorX = (config.xPercent / 100) * image.naturalWidth;
let anchorY = (config.yPercent / 100) * image.naturalHeight;
if (!hitTestTextBox(box, point.x, point.y, image.naturalWidth)) {
anchorX = point.x;
anchorY = point.y;
patch({
xPercent: (point.x / image.naturalWidth) * 100,
yPercent: (point.y / image.naturalHeight) * 100,
});
}
dragRef.current = {
active: true,
offsetX: point.x - anchorX,
offsetY: point.y - anchorY,
};
setCursor("grabbing");
canvas.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image) return;
const point = canvasPoint(canvas, event.clientX, event.clientY);
if (!dragRef.current.active) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const box = measureIdTextBox(
ctx,
sampleId,
image.naturalWidth,
image.naturalHeight,
drawStyle,
);
setCursor(
hitTestTextBox(box, point.x, point.y, image.naturalWidth)
? "grab"
: "default",
);
return;
}
const x = point.x - dragRef.current.offsetX;
const y = point.y - dragRef.current.offsetY;
patch({
xPercent: Math.min(100, Math.max(0, (x / image.naturalWidth) * 100)),
yPercent: Math.min(100, Math.max(0, (y / image.naturalHeight) * 100)),
});
};
const onPointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
dragRef.current.active = false;
setCursor("grab");
event.currentTarget.releasePointerCapture(event.pointerId);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) loadImageFile(file);
};
const generate = async () => {
if (!imageFile) {
setError("Choose a base image first");
return;
}
if (config.useCustomFont && !customFontFile) {
setError("Upload a custom font or switch back to a preset");
return;
}
setBusy(true);
setError(null);
setStatus(`Generating ${config.count} images on the server…`);
try {
const form = new FormData();
form.append("image", imageFile);
if (config.useCustomFont && customFontFile) {
form.append("font", customFontFile);
}
form.append("config", JSON.stringify(config));
const response = await fetch("/api/generate", {
method: "POST",
body: form,
});
if (!response.ok) {
let message = "Generation failed";
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
message = `Generation failed (${response.status})`;
}
throw new Error(message);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "ids.zip";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
} catch (err) {
setStatus(null);
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
setBusy(false);
}
};
return (
<div className="flex min-h-screen flex-col">
<div className="mx-auto grid w-full max-w-7xl flex-1 grid-cols-1 gap-6 p-6 lg:grid-cols-[minmax(0,1fr)_380px]">
<section
className="flex min-h-[420px] flex-col rounded-2xl border border-zinc-800 bg-zinc-950"
onDragOver={(event) => event.preventDefault()}
onDrop={onDrop}
>
{!imageUrl ? (
<button
type="button"
onClick={() => imageInputRef.current?.click()}
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"
>
<span className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1 text-xs text-zinc-300">
PNG · JPG · WebP
</span>
<span className="text-lg font-medium">Select a base photo</span>
</button>
) : (
<div className="flex flex-1 items-center justify-center p-4">
<canvas
ref={canvasRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="max-h-[70vh] max-w-full rounded-lg shadow-2xl shadow-black/40"
style={{ cursor }}
/>
</div>
)}
</section>
<aside className="flex flex-col gap-5 rounded-2xl border border-zinc-800 bg-zinc-950 p-5 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
<FieldGroup title="Base image">
<FileButton
onClick={() => imageInputRef.current?.click()}
label={imageFile ? imageFile.name : "Choose image"}
/>
<input
ref={imageInputRef}
type="file"
accept={IMAGE_ACCEPT}
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) loadImageFile(file);
}}
/>
</FieldGroup>
<FieldGroup title="User ID">
<div className="grid grid-cols-2 gap-3">
<TextField
label="Prefix"
value={config.prefix}
onChange={(value) => patch({ prefix: value })}
/>
<TextField
label="Suffix"
value={config.suffix}
onChange={(value) => patch({ suffix: value })}
/>
<NumberField
label="Start at"
value={config.start}
min={0}
max={1_000_000}
onChange={(value) => patch({ start: value })}
/>
<NumberField
label="Pad digits"
value={config.pad}
min={0}
max={6}
onChange={(value) => patch({ pad: value })}
/>
<NumberField
label="Count"
value={config.count}
min={1}
max={MAX_COUNT}
onChange={(value) => patch({ count: value })}
/>
</div>
<p className="rounded-lg bg-zinc-900 px-3 py-2 font-mono text-xs text-zinc-300">
{sampleId}.png
<span className="text-zinc-500"> </span>
{lastId}.png
</p>
</FieldGroup>
<FieldGroup title="Text style">
<label className="block text-xs text-zinc-400">
Font
<select
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
value={
config.useCustomFont ? CUSTOM_FONT_FAMILY : config.fontFamily
}
onChange={(event) => {
const value = event.target.value;
if (value === CUSTOM_FONT_FAMILY) {
patch({
useCustomFont: true,
fontFamily: CUSTOM_FONT_FAMILY,
});
return;
}
patch({ useCustomFont: false, fontFamily: value });
}}
>
{PRESET_FONTS.map((font) => (
<option key={font.id} value={font.id}>
{font.label}
</option>
))}
{customFontName ? (
<option value={CUSTOM_FONT_FAMILY}>
Custom: {customFontName}
</option>
) : null}
</select>
</label>
<FileButton
onClick={() => fontInputRef.current?.click()}
label={
customFontName
? `Replace font (${customFontName})`
: "Upload TTF / OTF"
}
/>
<input
ref={fontInputRef}
type="file"
accept={FONT_ACCEPT}
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void loadCustomFont(file);
}}
/>
<RangeField
label="Size"
value={config.fontSize}
min={8}
max={220}
onChange={(value) => patch({ fontSize: value })}
suffix="px"
/>
<label className="block text-xs text-zinc-400">
Color
<span className="mt-1 flex items-center gap-2">
<input
type="color"
value={config.color.length === 7 ? config.color : "#111111"}
onChange={(event) => patch({ color: event.target.value })}
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
/>
<input
value={config.color}
onChange={(event) => patch({ color: event.target.value })}
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
/>
</span>
</label>
<div className="flex flex-wrap gap-2">
<Toggle
pressed={config.bold}
onClick={() => patch({ bold: !config.bold })}
label="Bold"
/>
<Toggle
pressed={config.italic}
onClick={() => patch({ italic: !config.italic })}
label="Italic"
/>
{(["left", "center", "right"] as TextAlign[]).map((align) => (
<Toggle
key={align}
pressed={config.align === align}
onClick={() => patch({ align })}
label={align[0].toUpperCase() + align.slice(1)}
/>
))}
</div>
</FieldGroup>
<FieldGroup title="Drop shadow">
<Toggle
pressed={config.shadowEnabled}
onClick={() => patch({ shadowEnabled: !config.shadowEnabled })}
label={config.shadowEnabled ? "On" : "Off"}
/>
{config.shadowEnabled ? (
<>
<label className="block text-xs text-zinc-400">
Color
<span className="mt-1 flex items-center gap-2">
<input
type="color"
value={
config.shadowColor.length === 7
? config.shadowColor
: "#000000"
}
onChange={(event) =>
patch({ shadowColor: event.target.value })
}
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
/>
<input
value={config.shadowColor}
onChange={(event) =>
patch({ shadowColor: event.target.value })
}
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
/>
</span>
</label>
<RangeField
label="Opacity"
value={Number(config.shadowOpacity.toFixed(2))}
min={0}
max={1}
step={0.05}
onChange={(value) => patch({ shadowOpacity: value })}
/>
<RangeField
label="Blur"
value={config.shadowBlur}
min={0}
max={40}
onChange={(value) => patch({ shadowBlur: value })}
suffix="px"
/>
<RangeField
label="Offset X"
value={config.shadowOffsetX}
min={-30}
max={30}
onChange={(value) => patch({ shadowOffsetX: value })}
suffix="px"
/>
<RangeField
label="Offset Y"
value={config.shadowOffsetY}
min={-30}
max={30}
onChange={(value) => patch({ shadowOffsetY: value })}
suffix="px"
/>
</>
) : null}
</FieldGroup>
<FieldGroup title="Outline">
<Toggle
pressed={config.outlineEnabled}
onClick={() => patch({ outlineEnabled: !config.outlineEnabled })}
label={config.outlineEnabled ? "On" : "Off"}
/>
{config.outlineEnabled ? (
<>
<label className="block text-xs text-zinc-400">
Color
<span className="mt-1 flex items-center gap-2">
<input
type="color"
value={
config.outlineColor.length === 7
? config.outlineColor
: "#ffffff"
}
onChange={(event) =>
patch({ outlineColor: event.target.value })
}
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
/>
<input
value={config.outlineColor}
onChange={(event) =>
patch({ outlineColor: event.target.value })
}
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
/>
</span>
</label>
<RangeField
label="Opacity"
value={Number(config.outlineOpacity.toFixed(2))}
min={0}
max={1}
step={0.05}
onChange={(value) => patch({ outlineOpacity: value })}
/>
<RangeField
label="Width"
value={config.outlineWidth}
min={0.5}
max={20}
step={0.5}
onChange={(value) => patch({ outlineWidth: value })}
suffix="px"
/>
</>
) : null}
</FieldGroup>
<FieldGroup title="Position">
<p className="text-xs text-zinc-500">
Drag the ID on the preview, click to place it, or nudge the
percentages.
</p>
<RangeField
label="X"
value={Number(config.xPercent.toFixed(1))}
min={0}
max={100}
step={0.1}
onChange={(value) => patch({ xPercent: value })}
suffix="%"
/>
<RangeField
label="Y"
value={Number(config.yPercent.toFixed(1))}
min={0}
max={100}
step={0.1}
onChange={(value) => patch({ yPercent: value })}
suffix="%"
/>
</FieldGroup>
{error ? (
<p className="rounded-lg border border-red-900 bg-red-950/60 px-3 py-2 text-sm text-red-200">
{error}
</p>
) : null}
{status && !error ? (
<p className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-2 text-sm text-indigo-100">
{status}
</p>
) : null}
<button
type="button"
onClick={() => void generate()}
disabled={busy || !imageFile}
className="rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:bg-zinc-700 disabled:text-zinc-400"
>
{busy ? "Generating…" : `Generate ${config.count} images`}
</button>
</aside>
</div>
</div>
);
}
function FieldGroup({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
{title}
</h2>
{children}
</section>
);
}
function FileButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="w-full truncate rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-left text-sm text-zinc-200 hover:border-zinc-600"
>
{label}
</button>
);
}
function TextField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block text-xs text-zinc-400">
{label}
<input
value={value}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange(event.target.value)
}
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
/>
</label>
);
}
function NumberField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
return (
<label className="block text-xs text-zinc-400">
{label}
<input
type="number"
value={value}
min={min}
max={max}
onChange={(event) => {
const next = Number(event.target.value);
if (!Number.isFinite(next)) return;
onChange(Math.min(max, Math.max(min, Math.trunc(next))));
}}
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
/>
</label>
);
}
function RangeField({
label,
value,
min,
max,
step = 1,
suffix,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return (
<label className="block text-xs text-zinc-400">
<span className="flex justify-between">
<span>{label}</span>
<span className="font-mono text-zinc-300">
{value}
{suffix}
</span>
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="mt-2 w-full"
/>
</label>
);
}
function Toggle({
pressed,
onClick,
label,
}: {
pressed: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-lg border px-3 py-1.5 text-xs capitalize ${
pressed
? "border-indigo-400 bg-indigo-500/20 text-indigo-100"
: "border-zinc-800 bg-zinc-900 text-zinc-300"
}`}
>
{label}
</button>
);
}
+93 -8
View File
@@ -1,26 +1,111 @@
@import "tailwindcss"; @import "tailwindcss";
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Roboto";
src: url("/fonts/Roboto-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Roboto";
src: url("/fonts/Roboto-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Oswald";
src: url("/fonts/Oswald-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Oswald";
src: url("/fonts/Oswald-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Playfair Display";
src: url("/fonts/PlayfairDisplay-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Playfair Display";
src: url("/fonts/PlayfairDisplay-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root { :root {
--background: #ffffff; --background: #09090b;
--foreground: #171717; --foreground: #f4f4f5;
--panel: #18181b;
--border: #27272a;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-panel: var(--panel);
--color-border: var(--border);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { * {
:root { box-sizing: border-box;
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
input[type="range"] {
accent-color: #6366f1;
} }
+5 -3
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "KK Card Gen",
description: "Generated by create next app", description: "Stamp sequential user IDs onto a base card image and download a zip of PNGs",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
@@ -23,7 +23,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col bg-background text-foreground">
{children}
</body>
</html> </html>
); );
} }
+2 -66
View File
@@ -1,69 +1,5 @@
import Image from "next/image"; import { Editor } from "./editor";
export default function Home() { export default function Home() {
return ( return <Editor />;
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
} }
+5
View File
@@ -0,0 +1,5 @@
import { ZipArchive } from "archiver";
export function createZipArchive(level = 6): ZipArchive {
return new ZipArchive({ zlib: { level } });
}
+208
View File
@@ -0,0 +1,208 @@
import { buildCanvasFont, hexToRgba, type IdDrawStyle } from "./id-config";
export type TextMetricsLike = {
width: number;
actualBoundingBoxAscent?: number;
actualBoundingBoxDescent?: number;
};
export type Canvas2D = {
font: string;
fillStyle: string | CanvasGradient | CanvasPattern;
strokeStyle: string | CanvasGradient | CanvasPattern;
lineWidth: number;
textAlign: CanvasTextAlign;
textBaseline: CanvasTextBaseline;
shadowColor: string;
shadowBlur: number;
shadowOffsetX: number;
shadowOffsetY: number;
lineJoin: CanvasLineJoin;
miterLimit: number;
fillText(text: string, x: number, y: number): void;
strokeText(text: string, x: number, y: number): void;
measureText(text: string): TextMetricsLike;
save(): void;
restore(): void;
setLineDash(segments: number[]): void;
strokeRect(x: number, y: number, w: number, h: number): void;
};
export type TextBox = {
left: number;
top: number;
width: number;
height: number;
};
export type GlyphMetrics = {
width: number;
ascent: number;
descent: number;
height: number;
};
export function positionFromPercent(
xPercent: number,
yPercent: number,
imageWidth: number,
imageHeight: number,
): { x: number; y: number } {
return {
x: (xPercent / 100) * imageWidth,
y: (yPercent / 100) * imageHeight,
};
}
function applyFont(ctx: Canvas2D, style: IdDrawStyle): void {
ctx.font = buildCanvasFont(style);
ctx.textAlign = style.align;
ctx.textBaseline = "alphabetic";
}
export function measureGlyphs(
ctx: Canvas2D,
text: string,
style: IdDrawStyle,
): GlyphMetrics {
applyFont(ctx, style);
const metrics = ctx.measureText(text);
const ascent = metrics.actualBoundingBoxAscent ?? style.fontSize * 0.8;
const descent = metrics.actualBoundingBoxDescent ?? style.fontSize * 0.2;
return {
width: metrics.width,
ascent,
descent,
height: ascent + descent,
};
}
export function alphabeticBaselineY(
visualCenterY: number,
glyphs: GlyphMetrics,
): number {
return visualCenterY + (glyphs.ascent - glyphs.descent) / 2;
}
export function applyIdStyle(ctx: Canvas2D, style: IdDrawStyle): void {
applyFont(ctx, style);
ctx.fillStyle = style.color;
}
function applyShadow(ctx: Canvas2D, style: IdDrawStyle): void {
if (!style.shadowEnabled) {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
return;
}
ctx.shadowColor = hexToRgba(style.shadowColor, style.shadowOpacity);
ctx.shadowBlur = style.shadowBlur;
ctx.shadowOffsetX = style.shadowOffsetX;
ctx.shadowOffsetY = style.shadowOffsetY;
}
export function drawIdText(
ctx: Canvas2D,
text: string,
imageWidth: number,
imageHeight: number,
style: IdDrawStyle,
): void {
const { x, y } = positionFromPercent(
style.xPercent,
style.yPercent,
imageWidth,
imageHeight,
);
const glyphs = measureGlyphs(ctx, text, style);
const baselineY = alphabeticBaselineY(y, glyphs);
ctx.save();
applyIdStyle(ctx, style);
applyShadow(ctx, style);
if (style.outlineEnabled && style.outlineWidth > 0) {
ctx.strokeStyle = hexToRgba(style.outlineColor, style.outlineOpacity);
ctx.lineWidth = style.outlineWidth;
ctx.lineJoin = "round";
ctx.miterLimit = 2;
ctx.strokeText(text, x, baselineY);
ctx.shadowColor = "transparent";
}
ctx.fillText(text, x, baselineY);
ctx.restore();
}
export function measureIdTextBox(
ctx: Canvas2D,
text: string,
imageWidth: number,
imageHeight: number,
style: IdDrawStyle,
): TextBox {
const glyphs = measureGlyphs(ctx, text, style);
const { x, y } = positionFromPercent(
style.xPercent,
style.yPercent,
imageWidth,
imageHeight,
);
let left = x;
if (style.align === "center") left = x - glyphs.width / 2;
if (style.align === "right") left = x - glyphs.width;
const outlinePad =
style.outlineEnabled && style.outlineWidth > 0
? style.outlineWidth / 2
: 0;
return {
left: left - outlinePad,
top: y - glyphs.height / 2 - outlinePad,
width: glyphs.width + outlinePad * 2,
height: glyphs.height + outlinePad * 2,
};
}
export function drawIdSelection(
ctx: Canvas2D,
box: TextBox,
imageWidth: number,
): void {
const pad = Math.max(6, imageWidth / 400);
ctx.save();
ctx.shadowColor = "transparent";
ctx.strokeStyle = "rgba(99, 102, 241, 0.95)";
ctx.lineWidth = Math.max(1, imageWidth / 700);
ctx.setLineDash([
Math.max(4, imageWidth / 180),
Math.max(3, imageWidth / 220),
]);
ctx.strokeRect(
box.left - pad,
box.top - pad,
box.width + pad * 2,
box.height + pad * 2,
);
ctx.restore();
}
export function hitTestTextBox(
box: TextBox,
x: number,
y: number,
imageWidth: number,
): boolean {
const pad = Math.max(10, imageWidth / 80);
return (
x >= box.left - pad &&
x <= box.left + box.width + pad &&
y >= box.top - pad &&
y <= box.top + box.height + pad
);
}
+43
View File
@@ -0,0 +1,43 @@
export type PresetFont = {
id: string;
label: string;
regular: string;
bold: string;
};
export const PRESET_FONTS: PresetFont[] = [
{
id: "Inter",
label: "Inter",
regular: "Inter-Regular.ttf",
bold: "Inter-Bold.ttf",
},
{
id: "Roboto",
label: "Roboto",
regular: "Roboto-Regular.ttf",
bold: "Roboto-Bold.ttf",
},
{
id: "Oswald",
label: "Oswald",
regular: "Oswald-Regular.ttf",
bold: "Oswald-Bold.ttf",
},
{
id: "Montserrat",
label: "Montserrat",
regular: "Montserrat-Regular.ttf",
bold: "Montserrat-Bold.ttf",
},
{
id: "Playfair Display",
label: "Playfair Display",
regular: "PlayfairDisplay-Regular.ttf",
bold: "PlayfairDisplay-Bold.ttf",
},
];
export function isPresetFont(family: string): boolean {
return PRESET_FONTS.some((font) => font.id === family);
}
+277
View File
@@ -0,0 +1,277 @@
export type TextAlign = "left" | "center" | "right";
export type IdDrawStyle = {
fontFamily: string;
fontSize: number;
color: string;
bold: boolean;
italic: boolean;
align: TextAlign;
xPercent: number;
yPercent: number;
shadowEnabled: boolean;
shadowColor: string;
shadowOpacity: number;
shadowBlur: number;
shadowOffsetX: number;
shadowOffsetY: number;
outlineEnabled: boolean;
outlineColor: string;
outlineOpacity: number;
outlineWidth: number;
};
export type GenerateConfig = IdDrawStyle & {
prefix: string;
suffix: string;
start: number;
pad: number;
count: number;
useCustomFont: boolean;
};
export const MAX_COUNT = 1000;
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_FONT_BYTES = 5 * 1024 * 1024;
export const CUSTOM_FONT_FAMILY = "CustomUpload";
export const DEFAULT_CONFIG: GenerateConfig = {
prefix: "KK",
suffix: "",
start: 1,
pad: 4,
count: 1000,
fontFamily: "Inter",
fontSize: 48,
color: "#111111",
bold: false,
italic: false,
align: "center",
xPercent: 50,
yPercent: 50,
shadowEnabled: false,
shadowColor: "#000000",
shadowOpacity: 0.45,
shadowBlur: 8,
shadowOffsetX: 2,
shadowOffsetY: 3,
outlineEnabled: false,
outlineColor: "#ffffff",
outlineOpacity: 1,
outlineWidth: 3,
useCustomFont: false,
};
const COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export function formatUserId(
prefix: string,
n: number,
pad: number,
suffix: string,
): string {
return `${prefix}${String(n).padStart(Math.max(0, pad), "0")}${suffix}`;
}
export function sanitizeFilename(id: string): string {
const cleaned = id
.replace(/[/\\:*?"<>|]/g, "")
.replace(/\s+/g, " ")
.trim();
return cleaned || "id";
}
export function buildCanvasFont(
style: Pick<IdDrawStyle, "fontFamily" | "fontSize" | "bold" | "italic">,
): string {
const italic = style.italic ? "italic " : "";
const weight = style.bold ? "700 " : "400 ";
return `${italic}${weight}${style.fontSize}px "${style.fontFamily}"`;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function asNumber(value: unknown, fallback: number): number {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asBool(value: unknown): boolean {
return value === true;
}
export function parseGenerateConfig(raw: unknown): GenerateConfig {
if (!raw || typeof raw !== "object") {
throw new Error("Invalid config");
}
const input = raw as Record<string, unknown>;
const start = Math.trunc(asNumber(input.start, DEFAULT_CONFIG.start));
const pad = Math.trunc(asNumber(input.pad, DEFAULT_CONFIG.pad));
const count = Math.trunc(asNumber(input.count, DEFAULT_CONFIG.count));
const fontSize = asNumber(input.fontSize, DEFAULT_CONFIG.fontSize);
const xPercent = asNumber(input.xPercent, DEFAULT_CONFIG.xPercent);
const yPercent = asNumber(input.yPercent, DEFAULT_CONFIG.yPercent);
const shadowOpacity = asNumber(
input.shadowOpacity,
DEFAULT_CONFIG.shadowOpacity,
);
const shadowBlur = asNumber(input.shadowBlur, DEFAULT_CONFIG.shadowBlur);
const shadowOffsetX = asNumber(
input.shadowOffsetX,
DEFAULT_CONFIG.shadowOffsetX,
);
const shadowOffsetY = asNumber(
input.shadowOffsetY,
DEFAULT_CONFIG.shadowOffsetY,
);
const outlineOpacity = asNumber(
input.outlineOpacity,
DEFAULT_CONFIG.outlineOpacity,
);
const outlineWidth = asNumber(
input.outlineWidth,
DEFAULT_CONFIG.outlineWidth,
);
const color = asString(input.color, DEFAULT_CONFIG.color);
const shadowColor = asString(input.shadowColor, DEFAULT_CONFIG.shadowColor);
const outlineColor = asString(
input.outlineColor,
DEFAULT_CONFIG.outlineColor,
);
const align = asString(input.align, DEFAULT_CONFIG.align);
const prefix = asString(input.prefix).slice(0, 50);
const suffix = asString(input.suffix).slice(0, 50);
const fontFamily = asString(input.fontFamily, DEFAULT_CONFIG.fontFamily).slice(
0,
80,
);
if (start < 0 || start > 1_000_000) {
throw new Error("Start number must be between 0 and 1000000");
}
if (pad < 0 || pad > 6) {
throw new Error("Pad length must be between 0 and 6");
}
if (count < 1 || count > MAX_COUNT) {
throw new Error(`Count must be between 1 and ${MAX_COUNT}`);
}
if (fontSize < 8 || fontSize > 400) {
throw new Error("Font size must be between 8 and 400");
}
if (xPercent < 0 || xPercent > 100 || yPercent < 0 || yPercent > 100) {
throw new Error("Position must be between 0 and 100 percent");
}
if (!COLOR_RE.test(color)) {
throw new Error("Color must be a hex value like #111111");
}
if (!COLOR_RE.test(shadowColor)) {
throw new Error("Shadow color must be a hex value like #000000");
}
if (shadowOpacity < 0 || shadowOpacity > 1) {
throw new Error("Shadow opacity must be between 0 and 1");
}
if (shadowBlur < 0 || shadowBlur > 80) {
throw new Error("Shadow blur must be between 0 and 80");
}
if (
shadowOffsetX < -80 ||
shadowOffsetX > 80 ||
shadowOffsetY < -80 ||
shadowOffsetY > 80
) {
throw new Error("Shadow offset must be between -80 and 80");
}
if (!COLOR_RE.test(outlineColor)) {
throw new Error("Outline color must be a hex value like #ffffff");
}
if (outlineOpacity < 0 || outlineOpacity > 1) {
throw new Error("Outline opacity must be between 0 and 1");
}
if (outlineWidth < 0 || outlineWidth > 40) {
throw new Error("Outline width must be between 0 and 40");
}
if (align !== "left" && align !== "center" && align !== "right") {
throw new Error("Alignment must be left, center, or right");
}
return {
prefix,
suffix,
start,
pad,
count,
fontFamily,
fontSize,
color,
bold: asBool(input.bold),
italic: asBool(input.italic),
align,
xPercent,
yPercent,
shadowEnabled: asBool(input.shadowEnabled),
shadowColor,
shadowOpacity,
shadowBlur,
shadowOffsetX,
shadowOffsetY,
outlineEnabled: asBool(input.outlineEnabled),
outlineColor,
outlineOpacity,
outlineWidth,
useCustomFont: asBool(input.useCustomFont),
};
}
export function toIdDrawStyle(
config: GenerateConfig,
fontFamily = config.useCustomFont ? CUSTOM_FONT_FAMILY : config.fontFamily,
): IdDrawStyle {
return {
fontFamily,
fontSize: config.fontSize,
color: config.color,
bold: config.bold,
italic: config.italic,
align: config.align,
xPercent: config.xPercent,
yPercent: config.yPercent,
shadowEnabled: config.shadowEnabled,
shadowColor: config.shadowColor,
shadowOpacity: config.shadowOpacity,
shadowBlur: config.shadowBlur,
shadowOffsetX: config.shadowOffsetX,
shadowOffsetY: config.shadowOffsetY,
outlineEnabled: config.outlineEnabled,
outlineColor: config.outlineColor,
outlineOpacity: config.outlineOpacity,
outlineWidth: config.outlineWidth,
};
}
export function hexToRgba(hex: string, opacity: number): string {
let value = hex.replace("#", "");
if (value.length === 3) {
value = value
.split("")
.map((char) => char + char)
.join("");
}
let alpha = clamp(opacity, 0, 1);
if (value.length === 8) {
alpha *= parseInt(value.slice(6, 8), 16) / 255;
value = value.slice(0, 6);
}
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
+16
View File
@@ -0,0 +1,16 @@
import { GlobalFonts } from "@napi-rs/canvas";
import path from "node:path";
import { PRESET_FONTS } from "./fonts";
let registered = false;
export function registerPresetFonts(): void {
if (registered) return;
const dir = path.join(process.cwd(), "public", "fonts");
for (const font of PRESET_FONTS) {
GlobalFonts.registerFromPath(path.join(dir, font.regular), font.id);
GlobalFonts.registerFromPath(path.join(dir, font.bold), font.id);
}
registered = true;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ serverExternalPackages: ["@napi-rs/canvas"],
}; };
export default nextConfig; export default nextConfig;
+805
View File
@@ -8,12 +8,15 @@
"name": "kk_card_gen", "name": "kk_card_gen",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.9",
"archiver": "^8.0.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/archiver": "^8.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -1070,6 +1073,255 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
@@ -1615,6 +1867,17 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/archiver": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz",
"integrity": "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/readdir-glob": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1666,6 +1929,16 @@
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.70.0", "version": "8.70.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
@@ -2285,6 +2558,18 @@
"win32" "win32"
] ]
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -2341,6 +2626,26 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/archiver": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz",
"integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==",
"license": "MIT",
"dependencies": {
"async": "^3.2.4",
"buffer-crc32": "^1.0.0",
"is-stream": "^4.0.0",
"lazystream": "^1.0.0",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0",
"readdir-glob": "^3.0.0",
"tar-stream": "^3.0.0",
"zip-stream": "^7.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2525,6 +2830,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/async-function": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -2571,6 +2882,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz",
"integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2578,6 +2903,106 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bare-events": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
"integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.28.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-path": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
"license": "Apache-2.0"
},
"node_modules/bare-stream": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.8.1",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.22", "version": "2.11.22",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
@@ -2648,6 +3073,39 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2771,6 +3229,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/compress-commons": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz",
"integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"crc32-stream": "^7.0.1",
"is-stream": "^4.0.0",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2785,6 +3259,37 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc32-stream": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz",
"integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3624,6 +4129,33 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3631,6 +4163,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -4073,6 +4611,26 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4110,6 +4668,12 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4459,6 +5023,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-string": { "node_modules/is-string": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
@@ -4721,6 +5297,54 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
},
"engines": {
"node": ">= 0.6.3"
}
},
"node_modules/lazystream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5267,6 +5891,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5567,6 +6200,21 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5638,6 +6286,73 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readdir-glob": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz",
"integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/yqnn"
}
},
"node_modules/readdir-glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5781,6 +6496,26 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6073,6 +6808,26 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6280,6 +7035,36 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tar-stream": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
"integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6620,6 +7405,12 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6755,6 +7546,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/zip-stream": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz",
"integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==",
"license": "MIT",
"dependencies": {
"compress-commons": "^7.0.0",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
+5 -2
View File
@@ -3,18 +3,21 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --port 47382",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start --port 47382",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.9",
"archiver": "^8.0.0",
"next": "16.3.5", "next": "16.3.5",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/archiver": "^8.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB