diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..e98e895 --- /dev/null +++ b/app/api/generate/route.ts @@ -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 { + 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(); + + 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); + } +} diff --git a/app/editor.tsx b/app/editor.tsx new file mode 100644 index 0000000..da95c03 --- /dev/null +++ b/app/editor.tsx @@ -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(null); + const imageRef = useRef(null); + const imageInputRef = useRef(null); + const fontInputRef = useRef(null); + + const [imageFile, setImageFile] = useState(null); + const [imageUrl, setImageUrl] = useState(null); + const [imageReady, setImageReady] = useState(0); + const [customFontFile, setCustomFontFile] = useState(null); + const [customFontName, setCustomFontName] = useState(null); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(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) => { + 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) => { + 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) => { + 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) => { + dragRef.current.active = false; + setCursor("grab"); + event.currentTarget.releasePointerCapture(event.pointerId); + }; + + const onDrop = (event: DragEvent) => { + 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 ( +
+
+
event.preventDefault()} + onDrop={onDrop} + > + {!imageUrl ? ( + + ) : ( +
+ +
+ )} +
+ + +
+
+ ); +} + +function FieldGroup({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function FileButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function TextField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function NumberField({ + label, + value, + min, + max, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + onChange: (value: number) => void; +}) { + return ( + + ); +} + +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 ( + + ); +} + +function Toggle({ + pressed, + onClick, + label, +}: { + pressed: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..507f12d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,111 @@ @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 { - --background: #ffffff; - --foreground: #171717; + --background: #09090b; + --foreground: #f4f4f5; + --panel: #18181b; + --border: #27272a; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-panel: var(--panel); + --color-border: var(--border); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +* { + box-sizing: border-box; } body { background: var(--background); 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; } diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..e095145 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "KK Card Gen", + description: "Stamp sequential user IDs onto a base card image and download a zip of PNGs", }; export default function RootLayout({ children }: LayoutProps<"/">) { @@ -23,7 +23,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) { lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/app/page.tsx b/app/page.tsx index c887311..faaa0a3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,5 @@ -import Image from "next/image"; +import { Editor } from "./editor"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + return ; } diff --git a/lib/create-zip.ts b/lib/create-zip.ts new file mode 100644 index 0000000..5c5c46b --- /dev/null +++ b/lib/create-zip.ts @@ -0,0 +1,5 @@ +import { ZipArchive } from "archiver"; + +export function createZipArchive(level = 6): ZipArchive { + return new ZipArchive({ zlib: { level } }); +} diff --git a/lib/draw-id.ts b/lib/draw-id.ts new file mode 100644 index 0000000..c998f12 --- /dev/null +++ b/lib/draw-id.ts @@ -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 + ); +} diff --git a/lib/fonts.ts b/lib/fonts.ts new file mode 100644 index 0000000..ae72e95 --- /dev/null +++ b/lib/fonts.ts @@ -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); +} diff --git a/lib/id-config.ts b/lib/id-config.ts new file mode 100644 index 0000000..bed8ba5 --- /dev/null +++ b/lib/id-config.ts @@ -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, +): 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; + 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})`; +} diff --git a/lib/register-fonts.ts b/lib/register-fonts.ts new file mode 100644 index 0000000..1baf637 --- /dev/null +++ b/lib/register-fonts.ts @@ -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; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..80435c2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + serverExternalPackages: ["@napi-rs/canvas"], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 8f6083e..aaf8248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,15 @@ "name": "kk_card_gen", "version": "0.1.0", "dependencies": { + "@napi-rs/canvas": "^1.0.9", + "archiver": "^8.0.0", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/archiver": "^8.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1070,6 +1073,255 @@ "@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": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", @@ -1615,6 +1867,17 @@ "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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1666,6 +1929,16 @@ "@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": { "version": "8.70.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", @@ -2285,6 +2558,18 @@ "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": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2341,6 +2626,26 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2525,6 +2830,12 @@ "dev": true, "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2571,6 +2882,20 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2578,6 +2903,106 @@ "dev": true, "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": { "version": "2.11.22", "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_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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2771,6 +3229,22 @@ "dev": true, "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2785,6 +3259,37 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3624,6 +4129,33 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3631,6 +4163,12 @@ "dev": true, "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -4073,6 +4611,26 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4110,6 +4668,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4459,6 +5023,18 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -4721,6 +5297,54 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5267,6 +5891,15 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5567,6 +6200,21 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5638,6 +6286,73 @@ "dev": true, "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5781,6 +6496,26 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6073,6 +6808,26 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6280,6 +7035,36 @@ "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": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6620,6 +7405,12 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6755,6 +7546,20 @@ "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", diff --git a/package.json b/package.json index 6b1064c..fb0304e 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,21 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --port 47382", "build": "next build", - "start": "next start", + "start": "next start --port 47382", "lint": "eslint" }, "dependencies": { + "@napi-rs/canvas": "^1.0.9", + "archiver": "^8.0.0", "next": "16.3.5", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/archiver": "^8.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/public/fonts/Inter-Bold.ttf b/public/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..bd74151 Binary files /dev/null and b/public/fonts/Inter-Bold.ttf differ diff --git a/public/fonts/Inter-Regular.ttf b/public/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..3e4cc80 Binary files /dev/null and b/public/fonts/Inter-Regular.ttf differ diff --git a/public/fonts/Montserrat-Bold.ttf b/public/fonts/Montserrat-Bold.ttf new file mode 100644 index 0000000..dffac27 Binary files /dev/null and b/public/fonts/Montserrat-Bold.ttf differ diff --git a/public/fonts/Montserrat-Regular.ttf b/public/fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..b48180c Binary files /dev/null and b/public/fonts/Montserrat-Regular.ttf differ diff --git a/public/fonts/Oswald-Bold.ttf b/public/fonts/Oswald-Bold.ttf new file mode 100644 index 0000000..ace4040 Binary files /dev/null and b/public/fonts/Oswald-Bold.ttf differ diff --git a/public/fonts/Oswald-Regular.ttf b/public/fonts/Oswald-Regular.ttf new file mode 100644 index 0000000..87095cc Binary files /dev/null and b/public/fonts/Oswald-Regular.ttf differ diff --git a/public/fonts/PlayfairDisplay-Bold.ttf b/public/fonts/PlayfairDisplay-Bold.ttf new file mode 100644 index 0000000..da4399c Binary files /dev/null and b/public/fonts/PlayfairDisplay-Bold.ttf differ diff --git a/public/fonts/PlayfairDisplay-Regular.ttf b/public/fonts/PlayfairDisplay-Regular.ttf new file mode 100644 index 0000000..2fc72b0 Binary files /dev/null and b/public/fonts/PlayfairDisplay-Regular.ttf differ diff --git a/public/fonts/Roboto-Bold.ttf b/public/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..925694f Binary files /dev/null and b/public/fonts/Roboto-Bold.ttf differ diff --git a/public/fonts/Roboto-Regular.ttf b/public/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..5c80f57 Binary files /dev/null and b/public/fonts/Roboto-Regular.ttf differ diff --git a/public/test-card.png b/public/test-card.png new file mode 100644 index 0000000..be7ccaf Binary files /dev/null and b/public/test-card.png differ