init
This commit is contained in:
+860
@@ -0,0 +1,860 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
drawIdSelection,
|
||||
drawIdText,
|
||||
hitTestTextBox,
|
||||
measureIdTextBox,
|
||||
} from "@/lib/draw-id";
|
||||
import { PRESET_FONTS } from "@/lib/fonts";
|
||||
import {
|
||||
CUSTOM_FONT_FAMILY,
|
||||
DEFAULT_CONFIG,
|
||||
formatUserId,
|
||||
MAX_COUNT,
|
||||
MAX_FONT_BYTES,
|
||||
MAX_IMAGE_BYTES,
|
||||
toIdDrawStyle,
|
||||
type GenerateConfig,
|
||||
type TextAlign,
|
||||
} from "@/lib/id-config";
|
||||
|
||||
const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||
const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf";
|
||||
|
||||
function canvasPoint(
|
||||
canvas: HTMLCanvasElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * canvas.width,
|
||||
y: ((clientY - rect.top) / rect.height) * canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function Editor() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const fontInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [imageReady, setImageReady] = useState(0);
|
||||
const [customFontFile, setCustomFontFile] = useState<File | null>(null);
|
||||
const [customFontName, setCustomFontName] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<GenerateConfig>(DEFAULT_CONFIG);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
|
||||
"default",
|
||||
);
|
||||
|
||||
const dragRef = useRef<{
|
||||
active: boolean;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}>({ active: false, offsetX: 0, offsetY: 0 });
|
||||
|
||||
const sampleId = useMemo(
|
||||
() =>
|
||||
formatUserId(config.prefix, config.start, config.pad, config.suffix),
|
||||
[config.prefix, config.start, config.pad, config.suffix],
|
||||
);
|
||||
|
||||
const lastId = useMemo(
|
||||
() =>
|
||||
formatUserId(
|
||||
config.prefix,
|
||||
config.start + config.count - 1,
|
||||
config.pad,
|
||||
config.suffix,
|
||||
),
|
||||
[config.prefix, config.start, config.pad, config.suffix, config.count],
|
||||
);
|
||||
|
||||
const drawStyle = useMemo(() => toIdDrawStyle(config), [config]);
|
||||
|
||||
const patch = useCallback((partial: Partial<GenerateConfig>) => {
|
||||
setConfig((current) => ({ ...current, ...partial }));
|
||||
}, []);
|
||||
|
||||
const revokeImageUrl = useCallback((url: string | null) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const loadImageFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("Please choose a PNG, JPG, or WebP image");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
setError("Base image must be 10MB or smaller");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setImageFile(file);
|
||||
setImageUrl((previous) => {
|
||||
revokeImageUrl(previous);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
},
|
||||
[revokeImageUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => revokeImageUrl(imageUrl);
|
||||
}, [imageUrl, revokeImageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
imageRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
imageRef.current = image;
|
||||
setImageReady((value) => value + 1);
|
||||
};
|
||||
image.onerror = () => {
|
||||
setError("Could not read that image");
|
||||
imageRef.current = null;
|
||||
};
|
||||
image.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const redraw = useCallback(async () => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
await document.fonts.ready;
|
||||
if (config.useCustomFont) {
|
||||
await document.fonts.load(drawStyle.fontSize + "px " + CUSTOM_FONT_FAMILY);
|
||||
} else {
|
||||
await document.fonts.load(
|
||||
`${config.bold ? "700" : "400"} ${config.fontSize}px "${config.fontFamily}"`,
|
||||
);
|
||||
}
|
||||
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
drawIdText(ctx, sampleId, image.naturalWidth, image.naturalHeight, drawStyle);
|
||||
const box = measureIdTextBox(
|
||||
ctx,
|
||||
sampleId,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
drawStyle,
|
||||
);
|
||||
drawIdSelection(ctx, box, image.naturalWidth);
|
||||
}, [config.bold, config.fontFamily, config.fontSize, config.useCustomFont, drawStyle, sampleId]);
|
||||
|
||||
useEffect(() => {
|
||||
void redraw();
|
||||
}, [redraw, imageReady]);
|
||||
|
||||
const loadCustomFont = useCallback(
|
||||
async (file: File) => {
|
||||
const name = file.name.toLowerCase();
|
||||
if (!name.endsWith(".ttf") && !name.endsWith(".otf")) {
|
||||
setError("Please choose a TTF or OTF font");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FONT_BYTES) {
|
||||
setError("Custom font must be 5MB or smaller");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const face = new FontFace(CUSTOM_FONT_FAMILY, buffer);
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
setCustomFontFile(file);
|
||||
setCustomFontName(file.name);
|
||||
patch({ useCustomFont: true, fontFamily: CUSTOM_FONT_FAMILY });
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Could not load that font file");
|
||||
}
|
||||
},
|
||||
[patch],
|
||||
);
|
||||
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const point = canvasPoint(canvas, event.clientX, event.clientY);
|
||||
const box = measureIdTextBox(
|
||||
ctx,
|
||||
sampleId,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
drawStyle,
|
||||
);
|
||||
|
||||
let anchorX = (config.xPercent / 100) * image.naturalWidth;
|
||||
let anchorY = (config.yPercent / 100) * image.naturalHeight;
|
||||
|
||||
if (!hitTestTextBox(box, point.x, point.y, image.naturalWidth)) {
|
||||
anchorX = point.x;
|
||||
anchorY = point.y;
|
||||
patch({
|
||||
xPercent: (point.x / image.naturalWidth) * 100,
|
||||
yPercent: (point.y / image.naturalHeight) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
active: true,
|
||||
offsetX: point.x - anchorX,
|
||||
offsetY: point.y - anchorY,
|
||||
};
|
||||
setCursor("grabbing");
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
const image = imageRef.current;
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const point = canvasPoint(canvas, event.clientX, event.clientY);
|
||||
|
||||
if (!dragRef.current.active) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const box = measureIdTextBox(
|
||||
ctx,
|
||||
sampleId,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
drawStyle,
|
||||
);
|
||||
setCursor(
|
||||
hitTestTextBox(box, point.x, point.y, image.naturalWidth)
|
||||
? "grab"
|
||||
: "default",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const x = point.x - dragRef.current.offsetX;
|
||||
const y = point.y - dragRef.current.offsetY;
|
||||
patch({
|
||||
xPercent: Math.min(100, Math.max(0, (x / image.naturalWidth) * 100)),
|
||||
yPercent: Math.min(100, Math.max(0, (y / image.naturalHeight) * 100)),
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
dragRef.current.active = false;
|
||||
setCursor("grab");
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) loadImageFile(file);
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (!imageFile) {
|
||||
setError("Choose a base image first");
|
||||
return;
|
||||
}
|
||||
if (config.useCustomFont && !customFontFile) {
|
||||
setError("Upload a custom font or switch back to a preset");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setStatus(`Generating ${config.count} images on the server…`);
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("image", imageFile);
|
||||
if (config.useCustomFont && customFontFile) {
|
||||
form.append("font", customFontFile);
|
||||
}
|
||||
form.append("config", JSON.stringify(config));
|
||||
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Generation failed";
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (payload.error) message = payload.error;
|
||||
} catch {
|
||||
message = `Generation failed (${response.status})`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "ids.zip";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
|
||||
} catch (err) {
|
||||
setStatus(null);
|
||||
setError(err instanceof Error ? err.message : "Generation failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="mx-auto grid w-full max-w-7xl flex-1 grid-cols-1 gap-6 p-6 lg:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<section
|
||||
className="flex min-h-[420px] flex-col rounded-2xl border border-zinc-800 bg-zinc-950"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{!imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"
|
||||
>
|
||||
<span className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1 text-xs text-zinc-300">
|
||||
PNG · JPG · WebP
|
||||
</span>
|
||||
<span className="text-lg font-medium">Select a base photo</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="max-h-[70vh] max-w-full rounded-lg shadow-2xl shadow-black/40"
|
||||
style={{ cursor }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-5 rounded-2xl border border-zinc-800 bg-zinc-950 p-5 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
<FieldGroup title="Base image">
|
||||
<FileButton
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
label={imageFile ? imageFile.name : "Choose image"}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) loadImageFile(file);
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="User ID">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Prefix"
|
||||
value={config.prefix}
|
||||
onChange={(value) => patch({ prefix: value })}
|
||||
/>
|
||||
<TextField
|
||||
label="Suffix"
|
||||
value={config.suffix}
|
||||
onChange={(value) => patch({ suffix: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Start at"
|
||||
value={config.start}
|
||||
min={0}
|
||||
max={1_000_000}
|
||||
onChange={(value) => patch({ start: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pad digits"
|
||||
value={config.pad}
|
||||
min={0}
|
||||
max={6}
|
||||
onChange={(value) => patch({ pad: value })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Count"
|
||||
value={config.count}
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
onChange={(value) => patch({ count: value })}
|
||||
/>
|
||||
</div>
|
||||
<p className="rounded-lg bg-zinc-900 px-3 py-2 font-mono text-xs text-zinc-300">
|
||||
{sampleId}.png
|
||||
<span className="text-zinc-500"> → </span>
|
||||
{lastId}.png
|
||||
</p>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Text style">
|
||||
<label className="block text-xs text-zinc-400">
|
||||
Font
|
||||
<select
|
||||
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
|
||||
value={
|
||||
config.useCustomFont ? CUSTOM_FONT_FAMILY : config.fontFamily
|
||||
}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value === CUSTOM_FONT_FAMILY) {
|
||||
patch({
|
||||
useCustomFont: true,
|
||||
fontFamily: CUSTOM_FONT_FAMILY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
patch({ useCustomFont: false, fontFamily: value });
|
||||
}}
|
||||
>
|
||||
{PRESET_FONTS.map((font) => (
|
||||
<option key={font.id} value={font.id}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
{customFontName ? (
|
||||
<option value={CUSTOM_FONT_FAMILY}>
|
||||
Custom: {customFontName}
|
||||
</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<FileButton
|
||||
onClick={() => fontInputRef.current?.click()}
|
||||
label={
|
||||
customFontName
|
||||
? `Replace font (${customFontName})`
|
||||
: "Upload TTF / OTF"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={fontInputRef}
|
||||
type="file"
|
||||
accept={FONT_ACCEPT}
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void loadCustomFont(file);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RangeField
|
||||
label="Size"
|
||||
value={config.fontSize}
|
||||
min={8}
|
||||
max={220}
|
||||
onChange={(value) => patch({ fontSize: value })}
|
||||
suffix="px"
|
||||
/>
|
||||
|
||||
<label className="block text-xs text-zinc-400">
|
||||
Color
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={config.color.length === 7 ? config.color : "#111111"}
|
||||
onChange={(event) => patch({ color: event.target.value })}
|
||||
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
|
||||
/>
|
||||
<input
|
||||
value={config.color}
|
||||
onChange={(event) => patch({ color: event.target.value })}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Toggle
|
||||
pressed={config.bold}
|
||||
onClick={() => patch({ bold: !config.bold })}
|
||||
label="Bold"
|
||||
/>
|
||||
<Toggle
|
||||
pressed={config.italic}
|
||||
onClick={() => patch({ italic: !config.italic })}
|
||||
label="Italic"
|
||||
/>
|
||||
{(["left", "center", "right"] as TextAlign[]).map((align) => (
|
||||
<Toggle
|
||||
key={align}
|
||||
pressed={config.align === align}
|
||||
onClick={() => patch({ align })}
|
||||
label={align[0].toUpperCase() + align.slice(1)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Drop shadow">
|
||||
<Toggle
|
||||
pressed={config.shadowEnabled}
|
||||
onClick={() => patch({ shadowEnabled: !config.shadowEnabled })}
|
||||
label={config.shadowEnabled ? "On" : "Off"}
|
||||
/>
|
||||
{config.shadowEnabled ? (
|
||||
<>
|
||||
<label className="block text-xs text-zinc-400">
|
||||
Color
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
config.shadowColor.length === 7
|
||||
? config.shadowColor
|
||||
: "#000000"
|
||||
}
|
||||
onChange={(event) =>
|
||||
patch({ shadowColor: event.target.value })
|
||||
}
|
||||
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
|
||||
/>
|
||||
<input
|
||||
value={config.shadowColor}
|
||||
onChange={(event) =>
|
||||
patch({ shadowColor: event.target.value })
|
||||
}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<RangeField
|
||||
label="Opacity"
|
||||
value={Number(config.shadowOpacity.toFixed(2))}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(value) => patch({ shadowOpacity: value })}
|
||||
/>
|
||||
<RangeField
|
||||
label="Blur"
|
||||
value={config.shadowBlur}
|
||||
min={0}
|
||||
max={40}
|
||||
onChange={(value) => patch({ shadowBlur: value })}
|
||||
suffix="px"
|
||||
/>
|
||||
<RangeField
|
||||
label="Offset X"
|
||||
value={config.shadowOffsetX}
|
||||
min={-30}
|
||||
max={30}
|
||||
onChange={(value) => patch({ shadowOffsetX: value })}
|
||||
suffix="px"
|
||||
/>
|
||||
<RangeField
|
||||
label="Offset Y"
|
||||
value={config.shadowOffsetY}
|
||||
min={-30}
|
||||
max={30}
|
||||
onChange={(value) => patch({ shadowOffsetY: value })}
|
||||
suffix="px"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Outline">
|
||||
<Toggle
|
||||
pressed={config.outlineEnabled}
|
||||
onClick={() => patch({ outlineEnabled: !config.outlineEnabled })}
|
||||
label={config.outlineEnabled ? "On" : "Off"}
|
||||
/>
|
||||
{config.outlineEnabled ? (
|
||||
<>
|
||||
<label className="block text-xs text-zinc-400">
|
||||
Color
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
config.outlineColor.length === 7
|
||||
? config.outlineColor
|
||||
: "#ffffff"
|
||||
}
|
||||
onChange={(event) =>
|
||||
patch({ outlineColor: event.target.value })
|
||||
}
|
||||
className="h-9 w-12 cursor-pointer rounded border border-zinc-700 bg-zinc-900"
|
||||
/>
|
||||
<input
|
||||
value={config.outlineColor}
|
||||
onChange={(event) =>
|
||||
patch({ outlineColor: event.target.value })
|
||||
}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 font-mono text-sm text-zinc-100"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<RangeField
|
||||
label="Opacity"
|
||||
value={Number(config.outlineOpacity.toFixed(2))}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(value) => patch({ outlineOpacity: value })}
|
||||
/>
|
||||
<RangeField
|
||||
label="Width"
|
||||
value={config.outlineWidth}
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onChange={(value) => patch({ outlineWidth: value })}
|
||||
suffix="px"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup title="Position">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Drag the ID on the preview, click to place it, or nudge the
|
||||
percentages.
|
||||
</p>
|
||||
<RangeField
|
||||
label="X"
|
||||
value={Number(config.xPercent.toFixed(1))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
onChange={(value) => patch({ xPercent: value })}
|
||||
suffix="%"
|
||||
/>
|
||||
<RangeField
|
||||
label="Y"
|
||||
value={Number(config.yPercent.toFixed(1))}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
onChange={(value) => patch({ yPercent: value })}
|
||||
suffix="%"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-900 bg-red-950/60 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{status && !error ? (
|
||||
<p className="rounded-lg border border-indigo-900 bg-indigo-950/50 px-3 py-2 text-sm text-indigo-100">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generate()}
|
||||
disabled={busy || !imageFile}
|
||||
className="rounded-xl bg-indigo-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:bg-zinc-700 disabled:text-zinc-400"
|
||||
>
|
||||
{busy ? "Generating…" : `Generate ${config.count} images`}
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FileButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full truncate rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-left text-sm text-zinc-200 hover:border-zinc-600"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-zinc-400">
|
||||
{label}
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.target.value)
|
||||
}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-zinc-400">
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
if (!Number.isFinite(next)) return;
|
||||
onChange(Math.min(max, Math.max(min, Math.trunc(next))));
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
suffix,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
suffix?: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-xs text-zinc-400">
|
||||
<span className="flex justify-between">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono text-zinc-300">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
pressed,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs capitalize ${
|
||||
pressed
|
||||
? "border-indigo-400 bg-indigo-500/20 text-indigo-100"
|
||||
: "border-zinc-800 bg-zinc-900 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user