mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,85 @@
|
||||
import sharp from "sharp";
|
||||
import jsQR from "jsqr";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { basename } from "node:path";
|
||||
|
||||
/**
|
||||
* Read QR codes and barcodes from uploaded images.
|
||||
*/
|
||||
export function registerBarcodeRead(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/barcode-read",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert to RGBA raw pixel data for jsQR
|
||||
const image = sharp(fileBuffer);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width ?? 0;
|
||||
const height = metadata.height ?? 0;
|
||||
|
||||
const rawData = await image
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const code = jsQR(
|
||||
new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
if (!code) {
|
||||
return reply.send({
|
||||
filename,
|
||||
found: false,
|
||||
text: null,
|
||||
message: "No QR code found in the image",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
filename,
|
||||
found: true,
|
||||
text: code.data,
|
||||
location: {
|
||||
topLeft: code.location.topLeftCorner,
|
||||
topRight: code.location.topRightCorner,
|
||||
bottomLeft: code.location.bottomLeftCorner,
|
||||
bottomRight: code.location.bottomRightCorner,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Barcode reading failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { basename } from "node:path";
|
||||
|
||||
/**
|
||||
* Simple k-means-like color quantization to extract dominant colors.
|
||||
*/
|
||||
function extractColors(pixels: Buffer, channelCount: number, maxColors: number): string[] {
|
||||
// Build frequency map of quantized colors
|
||||
const colorMap = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < pixels.length; i += channelCount) {
|
||||
// Quantize to reduce noise (round to nearest 16)
|
||||
const r = Math.round(pixels[i] / 16) * 16;
|
||||
const g = Math.round(pixels[i + 1] / 16) * 16;
|
||||
const b = Math.round(pixels[i + 2] / 16) * 16;
|
||||
const key = `${r},${g},${b}`;
|
||||
colorMap.set(key, (colorMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Sort by frequency and pick top colors
|
||||
const sorted = [...colorMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
// Filter similar colors (merge colors within distance 40)
|
||||
const results: Array<{ r: number; g: number; b: number; count: number }> = [];
|
||||
for (const [key, count] of sorted) {
|
||||
const [r, g, b] = key.split(",").map(Number);
|
||||
const tooClose = results.some(
|
||||
(c) =>
|
||||
Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
|
||||
);
|
||||
if (!tooClose) {
|
||||
results.push({ r, g, b, count });
|
||||
}
|
||||
if (results.length >= maxColors) break;
|
||||
}
|
||||
|
||||
return results.map(({ r, g, b }) => {
|
||||
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
return hex;
|
||||
});
|
||||
}
|
||||
|
||||
export function registerColorPalette(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/color-palette",
|
||||
async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Resize to small image for analysis
|
||||
const raw = await sharp(fileBuffer)
|
||||
.resize(50, 50, { fit: "fill" })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const colors = extractColors(raw, 3, 8);
|
||||
|
||||
return reply.send({
|
||||
filename,
|
||||
colors,
|
||||
count: colors.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Color extraction failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
/**
|
||||
* Compare two images: compute a pixel-level diff and similarity score.
|
||||
*/
|
||||
export function registerCompare(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/compare",
|
||||
async (request, reply) => {
|
||||
let bufferA: Buffer | null = null;
|
||||
let bufferB: Buffer | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (!bufferA) {
|
||||
bufferA = buf;
|
||||
} else {
|
||||
bufferB = buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!bufferA || !bufferB) {
|
||||
return reply.status(400).send({ error: "Two image files are required for comparison" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize both to same size for comparison
|
||||
const metaA = await sharp(bufferA).metadata();
|
||||
const metaB = await sharp(bufferB).metadata();
|
||||
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
|
||||
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
|
||||
|
||||
const rawA = await sharp(bufferA).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
|
||||
const rawB = await sharp(bufferB).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
|
||||
|
||||
// Compute pixel diff
|
||||
const diffPixels = Buffer.alloc(w * h * 4);
|
||||
let totalDiff = 0;
|
||||
const pixelCount = w * h;
|
||||
|
||||
for (let i = 0; i < rawA.length; i += 4) {
|
||||
const dr = Math.abs(rawA[i] - rawB[i]);
|
||||
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
|
||||
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
|
||||
const pixelDiff = (dr + dg + db) / 3;
|
||||
totalDiff += pixelDiff;
|
||||
|
||||
// Red tint for differences, transparent for identical
|
||||
if (pixelDiff > 10) {
|
||||
diffPixels[i] = 255; // R
|
||||
diffPixels[i + 1] = 0; // G
|
||||
diffPixels[i + 2] = 0; // B
|
||||
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
|
||||
} else {
|
||||
// Slightly show original
|
||||
diffPixels[i] = rawA[i];
|
||||
diffPixels[i + 1] = rawA[i + 1];
|
||||
diffPixels[i + 2] = rawA[i + 2];
|
||||
diffPixels[i + 3] = 128;
|
||||
}
|
||||
}
|
||||
|
||||
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
|
||||
|
||||
const diffBuffer = await sharp(diffPixels, {
|
||||
raw: { width: w, height: h, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const diffFilename = "diff.png";
|
||||
const outputPath = join(workspacePath, "output", diffFilename);
|
||||
await writeFile(outputPath, diffBuffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
similarity: Math.round(similarity * 100) / 100,
|
||||
dimensions: { width: w, height: h },
|
||||
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
|
||||
originalSize: bufferA.length + bufferB.length,
|
||||
processedSize: diffBuffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Comparison failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { basename } from "node:path";
|
||||
|
||||
/**
|
||||
* Compute a dHash (difference hash) for perceptual duplicate detection.
|
||||
* Resize to 9x8 grayscale, compare adjacent pixels to create 64-bit hash.
|
||||
*/
|
||||
async function computeDHash(buffer: Buffer): Promise<string> {
|
||||
const pixels = await sharp(buffer)
|
||||
.resize(9, 8, { fit: "fill" })
|
||||
.grayscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
let hash = "";
|
||||
for (let y = 0; y < 8; y++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const left = pixels[y * 9 + x];
|
||||
const right = pixels[y * 9 + x + 1];
|
||||
hash += left > right ? "1" : "0";
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hamming distance between two 64-bit hash strings.
|
||||
*/
|
||||
function hammingDistance(a: string, b: string): number {
|
||||
let distance = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) distance++;
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
export function registerFindDuplicates(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/find-duplicates",
|
||||
async (request, reply) => {
|
||||
const files: Array<{ buffer: Buffer; filename: string }> = [];
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (buf.length > 0) {
|
||||
files.push({
|
||||
buffer: buf,
|
||||
filename: basename(part.filename ?? `image-${files.length}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length < 2) {
|
||||
return reply.status(400).send({ error: "At least 2 images are required for duplicate detection" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Compute hashes for all images
|
||||
const hashes: Array<{ filename: string; hash: string }> = [];
|
||||
for (const file of files) {
|
||||
const hash = await computeDHash(file.buffer);
|
||||
hashes.push({ filename: file.filename, hash });
|
||||
}
|
||||
|
||||
// Compare all pairs, group duplicates
|
||||
const threshold = 10; // Hamming distance threshold for "similar"
|
||||
const groups: Array<{
|
||||
files: Array<{ filename: string; similarity: number }>;
|
||||
}> = [];
|
||||
const assigned = new Set<number>();
|
||||
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (assigned.has(i)) continue;
|
||||
|
||||
const group: Array<{ filename: string; similarity: number }> = [
|
||||
{ filename: hashes[i].filename, similarity: 100 },
|
||||
];
|
||||
|
||||
for (let j = i + 1; j < hashes.length; j++) {
|
||||
if (assigned.has(j)) continue;
|
||||
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
|
||||
if (dist <= threshold) {
|
||||
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
|
||||
group.push({ filename: hashes[j].filename, similarity });
|
||||
assigned.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
if (group.length > 1) {
|
||||
assigned.add(i);
|
||||
groups.push({ files: group });
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
totalImages: files.length,
|
||||
duplicateGroups: groups,
|
||||
uniqueImages: files.length - assigned.size,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Duplicate detection failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { basename } from "node:path";
|
||||
|
||||
/**
|
||||
* Image info route - read-only, returns JSON metadata.
|
||||
* Does NOT use createToolRoute since it doesn't produce a processed file.
|
||||
*/
|
||||
export function registerInfo(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/info",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await sharp(fileBuffer).metadata();
|
||||
const stats = await sharp(fileBuffer).stats();
|
||||
|
||||
// Build histogram data from stats
|
||||
const histogram = stats.channels.map((ch, i) => ({
|
||||
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
|
||||
min: ch.min,
|
||||
max: ch.max,
|
||||
mean: Math.round(ch.mean * 100) / 100,
|
||||
stdev: Math.round(ch.stdev * 100) / 100,
|
||||
}));
|
||||
|
||||
return reply.send({
|
||||
filename,
|
||||
fileSize: fileBuffer.length,
|
||||
width: metadata.width ?? 0,
|
||||
height: metadata.height ?? 0,
|
||||
format: metadata.format ?? "unknown",
|
||||
channels: metadata.channels ?? 0,
|
||||
hasAlpha: metadata.hasAlpha ?? false,
|
||||
colorSpace: metadata.space ?? "unknown",
|
||||
density: metadata.density ?? null,
|
||||
isProgressive: metadata.isProgressive ?? false,
|
||||
orientation: metadata.orientation ?? null,
|
||||
hasProfile: metadata.hasProfile ?? false,
|
||||
hasExif: !!metadata.exif,
|
||||
hasIcc: !!metadata.icc,
|
||||
hasXmp: !!metadata.xmp,
|
||||
bitDepth: metadata.depth ?? null,
|
||||
pages: metadata.pages ?? 1,
|
||||
histogram,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to read image metadata",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
import QRCode from "qrcode";
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
text: z.string().min(1).max(2000),
|
||||
size: z.number().min(100).max(2000).default(400),
|
||||
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
|
||||
foreground: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
|
||||
background: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
|
||||
});
|
||||
|
||||
/**
|
||||
* QR code generator - custom route (not factory) since it generates
|
||||
* images from text input, not from uploaded files.
|
||||
*/
|
||||
export function registerQrGenerate(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/qr-generate",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = request.body;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid request body" });
|
||||
}
|
||||
|
||||
const result = settingsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const settings = result.data;
|
||||
|
||||
try {
|
||||
const buffer = await QRCode.toBuffer(settings.text, {
|
||||
width: settings.size,
|
||||
errorCorrectionLevel: settings.errorCorrection,
|
||||
color: {
|
||||
dark: settings.foreground,
|
||||
light: settings.background,
|
||||
},
|
||||
type: "png",
|
||||
margin: 2,
|
||||
});
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const filename = "qrcode.png";
|
||||
const outputPath = join(workspacePath, "output", filename);
|
||||
await writeFile(outputPath, buffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||
originalSize: 0,
|
||||
processedSize: buffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "QR code generation failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user