mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -1,99 +1,376 @@
|
|||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Download, Search } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
export function BarcodeReadSettings() {
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
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<FileResult[]>([]);
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
const [copiedAll, setCopiedAll] = useState(false);
|
||||||
|
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
||||||
|
const [progressPercent, setProgressPercent] = useState(0);
|
||||||
|
const [progressStage, setProgressStage] = useState<string | undefined>();
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const handleProcess = async () => {
|
const handleProcess = async () => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
setProcessing(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResults([]);
|
||||||
|
setProcessing(true);
|
||||||
|
setProgressPhase("uploading");
|
||||||
|
setProgressPercent(0);
|
||||||
|
setProgressStage(undefined);
|
||||||
|
setElapsed(0);
|
||||||
|
|
||||||
try {
|
const startTime = Date.now();
|
||||||
const formData = new FormData();
|
elapsedRef.current = setInterval(() => {
|
||||||
formData.append("file", files[0]);
|
setElapsed(Math.floor((Date.now() - startTime) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
const res = await fetch("/api/v1/tools/barcode-read", {
|
const total = files.length;
|
||||||
method: "POST",
|
const allResults: FileResult[] = [];
|
||||||
headers: formatHeaders(),
|
const errors: string[] = [];
|
||||||
body: formData,
|
const { updateEntry } = useFileStore.getState();
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
for (let i = 0; i < total; i++) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const file = files[i];
|
||||||
throw new Error(body.error || `Failed: ${res.status}`);
|
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();
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
setResult(data);
|
|
||||||
} catch (err) {
|
if (errors.length === total) {
|
||||||
setError(err instanceof Error ? err.message : "Reading failed");
|
setError(errors.join("; "));
|
||||||
} finally {
|
} else if (errors.length > 0) {
|
||||||
setProcessing(false);
|
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 () => {
|
const handleCopyAll = async () => {
|
||||||
if (!result?.text) return;
|
const allText = results
|
||||||
const ok = await copyToClipboard(result.text);
|
.flatMap((r) => r.barcodes.map((b) => `${FORMAT_LABELS[b.type] ?? b.type}: ${b.text}`))
|
||||||
|
.join("\n");
|
||||||
|
const ok = await copyToClipboard(allText);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setCopied(true);
|
setCopiedAll(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
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;
|
const hasFile = files.length > 0;
|
||||||
|
let globalIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
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).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
{/* Thorough scan toggle */}
|
||||||
type="button"
|
<SectionLabel>Options</SectionLabel>
|
||||||
data-testid="barcode-read-submit"
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
onClick={handleProcess}
|
<input
|
||||||
disabled={!hasFile || processing}
|
type="checkbox"
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
checked={tryHarder}
|
||||||
>
|
onChange={(e) => setTryHarder(e.target.checked)}
|
||||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
className="rounded border-border accent-primary"
|
||||||
{processing ? "Reading..." : "Read Barcode"}
|
/>
|
||||||
</button>
|
<span className="text-sm text-muted-foreground">Thorough scan</span>
|
||||||
|
<span
|
||||||
|
title="Spends more time analyzing the image. Enable for small, damaged, or low-contrast barcodes."
|
||||||
|
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
{result && (
|
{/* Process button / progress */}
|
||||||
<div className="p-3 rounded-lg bg-muted space-y-2">
|
{processing ? (
|
||||||
{result.found ? (
|
<ProgressCard
|
||||||
<>
|
active={processing}
|
||||||
<p className="text-xs text-muted-foreground">Decoded Text:</p>
|
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
||||||
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
|
label="Scanning for barcodes"
|
||||||
<button
|
stage={progressStage}
|
||||||
type="button"
|
percent={progressPercent}
|
||||||
onClick={copyText}
|
elapsed={elapsed}
|
||||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
|
/>
|
||||||
>
|
) : (
|
||||||
{copied ? (
|
<button
|
||||||
<>
|
type="button"
|
||||||
<Check className="h-3 w-3" /> Copied
|
data-testid="barcode-read-submit"
|
||||||
</>
|
onClick={handleProcess}
|
||||||
) : (
|
disabled={!hasFile || processing}
|
||||||
<>
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
<Copy className="h-3 w-3" /> Copy to clipboard
|
>
|
||||||
</>
|
<Search className="h-4 w-4" />
|
||||||
)}
|
{files.length > 1 ? `Scan Barcodes (${files.length} files)` : "Scan Barcodes"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
)}
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">No QR code found in the image.</p>
|
{/* Results */}
|
||||||
)}
|
{results.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Summary badge */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{totalBarcodes === 0
|
||||||
|
? "No barcodes found"
|
||||||
|
: `Found ${totalBarcodes} barcode${totalBarcodes !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
{totalBarcodes > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{copiedAll ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
{copiedAll ? "Copied" : "Copy all"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-file results */}
|
||||||
|
{results.map((fileResult) => (
|
||||||
|
<div key={fileResult.filename} className="space-y-1.5">
|
||||||
|
{/* Show filename header only when multiple files */}
|
||||||
|
{results.length > 1 && (
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground truncate pt-1">
|
||||||
|
{fileResult.filename}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileResult.barcodes.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground/60 italic py-1">No barcodes found</p>
|
||||||
|
) : (
|
||||||
|
fileResult.barcodes.map((barcode) => {
|
||||||
|
const idx = globalIndex++;
|
||||||
|
const label = FORMAT_LABELS[barcode.type] ?? barcode.type;
|
||||||
|
const badgeColor = getBadgeColor(barcode.type);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-muted group">
|
||||||
|
{/* Barcode type badge */}
|
||||||
|
<div className="shrink-0 pt-0.5">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${badgeColor}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Decoded value */}
|
||||||
|
<p className="flex-1 text-sm text-foreground font-mono break-all leading-relaxed">
|
||||||
|
{barcode.text}
|
||||||
|
</p>
|
||||||
|
{/* Copy button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyOne(barcode.text, idx)}
|
||||||
|
className="shrink-0 p-1 rounded hover:bg-background/80 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Copy value"
|
||||||
|
>
|
||||||
|
{copiedIndex === idx ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
id: "barcode-read",
|
id: "barcode-read",
|
||||||
name: "Barcode Reader",
|
name: "Barcode Reader",
|
||||||
description: "Read QR codes and barcodes from images",
|
description: "Scan images for QR codes, barcodes, and 2D codes",
|
||||||
category: "utilities",
|
category: "utilities",
|
||||||
icon: "ScanLine",
|
icon: "ScanLine",
|
||||||
route: "/barcode-read",
|
route: "/barcode-read",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
"barcode-read": {
|
"barcode-read": {
|
||||||
name: "Barcode Reader",
|
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" },
|
collage: { name: "Collage / Grid", description: "Combine images into a grid layout" },
|
||||||
stitch: { name: "Stitch", description: "Join images side by side or top to bottom" },
|
stitch: { name: "Stitch", description: "Join images side by side or top to bottom" },
|
||||||
|
|||||||
Reference in New Issue
Block a user