Files
kk_card_gen/app/editor.tsx
T
2026-09-13 14:58:27 +05:30

861 lines
26 KiB
TypeScript

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