init
This commit is contained in:
@@ -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<Response> {
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+93
-8
@@ -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;
|
||||
}
|
||||
|
||||
+5
-3
@@ -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`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-66
@@ -1,69 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { Editor } from "./editor";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <Editor />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user