"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"; import { isProgressEvent, type GenerateProgressEvent, } from "@/lib/progress"; const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp"; const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf"; async function readGenerateProgress( response: Response, onEvent: (event: Extract) => void, ): Promise { if (!response.body) { throw new Error("No progress stream from server"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let jobId: string | null = null; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; let parsed: unknown; try { parsed = JSON.parse(line); } catch { continue; } if (!isProgressEvent(parsed)) continue; if (parsed.phase === "error") { throw new Error(parsed.message); } if (parsed.phase === "done") { jobId = parsed.id; continue; } onEvent(parsed); } } if (!jobId) { throw new Error("Generation finished without a download"); } return jobId; } 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 [progress, setProgress] = useState<{ phase: "generate" | "zip" | "download"; current: number; total: number; } | 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) => { 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(null); setProgress({ phase: "generate", current: 0, total: config.count }); 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 jobId = await readGenerateProgress(response, (event) => { if (event.phase === "generate" || event.phase === "zip") { setProgress({ phase: event.phase, current: event.current, total: event.total, }); } }); setProgress({ phase: "download", current: 1, total: 1 }); const zipResponse = await fetch(`/api/download/${jobId}`); if (!zipResponse.ok) { throw new Error("Could not download the zip file"); } const blob = await zipResponse.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); setProgress(null); setStatus(`Downloaded ids.zip with ${config.count} PNGs`); } catch (err) { setProgress(null); setStatus(null); setError(err instanceof Error ? err.message : "Generation failed"); } finally { setBusy(false); } }; return (
event.preventDefault()} onDrop={onDrop} > {!imageUrl ? ( ) : (
)}
); } function progressLabel( progress: { phase: "generate" | "zip" | "download"; current: number; total: number; } | null, fallbackTotal: number, ): string { if (!progress) return `Generating 0 / ${fallbackTotal}`; if (progress.phase === "zip") { return `Zipping ${progress.current} / ${progress.total}`; } if (progress.phase === "download") { return "Downloading zip…"; } return `Generating ${progress.current} / ${progress.total}`; } function ProgressPanel({ progress, }: { progress: { phase: "generate" | "zip" | "download"; current: number; total: number; }; }) { const percent = progress.total > 0 ? Math.min(100, Math.round((progress.current / progress.total) * 100)) : 0; const title = progress.phase === "zip" ? "Zipping" : progress.phase === "download" ? "Downloading" : "Generating"; return (
{title} {progress.phase === "download" ? "ids.zip" : `${progress.current} / ${progress.total}`}
); } 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 ( ); }