feat: add utility tools (image info, compare, duplicates, color palette, QR, barcode)

Add 6 utility tools with API routes and frontend settings:
- info: read-only image metadata inspector with channel histogram
- compare: side-by-side pixel diff with similarity percentage
- find-duplicates: dHash perceptual hashing for duplicate detection
- color-palette: frequency-based dominant color extraction
- qr-generate: QR code generator from text/URL (custom JSON route)
- barcode-read: QR code reader using jsQR
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:20:35 +08:00
parent aeaf783ee4
commit 0c3c64eeac
12 changed files with 1253 additions and 0 deletions
@@ -0,0 +1,99 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2, Copy, Check } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
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 handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setResult(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
const res = await fetch("/api/v1/tools/barcode-read", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data = await res.json();
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Reading failed");
} finally {
setProcessing(false);
}
};
const copyText = async () => {
if (!result?.text) return;
try {
await navigator.clipboard.writeText(result.text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback: silent fail
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload an image containing a QR code to decode its content.
</p>
<button
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Reading..." : "Read Barcode"}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{result && (
<div className="p-3 rounded-lg bg-muted space-y-2">
{result.found ? (
<>
<p className="text-xs text-muted-foreground">Decoded Text:</p>
<p className="text-sm text-foreground font-mono break-all">{result.text}</p>
<button
onClick={copyText}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80"
>
{copied ? (
<><Check className="h-3 w-3" /> Copied</>
) : (
<><Copy className="h-3 w-3" /> Copy to clipboard</>
)}
</button>
</>
) : (
<p className="text-xs text-muted-foreground">No QR code found in the image.</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2, Copy, Check } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function ColorPaletteSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [colors, setColors] = useState<string[]>([]);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setColors([]);
try {
const formData = new FormData();
formData.append("file", files[0]);
const res = await fetch("/api/v1/tools/color-palette", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data = await res.json();
setColors(data.colors);
} catch (err) {
setError(err instanceof Error ? err.message : "Extraction failed");
} finally {
setProcessing(false);
}
};
const copyColor = async (color: string, idx: number) => {
try {
await navigator.clipboard.writeText(color);
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 1500);
} catch {
// Fallback: silent fail
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<button
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Extracting..." : "Extract Colors"}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{colors.length > 0 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Dominant Colors ({colors.length})
</label>
<div className="grid grid-cols-2 gap-1.5">
{colors.map((color, i) => (
<button
key={i}
onClick={() => copyColor(color, i)}
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
>
<div
className="w-6 h-6 rounded border border-border shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono text-foreground flex-1 text-left">
{color}
</span>
{copiedIdx === i ? (
<Check className="h-3 w-3 text-green-500 shrink-0" />
) : (
<Copy className="h-3 w-3 text-muted-foreground shrink-0" />
)}
</button>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,106 @@
import { useState, useRef } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2, Upload } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function CompareSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
const [secondFile, setSecondFile] = useState<File | null>(null);
const [similarity, setSimilarity] = useState<number | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const secondInputRef = useRef<HTMLInputElement>(null);
const handleProcess = async () => {
if (files.length === 0 || !secondFile) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
setSimilarity(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
formData.append("file", secondFile);
const res = await fetch("/api/v1/tools/compare", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setSimilarity(result.similarity);
setDownloadUrl(result.downloadUrl);
setProcessedUrl(result.downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Comparison failed");
} finally {
setProcessing(false);
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Second Image</label>
<input
ref={secondInputRef}
type="file"
accept="image/*"
onChange={(e) => setSecondFile(e.target.files?.[0] ?? null)}
className="hidden"
/>
<button
onClick={() => secondInputRef.current?.click()}
className="w-full mt-0.5 px-2 py-2 rounded border border-dashed border-border bg-background text-sm text-muted-foreground hover:text-foreground flex items-center justify-center gap-2"
>
<Upload className="h-4 w-4" />
{secondFile ? secondFile.name : "Choose second image"}
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{similarity !== null && (
<div className="p-3 rounded-lg bg-muted">
<p className="text-sm text-foreground font-medium">
Similarity: {similarity.toFixed(1)}%
</p>
<div className="mt-1 h-2 bg-background rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${similarity}%` }}
/>
</div>
</div>
)}
<button
onClick={handleProcess}
disabled={!hasFile || !secondFile || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Comparing..." : "Compare"}
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download Diff Image
</a>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface DuplicateGroup {
files: Array<{ filename: string; similarity: number }>;
}
interface DuplicateResult {
totalImages: number;
duplicateGroups: DuplicateGroup[];
uniqueImages: number;
}
export function FindDuplicatesSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [result, setResult] = useState<DuplicateResult | null>(null);
const handleProcess = async () => {
if (files.length < 2) return;
setProcessing(true);
setError(null);
setResult(null);
try {
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
const res = await fetch("/api/v1/tools/find-duplicates", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data: DuplicateResult = await res.json();
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Detection failed");
} finally {
setProcessing(false);
}
};
const hasFiles = files.length >= 2;
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload 2 or more images to find near-duplicates using perceptual hashing.
</p>
<button
onClick={handleProcess}
disabled={!hasFiles || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Scanning..." : `Scan ${files.length} Images`}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{result && (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-muted text-xs space-y-1">
<p className="text-foreground">Total images: {result.totalImages}</p>
<p className="text-foreground">Unique images: {result.uniqueImages}</p>
<p className="text-foreground">Duplicate groups: {result.duplicateGroups.length}</p>
</div>
{result.duplicateGroups.length === 0 ? (
<p className="text-xs text-muted-foreground">No duplicates found.</p>
) : (
result.duplicateGroups.map((group, gi) => (
<div key={gi} className="p-2 rounded border border-border space-y-1">
<p className="text-xs font-medium text-foreground">Group {gi + 1}</p>
{group.files.map((f, fi) => (
<div key={fi} className="flex justify-between text-xs">
<span className="text-foreground truncate">{f.filename}</span>
<span className="text-muted-foreground shrink-0 ml-2">{f.similarity}%</span>
</div>
))}
</div>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,150 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface ImageInfoData {
filename: string;
fileSize: number;
width: number;
height: number;
format: string;
channels: number;
hasAlpha: boolean;
colorSpace: string;
density: number | null;
isProgressive: boolean;
orientation: number | null;
hasProfile: boolean;
hasExif: boolean;
hasIcc: boolean;
hasXmp: boolean;
bitDepth: string | null;
pages: number;
histogram: Array<{
channel: string;
min: number;
max: number;
mean: number;
stdev: number;
}>;
}
export function InfoSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [info, setInfo] = useState<ImageInfoData | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setInfo(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
const res = await fetch("/api/v1/tools/info", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data: ImageInfoData = await res.json();
setInfo(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to read info");
} finally {
setProcessing(false);
}
};
const hasFile = files.length > 0;
const channelColors: Record<string, string> = {
red: "bg-red-500",
green: "bg-green-500",
blue: "bg-blue-500",
alpha: "bg-gray-500",
};
return (
<div className="space-y-4">
<button
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Reading..." : "Read Info"}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
{info && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-1 text-xs">
<div className="text-muted-foreground">Dimensions</div>
<div className="text-foreground font-mono">{info.width} x {info.height}</div>
<div className="text-muted-foreground">Format</div>
<div className="text-foreground font-mono">{info.format}</div>
<div className="text-muted-foreground">File Size</div>
<div className="text-foreground font-mono">{(info.fileSize / 1024).toFixed(1)} KB</div>
<div className="text-muted-foreground">Channels</div>
<div className="text-foreground font-mono">{info.channels}</div>
<div className="text-muted-foreground">Color Space</div>
<div className="text-foreground font-mono">{info.colorSpace}</div>
<div className="text-muted-foreground">Alpha</div>
<div className="text-foreground font-mono">{info.hasAlpha ? "Yes" : "No"}</div>
<div className="text-muted-foreground">DPI</div>
<div className="text-foreground font-mono">{info.density ?? "N/A"}</div>
<div className="text-muted-foreground">Progressive</div>
<div className="text-foreground font-mono">{info.isProgressive ? "Yes" : "No"}</div>
<div className="text-muted-foreground">ICC Profile</div>
<div className="text-foreground font-mono">{info.hasIcc ? "Yes" : "No"}</div>
<div className="text-muted-foreground">EXIF Data</div>
<div className="text-foreground font-mono">{info.hasExif ? "Yes" : "No"}</div>
<div className="text-muted-foreground">XMP Data</div>
<div className="text-foreground font-mono">{info.hasXmp ? "Yes" : "No"}</div>
<div className="text-muted-foreground">Pages</div>
<div className="text-foreground font-mono">{info.pages}</div>
</div>
{/* Histogram */}
<div>
<label className="text-xs font-medium text-muted-foreground">Channel Stats</label>
<div className="mt-1 space-y-1.5">
{info.histogram.map((ch) => (
<div key={ch.channel} className="space-y-0.5">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`} />
<span className="text-xs text-foreground capitalize">{ch.channel}</span>
</div>
<div className="flex gap-2 text-[10px] text-muted-foreground font-mono">
<span>min:{ch.min}</span>
<span>max:{ch.max}</span>
<span>mean:{ch.mean}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${channelColors[ch.channel] ?? "bg-gray-400"}`}
style={{ width: `${(ch.mean / 255) * 100}%`, opacity: 0.7 }}
/>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,120 @@
import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function QrGenerateSettings() {
const [text, setText] = useState("");
const [size, setSize] = useState(400);
const [errorCorrection, setErrorCorrection] = useState<"L" | "M" | "Q" | "H">("M");
const [foreground, setForeground] = useState("#000000");
const [background, setBackground] = useState("#FFFFFF");
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handleGenerate = async () => {
if (!text) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
setPreviewUrl(null);
try {
const res = await fetch("/api/v1/tools/qr-generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify({ text, size, errorCorrection, foreground, background }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Generation failed: ${res.status}`);
}
const result = await res.json();
setDownloadUrl(result.downloadUrl);
setPreviewUrl(result.downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
setProcessing(false);
}
};
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Text / URL</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or URL..."
rows={3}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground resize-none"
/>
</div>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Size</label>
<span className="text-xs font-mono text-foreground">{size}px</span>
</div>
<input type="range" min={100} max={2000} step={50} value={size} onChange={(e) => setSize(Number(e.target.value))} className="w-full mt-1" />
</div>
<div>
<label className="text-xs text-muted-foreground">Error Correction</label>
<select
value={errorCorrection}
onChange={(e) => setErrorCorrection(e.target.value as "L" | "M" | "Q" | "H")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="L">Low (7%)</option>
<option value="M">Medium (15%)</option>
<option value="Q">Quartile (25%)</option>
<option value="H">High (30%)</option>
</select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Foreground</label>
<input type="color" value={foreground} onChange={(e) => setForeground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Background</label>
<input type="color" value={background} onChange={(e) => setBackground(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
onClick={handleGenerate}
disabled={!text || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Generating..." : "Generate QR Code"}
</button>
{previewUrl && (
<div className="flex flex-col items-center gap-2">
<img src={previewUrl} alt="QR Code" className="max-w-full rounded border border-border" style={{ maxHeight: 200 }} />
<a href={downloadUrl!} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download QR Code
</a>
</div>
)}
</div>
);
}