"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 ( ); }