From 13c24e9db3e63163282fb0b13073b744c00481e7 Mon Sep 17 00:00:00 2001
From: Sewmina
Date: Sun, 13 Sep 2026 15:30:23 +0530
Subject: [PATCH] progress indicator added
---
app/api/download/[jobId]/route.ts | 34 +++++++
app/api/generate/route.ts | 162 ++++++++++++++++++------------
app/editor.tsx | 143 +++++++++++++++++++++++++-
lib/jobs.ts | 33 ++++++
lib/progress.ts | 16 +++
5 files changed, 319 insertions(+), 69 deletions(-)
create mode 100644 app/api/download/[jobId]/route.ts
create mode 100644 lib/jobs.ts
create mode 100644 lib/progress.ts
diff --git a/app/api/download/[jobId]/route.ts b/app/api/download/[jobId]/route.ts
new file mode 100644
index 0000000..1a9ecf1
--- /dev/null
+++ b/app/api/download/[jobId]/route.ts
@@ -0,0 +1,34 @@
+import { createReadStream } from "node:fs";
+import { rm } from "node:fs/promises";
+import { Readable } from "node:stream";
+import { takeJob } from "@/lib/jobs";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ _req: Request,
+ context: { params: Promise<{ jobId: string }> },
+): Promise {
+ const { jobId } = await context.params;
+ const job = takeJob(jobId);
+ if (!job) {
+ return Response.json({ error: "Download expired or not found" }, { status: 404 });
+ }
+
+ const file = createReadStream(job.zipPath);
+ file.on("close", () => {
+ void rm(job.dir, { recursive: true, force: true });
+ });
+ file.on("error", () => {
+ void rm(job.dir, { recursive: true, force: true });
+ });
+
+ return new Response(Readable.toWeb(file) as ReadableStream, {
+ headers: {
+ "Content-Type": "application/zip",
+ "Content-Disposition": 'attachment; filename="ids.zip"',
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts
index e98e895..144a244 100644
--- a/app/api/generate/route.ts
+++ b/app/api/generate/route.ts
@@ -1,8 +1,9 @@
import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { createWriteStream } from "node:fs";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
-import { PassThrough, Readable } from "node:stream";
+import { finished } from "node:stream/promises";
import { createZipArchive } from "@/lib/create-zip";
import { drawIdText } from "@/lib/draw-id";
import { isPresetFont } from "@/lib/fonts";
@@ -15,6 +16,8 @@ import {
sanitizeFilename,
toIdDrawStyle,
} from "@/lib/id-config";
+import { saveJob } from "@/lib/jobs";
+import type { GenerateProgressEvent } from "@/lib/progress";
import { registerPresetFonts } from "@/lib/register-fonts";
export const runtime = "nodejs";
@@ -26,8 +29,6 @@ function jsonError(message: string, status = 400): Response {
}
export async function POST(req: Request): Promise {
- let tmpDir: string | null = null;
-
try {
const form = await req.formData();
const imageFile = form.get("image");
@@ -67,79 +68,110 @@ export async function POST(req: Request): Promise {
return jsonError("Unknown font family");
}
- tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
- registerPresetFonts();
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ async start(controller) {
+ let tmpDir: string | null = null;
+ const send = (event: GenerateProgressEvent) => {
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
+ };
- const style = toIdDrawStyle(config);
+ try {
+ tmpDir = await mkdtemp(path.join(tmpdir(), "kk-card-"));
+ const pngDir = path.join(tmpDir, "png");
+ await mkdir(pngDir);
- 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);
- }
+ registerPresetFonts();
+ const style = toIdDrawStyle(config);
- 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();
-
- 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`;
+ 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);
}
- 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 });
+ const image = await loadImage(
+ Buffer.from(await imageFile.arrayBuffer()),
+ );
+ const canvas = createCanvas(image.width, image.height);
+ const ctx = canvas.getContext("2d");
+ const usedNames = new Set();
+ const files: { name: string; path: string }[] = [];
+
+ 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");
+ const filePath = path.join(pngDir, name);
+ await writeFile(filePath, png);
+ files.push({ name, path: filePath });
+ send({ phase: "generate", current: i + 1, total: config.count });
+ }
+
+ send({ phase: "zip", current: 0, total: files.length });
+
+ const zipPath = path.join(tmpDir, "ids.zip");
+ const output = createWriteStream(zipPath);
+ const archive = createZipArchive();
+ archive.on("error", (error: Error) => {
+ output.destroy(error);
+ });
+ archive.on("progress", (progress) => {
+ send({
+ phase: "zip",
+ current: progress.entries.processed,
+ total: Math.max(progress.entries.total, files.length),
+ });
+ });
+ archive.pipe(output);
+
+ for (const file of files) {
+ archive.file(file.path, { name: file.name });
+ }
+
+ await archive.finalize();
+ await finished(output);
+ send({ phase: "zip", current: files.length, total: files.length });
+
+ const id = saveJob(tmpDir, zipPath);
+ tmpDir = null;
+ send({ phase: "done", id });
+ controller.close();
+ } catch (error) {
+ if (tmpDir) {
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
+ }
+ const message =
+ error instanceof Error ? error.message : "Failed to generate images";
+ send({ phase: "error", message });
+ controller.close();
}
- 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, {
+ return new Response(stream, {
headers: {
- "Content-Type": "application/zip",
- "Content-Disposition": 'attachment; filename="ids.zip"',
+ "Content-Type": "application/x-ndjson",
"Cache-Control": "no-store",
+ "X-Accel-Buffering": "no",
},
});
} 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);
diff --git a/app/editor.tsx b/app/editor.tsx
index da95c03..21cfdb6 100644
--- a/app/editor.tsx
+++ b/app/editor.tsx
@@ -29,10 +29,60 @@ import {
type GenerateConfig,
type TextAlign,
} from "@/lib/id-config";
+import {
+ isProgressEvent,
+ type GenerateProgressEvent,
+} from "@/lib/progress";
const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
const FONT_ACCEPT = ".ttf,.otf,font/ttf,font/otf";
+async function readGenerateProgress(
+ response: Response,
+ onEvent: (event: Extract) => void,
+): Promise {
+ if (!response.body) {
+ throw new Error("No progress stream from server");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let jobId: string | null = null;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ if (!isProgressEvent(parsed)) continue;
+ if (parsed.phase === "error") {
+ throw new Error(parsed.message);
+ }
+ if (parsed.phase === "done") {
+ jobId = parsed.id;
+ continue;
+ }
+ onEvent(parsed);
+ }
+ }
+
+ if (!jobId) {
+ throw new Error("Generation finished without a download");
+ }
+ return jobId;
+}
+
function canvasPoint(
canvas: HTMLCanvasElement,
clientX: number,
@@ -60,6 +110,11 @@ export function Editor() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [status, setStatus] = useState(null);
+ const [progress, setProgress] = useState<{
+ phase: "generate" | "zip" | "download";
+ current: number;
+ total: number;
+ } | null>(null);
const [cursor, setCursor] = useState<"grab" | "grabbing" | "default">(
"default",
);
@@ -298,7 +353,8 @@ export function Editor() {
setBusy(true);
setError(null);
- setStatus(`Generating ${config.count} images on the server…`);
+ setStatus(null);
+ setProgress({ phase: "generate", current: 0, total: config.count });
try {
const form = new FormData();
@@ -324,7 +380,23 @@ export function Editor() {
throw new Error(message);
}
- const blob = await response.blob();
+ const jobId = await readGenerateProgress(response, (event) => {
+ if (event.phase === "generate" || event.phase === "zip") {
+ setProgress({
+ phase: event.phase,
+ current: event.current,
+ total: event.total,
+ });
+ }
+ });
+
+ setProgress({ phase: "download", current: 1, total: 1 });
+ const zipResponse = await fetch(`/api/download/${jobId}`);
+ if (!zipResponse.ok) {
+ throw new Error("Could not download the zip file");
+ }
+
+ const blob = await zipResponse.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@@ -333,8 +405,10 @@ export function Editor() {
link.click();
link.remove();
URL.revokeObjectURL(url);
+ setProgress(null);
setStatus(`Downloaded ids.zip with ${config.count} PNGs`);
} catch (err) {
+ setProgress(null);
setStatus(null);
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
@@ -686,7 +760,8 @@ export function Editor() {
{error}
) : null}
- {status && !error ? (
+ {progress ? : null}
+ {status && !error && !progress ? (
{status}
@@ -698,7 +773,9 @@ export function Editor() {
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`}
+ {busy
+ ? progressLabel(progress, config.count)
+ : `Generate ${config.count} images`}
@@ -706,6 +783,64 @@ export function Editor() {
);
}
+function progressLabel(
+ progress: {
+ phase: "generate" | "zip" | "download";
+ current: number;
+ total: number;
+ } | null,
+ fallbackTotal: number,
+): string {
+ if (!progress) return `Generating 0 / ${fallbackTotal}`;
+ if (progress.phase === "zip") {
+ return `Zipping ${progress.current} / ${progress.total}`;
+ }
+ if (progress.phase === "download") {
+ return "Downloading zip…";
+ }
+ return `Generating ${progress.current} / ${progress.total}`;
+}
+
+function ProgressPanel({
+ progress,
+}: {
+ progress: {
+ phase: "generate" | "zip" | "download";
+ current: number;
+ total: number;
+ };
+}) {
+ const percent =
+ progress.total > 0
+ ? Math.min(100, Math.round((progress.current / progress.total) * 100))
+ : 0;
+ const title =
+ progress.phase === "zip"
+ ? "Zipping"
+ : progress.phase === "download"
+ ? "Downloading"
+ : "Generating";
+
+ return (
+
+
+ {title}
+
+ {progress.phase === "download"
+ ? "ids.zip"
+ : `${progress.current} / ${progress.total}`}
+
+
+
+
+ );
+}
+
function FieldGroup({
title,
children,
diff --git a/lib/jobs.ts b/lib/jobs.ts
new file mode 100644
index 0000000..e12484d
--- /dev/null
+++ b/lib/jobs.ts
@@ -0,0 +1,33 @@
+import { rm } from "node:fs/promises";
+
+type Job = {
+ dir: string;
+ zipPath: string;
+ createdAt: number;
+};
+
+const JOB_TTL_MS = 15 * 60 * 1000;
+const jobs = new Map();
+
+function sweepExpiredJobs(): void {
+ const cutoff = Date.now() - JOB_TTL_MS;
+ for (const [id, job] of jobs) {
+ if (job.createdAt > cutoff) continue;
+ jobs.delete(id);
+ void rm(job.dir, { recursive: true, force: true });
+ }
+}
+
+export function saveJob(dir: string, zipPath: string): string {
+ sweepExpiredJobs();
+ const id = crypto.randomUUID();
+ jobs.set(id, { dir, zipPath, createdAt: Date.now() });
+ return id;
+}
+
+export function takeJob(id: string): Job | null {
+ const job = jobs.get(id);
+ if (!job) return null;
+ jobs.delete(id);
+ return job;
+}
diff --git a/lib/progress.ts b/lib/progress.ts
new file mode 100644
index 0000000..eccad1f
--- /dev/null
+++ b/lib/progress.ts
@@ -0,0 +1,16 @@
+export type GenerateProgressEvent =
+ | { phase: "generate"; current: number; total: number }
+ | { phase: "zip"; current: number; total: number }
+ | { phase: "done"; id: string }
+ | { phase: "error"; message: string };
+
+export function isProgressEvent(value: unknown): value is GenerateProgressEvent {
+ if (!value || typeof value !== "object") return false;
+ const phase = (value as { phase?: unknown }).phase;
+ return (
+ phase === "generate" ||
+ phase === "zip" ||
+ phase === "done" ||
+ phase === "error"
+ );
+}