From c5e952119a18b964d8f1598aa5544f3d6a284c45 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 13 Apr 2026 11:36:05 +0800 Subject: [PATCH] feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n --- .../tools/barcode-read-settings.tsx | 407 +++++++++++++++--- packages/shared/src/constants.ts | 2 +- packages/shared/src/i18n/en.ts | 2 +- 3 files changed, 344 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/tools/barcode-read-settings.tsx b/apps/web/src/components/tools/barcode-read-settings.tsx index c4ef2efa..9502c24d 100644 --- a/apps/web/src/components/tools/barcode-read-settings.tsx +++ b/apps/web/src/components/tools/barcode-read-settings.tsx @@ -1,99 +1,376 @@ -import { Check, Copy, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { Check, Copy, Download, Search } from "lucide-react"; +import { useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; import { formatHeaders } from "@/lib/api"; import { copyToClipboard } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; + +interface BarcodeResult { + type: string; + text: string; + position: { + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + }; +} + +interface FileResult { + filename: string; + barcodes: BarcodeResult[]; +} + +/** Human-readable barcode type labels. */ +const FORMAT_LABELS: Record = { + QRCode: "QR Code", + Code128: "Code 128", + Code39: "Code 39", + Code93: "Code 93", + Codabar: "Codabar", + DataMatrix: "Data Matrix", + EAN8: "EAN-8", + EAN13: "EAN-13", + ITF: "ITF", + PDF417: "PDF417", + UPCA: "UPC-A", + UPCE: "UPC-E", + Aztec: "Aztec", + MaxiCode: "MaxiCode", + MicroQRCode: "Micro QR", + DataBar: "DataBar", + DataBarExpanded: "DataBar Exp", +}; + +/** Badge colors by barcode family. */ +function getBadgeColor(type: string): string { + if (type.includes("QR") || type === "Aztec" || type === "DataMatrix" || type === "MaxiCode") + return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; + if (type.includes("EAN") || type.includes("UPC") || type.includes("DataBar")) + return "bg-green-500/15 text-green-600 dark:text-green-400"; + if (type === "PDF417") return "bg-purple-500/15 text-purple-600 dark:text-purple-400"; + return "bg-amber-500/15 text-amber-600 dark:text-amber-400"; +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Send one file to the barcode-read API. */ +function scanOneFile( + file: File, + tryHarder: boolean, + onUploadProgress: (pct: number) => void, +): Promise<{ filename: string; barcodes: BarcodeResult[]; annotatedUrl: string | null }> { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("settings", JSON.stringify({ tryHarder })); + + const xhr = new XMLHttpRequest(); + xhr.timeout = 60_000; + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error("Invalid response")); + } + } else { + try { + const body = JSON.parse(xhr.responseText); + reject(new Error(body.error || `Failed: ${xhr.status}`)); + } catch { + reject(new Error(`Scanning failed: ${xhr.status}`)); + } + } + }; + xhr.onerror = () => reject(new Error("Network error")); + xhr.ontimeout = () => reject(new Error("Request timed out")); + + xhr.open("POST", "/api/v1/tools/barcode-read"); + for (const [key, value] of formatHeaders()) { + xhr.setRequestHeader(key, value); + } + xhr.send(formData); + }); +} + export function BarcodeReadSettings() { const { files, processing, error, setProcessing, setError } = useFileStore(); - const [result, setResult] = useState<{ found: boolean; text: string | null } | null>(null); - const [copied, setCopied] = useState(false); + + const [tryHarder, setTryHarder] = useState(false); + const [results, setResults] = useState([]); + const [copiedIndex, setCopiedIndex] = useState(null); + const [copiedAll, setCopiedAll] = useState(false); + const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle"); + const [progressPercent, setProgressPercent] = useState(0); + const [progressStage, setProgressStage] = useState(); + const [elapsed, setElapsed] = useState(0); + const elapsedRef = useRef | null>(null); const handleProcess = async () => { if (files.length === 0) return; - setProcessing(true); setError(null); - setResult(null); + setResults([]); + setProcessing(true); + setProgressPhase("uploading"); + setProgressPercent(0); + setProgressStage(undefined); + setElapsed(0); - try { - const formData = new FormData(); - formData.append("file", files[0]); + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTime) / 1000)); + }, 1000); - const res = await fetch("/api/v1/tools/barcode-read", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); + const total = files.length; + const allResults: FileResult[] = []; + const errors: string[] = []; + const { updateEntry } = useFileStore.getState(); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `Failed: ${res.status}`); + for (let i = 0; i < total; i++) { + const file = files[i]; + const prefix = total > 1 ? `[${i + 1}/${total}] ` : ""; + const fileBase = (i / total) * 100; + const fileShare = 100 / total; + + try { + setProgressStage(`${prefix}Scanning ${file.name}...`); + + const result = await scanOneFile(file, tryHarder, (pct) => { + setProgressPhase("uploading"); + setProgressPercent(fileBase + (pct / 100) * fileShare * 0.5); + }); + + setProgressPhase("processing"); + setProgressPercent(fileBase + fileShare); + + allResults.push({ + filename: result.filename, + barcodes: result.barcodes, + }); + + // Set annotated image as processedUrl for before/after view + if (result.annotatedUrl) { + updateEntry(i, { + processedUrl: result.annotatedUrl, + processedPreviewUrl: result.annotatedUrl, + processedFilename: `annotated-${file.name.replace(/\.[^.]+$/, "")}.png`, + status: "completed", + processedSize: null, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${file.name}: ${msg}`); + allResults.push({ filename: file.name, barcodes: [] }); } + } - const data = await res.json(); - setResult(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Reading failed"); - } finally { - setProcessing(false); + if (elapsedRef.current) clearInterval(elapsedRef.current); + + if (errors.length === total) { + setError(errors.join("; ")); + } else if (errors.length > 0) { + setError(`${errors.length} of ${total} files failed`); + } + + setResults(allResults); + setProcessing(false); + setProgressPhase("idle"); + }; + + // Total barcode count across all files + const totalBarcodes = results.reduce((sum, r) => sum + r.barcodes.length, 0); + + const handleCopyOne = async (text: string, globalIdx: number) => { + const ok = await copyToClipboard(text); + if (ok) { + setCopiedIndex(globalIdx); + setTimeout(() => setCopiedIndex(null), 1500); } }; - const copyText = async () => { - if (!result?.text) return; - const ok = await copyToClipboard(result.text); + const handleCopyAll = async () => { + const allText = results + .flatMap((r) => r.barcodes.map((b) => `${FORMAT_LABELS[b.type] ?? b.type}: ${b.text}`)) + .join("\n"); + const ok = await copyToClipboard(allText); if (ok) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); } }; + const handleExportCsv = () => { + const header = "File,Type,Value\n"; + const rows = results + .flatMap((r) => + r.barcodes.map( + (b) => + `"${r.filename.replace(/"/g, '""')}","${FORMAT_LABELS[b.type] ?? b.type}","${b.text.replace(/"/g, '""')}"`, + ), + ) + .join("\n"); + const csv = header + rows; + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "barcode-results.csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + const hasFile = files.length > 0; + let globalIndex = 0; return ( -
+

- Upload an image containing a QR code to decode its content. + Scan images for QR codes, barcodes (Code 128, EAN, UPC, etc.), and 2D codes (DataMatrix, + PDF417, Aztec).

- + {/* Thorough scan toggle */} + Options + + {/* Error */} {error &&

{error}

} - {result && ( -
- {result.found ? ( - <> -

Decoded Text:

-

{result.text}

- - - ) : ( -

No QR code found in the image.

- )} + {/* Process button / progress */} + {processing ? ( + + ) : ( + + )} + + {/* Results */} + {results.length > 0 && ( +
+ {/* Summary badge */} +
+ + {totalBarcodes === 0 + ? "No barcodes found" + : `Found ${totalBarcodes} barcode${totalBarcodes !== 1 ? "s" : ""}`} + + {totalBarcodes > 0 && ( +
+ + +
+ )} +
+ + {/* Per-file results */} + {results.map((fileResult) => ( +
+ {/* Show filename header only when multiple files */} + {results.length > 1 && ( +

+ {fileResult.filename} +

+ )} + + {fileResult.barcodes.length === 0 ? ( +

No barcodes found

+ ) : ( + fileResult.barcodes.map((barcode) => { + const idx = globalIndex++; + const label = FORMAT_LABELS[barcode.type] ?? barcode.type; + const badgeColor = getBadgeColor(barcode.type); + return ( +
+ {/* Barcode type badge */} +
+ + {label} + +
+ {/* Decoded value */} +

+ {barcode.text} +

+ {/* Copy button */} + +
+ ); + }) + )} +
+ ))}
)}
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 88223972..2499c751 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -238,7 +238,7 @@ export const TOOLS: Tool[] = [ { id: "barcode-read", name: "Barcode Reader", - description: "Read QR codes and barcodes from images", + description: "Scan images for QR codes, barcodes, and 2D codes", category: "utilities", icon: "ScanLine", route: "/barcode-read", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index b8061c8c..fe241567 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -92,7 +92,7 @@ export const en = { }, "barcode-read": { name: "Barcode Reader", - description: "Read QR codes and barcodes from images", + description: "Scan images for QR codes, barcodes, and 2D codes", }, collage: { name: "Collage / Grid", description: "Combine images into a grid layout" }, stitch: { name: "Stitch", description: "Join images side by side or top to bottom" },