This commit is contained in:
2026-09-13 14:58:27 +05:30
parent 4f80c16043
commit 9c7a16b57c
24 changed files with 2467 additions and 80 deletions
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 />;
}