mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: SOTA Image to Base64 converter with 6 output formats (#65)
* feat(image-to-base64): register tool in shared constants and i18n * feat(image-to-base64): add API route with Sharp pipeline and base64 encoding * feat(image-to-base64): add Zustand store for base64 results * feat(image-to-base64): add settings panel component * feat(image-to-base64): add results panel with 6-tab output and batch accordion * feat(image-to-base64): register tool in frontend tool registry * fix(image-to-base64): pass through original buffer when no resize/conversion needed --------- Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
co-authored by
stirling-image
parent
2f11b9e101
commit
01cbb16cd9
@@ -0,0 +1,199 @@
|
||||
import { basename } from "node:path";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
outputFormat: z.enum(["original", "jpeg", "png", "webp"]).default("original"),
|
||||
quality: z.number().int().min(1).max(100).default(80),
|
||||
maxWidth: z.number().int().min(0).default(0),
|
||||
maxHeight: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
interface FileResult {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
originalSize: number;
|
||||
encodedSize: number;
|
||||
overheadPercent: number;
|
||||
base64: string;
|
||||
dataUri: string;
|
||||
}
|
||||
|
||||
interface FileError {
|
||||
filename: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
tiff: "image/tiff",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
heic: "image/jpeg",
|
||||
heif: "image/jpeg",
|
||||
};
|
||||
|
||||
function detectMimeType(format: string): string {
|
||||
return MIME_MAP[format.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
export function registerImageToBase64(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/image-to-base64",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const files: Array<{ buffer: Buffer; filename: string }> = [];
|
||||
let settings = {};
|
||||
|
||||
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);
|
||||
}
|
||||
files.push({
|
||||
buffer: Buffer.concat(chunks),
|
||||
filename: basename(part.filename ?? "image"),
|
||||
});
|
||||
} else if (part.fieldname === "settings") {
|
||||
try {
|
||||
settings = JSON.parse(part.value as string);
|
||||
} catch {
|
||||
// ignore invalid JSON, use defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return reply.status(400).send({ error: "No image files provided" });
|
||||
}
|
||||
|
||||
const parsed = settingsSchema.safeParse(settings);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
}
|
||||
const opts = parsed.data;
|
||||
|
||||
const results: FileResult[] = [];
|
||||
const errors: FileError[] = [];
|
||||
|
||||
for (const { buffer, filename } of files) {
|
||||
try {
|
||||
const originalSize = buffer.length;
|
||||
|
||||
// Decode HEIC/HEIF to PNG for Sharp compatibility
|
||||
const decoded = await ensureSharpCompat(buffer);
|
||||
let pipeline = sharp(decoded);
|
||||
|
||||
// Get original metadata for dimensions
|
||||
const metadata = await pipeline.metadata();
|
||||
let width = metadata.width ?? 0;
|
||||
let height = metadata.height ?? 0;
|
||||
|
||||
// Apply resize if requested
|
||||
if (opts.maxWidth > 0 || opts.maxHeight > 0) {
|
||||
pipeline = pipeline.resize({
|
||||
width: opts.maxWidth > 0 ? opts.maxWidth : undefined,
|
||||
height: opts.maxHeight > 0 ? opts.maxHeight : undefined,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine output format and encode
|
||||
let outputBuffer: Buffer;
|
||||
let mimeType: string;
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const isHeic = ["heic", "heif", "hif"].includes(ext);
|
||||
|
||||
if (opts.outputFormat !== "original") {
|
||||
switch (opts.outputFormat) {
|
||||
case "jpeg":
|
||||
outputBuffer = await pipeline.jpeg({ quality: opts.quality }).toBuffer();
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case "png":
|
||||
outputBuffer = await pipeline.png().toBuffer();
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case "webp":
|
||||
outputBuffer = await pipeline.webp({ quality: opts.quality }).toBuffer();
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
default:
|
||||
outputBuffer = await pipeline.toBuffer();
|
||||
mimeType = detectMimeType(ext);
|
||||
}
|
||||
} else if (isHeic) {
|
||||
outputBuffer = await pipeline.jpeg({ quality: opts.quality }).toBuffer();
|
||||
mimeType = "image/jpeg";
|
||||
} else if (ext === "svg" || ext === "svgz") {
|
||||
outputBuffer = buffer;
|
||||
mimeType = "image/svg+xml";
|
||||
} else if (opts.maxWidth > 0 || opts.maxHeight > 0) {
|
||||
// Resize requested - must go through Sharp pipeline
|
||||
outputBuffer = await pipeline.toBuffer();
|
||||
mimeType = detectMimeType(metadata.format ?? ext);
|
||||
} else {
|
||||
// No conversion, no resize - pass through decoded buffer as-is
|
||||
outputBuffer = decoded;
|
||||
mimeType = detectMimeType(metadata.format ?? ext);
|
||||
}
|
||||
|
||||
// Get final dimensions after resize
|
||||
if (opts.maxWidth > 0 || opts.maxHeight > 0) {
|
||||
const resizedMeta = await sharp(outputBuffer).metadata();
|
||||
width = resizedMeta.width ?? width;
|
||||
height = resizedMeta.height ?? height;
|
||||
}
|
||||
|
||||
const base64 = outputBuffer.toString("base64");
|
||||
const encodedSize = Buffer.byteLength(base64, "utf8");
|
||||
const overheadPercent =
|
||||
originalSize > 0
|
||||
? Math.round(((encodedSize - originalSize) / originalSize) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
results.push({
|
||||
filename,
|
||||
mimeType,
|
||||
width,
|
||||
height,
|
||||
originalSize,
|
||||
encodedSize,
|
||||
overheadPercent,
|
||||
base64,
|
||||
dataUri: `data:${mimeType};base64,${base64}`,
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
filename,
|
||||
error: err instanceof Error ? err.message : "Failed to process image",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ results, errors });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { registerFavicon } from "./favicon.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
import { registerInfo } from "./info.js";
|
||||
import { registerNoiseRemoval } from "./noise-removal.js";
|
||||
@@ -106,6 +107,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "color-palette", register: registerColorPalette },
|
||||
{ id: "qr-generate", register: registerQrGenerate },
|
||||
{ id: "barcode-read", register: registerBarcodeRead },
|
||||
{ id: "image-to-base64", register: registerImageToBase64 },
|
||||
|
||||
// Layout & Composition
|
||||
{ id: "collage", register: registerCollage },
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Check, ChevronDown, ChevronRight, ClipboardCopy, Download, Loader2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Base64Result } from "@/stores/base64-store";
|
||||
import { useBase64Store } from "@/stores/base64-store";
|
||||
|
||||
// -- Snippet generators -----------------------------------------------------
|
||||
|
||||
type TabId = "datauri" | "raw" | "html" | "css" | "json" | "markdown";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
generate: (r: Base64Result) => string;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "datauri", label: "Data URI", generate: (r) => r.dataUri },
|
||||
{ id: "raw", label: "Raw Base64", generate: (r) => r.base64 },
|
||||
{
|
||||
id: "html",
|
||||
label: "HTML",
|
||||
generate: (r) => {
|
||||
const alt = r.filename.replace(/\.[^.]+$/, "");
|
||||
return `<img src="${r.dataUri}" alt="${alt}" />`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "css",
|
||||
label: "CSS",
|
||||
generate: (r) => `background-image: url(${r.dataUri});`,
|
||||
},
|
||||
{
|
||||
id: "json",
|
||||
label: "JSON",
|
||||
generate: (r) => JSON.stringify({ image: r.dataUri }, null, 2),
|
||||
},
|
||||
{
|
||||
id: "markdown",
|
||||
label: "Markdown",
|
||||
generate: (r) => {
|
||||
const alt = r.filename.replace(/\.[^.]+$/, "");
|
||||
return ``;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// -- CopyButton -------------------------------------------------------------
|
||||
|
||||
function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <ClipboardCopy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied!" : (label ?? "Copy")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Single file result -----------------------------------------------------
|
||||
|
||||
function FileResult({ result }: { result: Base64Result }) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("datauri");
|
||||
const tab = TABS.find((t) => t.id === activeTab)!;
|
||||
const output = tab.generate(result);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([output], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${result.filename}.base64.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [output, result.filename]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<img
|
||||
src={result.dataUri}
|
||||
alt={result.filename}
|
||||
className="w-12 h-12 rounded-md object-cover bg-muted flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{result.filename}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{result.width}x{result.height} · {formatBytes(result.originalSize)} →{" "}
|
||||
{formatBytes(result.encodedSize)}{" "}
|
||||
<span className={result.overheadPercent > 50 ? "text-amber-500" : ""}>
|
||||
(+{result.overheadPercent}%)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-0 border-b border-border mb-2 overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`px-3 py-1.5 text-xs whitespace-nowrap transition-colors ${
|
||||
activeTab === t.id
|
||||
? "text-primary border-b-2 border-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Code output */}
|
||||
<div className="flex-1 min-h-0 bg-muted rounded-md p-3 overflow-auto">
|
||||
<pre className="text-[11px] font-mono text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<CopyButton text={output} label="Copy to Clipboard" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download .txt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Batch accordion item ---------------------------------------------------
|
||||
|
||||
function BatchItem({
|
||||
result,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
result: Base64Result;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 bg-muted/50 hover:bg-muted/80 transition-colors text-left"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-foreground truncate">{result.filename}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
|
||||
{result.width}x{result.height} · {formatBytes(result.originalSize)} →{" "}
|
||||
{formatBytes(result.encodedSize)}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="p-3 border-t border-border">
|
||||
<FileResult result={result} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Main ResultsPanel ------------------------------------------------------
|
||||
|
||||
export function ImageToBase64Results() {
|
||||
const { results, errors, processing, expandedIndex, setExpandedIndex } = useBase64Store();
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Converting to base64...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload images and click "Convert to Base64" to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single file - show directly
|
||||
if (results.length === 1 && errors.length === 0) {
|
||||
return (
|
||||
<div className="p-4 h-full">
|
||||
<FileResult result={results[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Batch - accordion view
|
||||
return (
|
||||
<div className="p-4 space-y-2 overflow-auto h-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{results.length} converted
|
||||
{errors.length > 0 ? `, ${errors.length} failed` : ""}
|
||||
</p>
|
||||
<CopyButton
|
||||
text={JSON.stringify(
|
||||
results.map((r) => r.dataUri),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
label="Copy All as JSON"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.map((err) => (
|
||||
<div
|
||||
key={err.filename}
|
||||
className="border border-red-500/30 rounded-md px-3 py-2 bg-red-500/5"
|
||||
>
|
||||
<p className="text-xs text-red-500">
|
||||
<span className="font-medium">{err.filename}</span>: {err.error}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{results.map((result, i) => (
|
||||
<BatchItem
|
||||
key={result.filename}
|
||||
result={result}
|
||||
expanded={expandedIndex === i}
|
||||
onToggle={() => setExpandedIndex(expandedIndex === i ? -1 : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useBase64Store } from "@/stores/base64-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const OUTPUT_FORMATS = [
|
||||
{ value: "original", label: "Keep Original" },
|
||||
{ value: "jpeg", label: "JPEG" },
|
||||
{ value: "png", label: "PNG" },
|
||||
{ value: "webp", label: "WebP" },
|
||||
] as const;
|
||||
|
||||
export function ImageToBase64Settings() {
|
||||
const { files } = useFileStore();
|
||||
const { processing, setProcessing, setResults, reset } = useBase64Store();
|
||||
|
||||
const [outputFormat, setOutputFormat] = useState("original");
|
||||
const [quality, setQuality] = useState(80);
|
||||
const [maxWidth, setMaxWidth] = useState(0);
|
||||
const [maxHeight, setMaxHeight] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
reset();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
formData.append("settings", JSON.stringify({ outputFormat, quality, maxWidth, maxHeight }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/image-to-base64", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setResults(data.results, data.errors);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to convert");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = files.length > 0;
|
||||
const showQuality = outputFormat === "jpeg" || outputFormat === "webp";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Output Format */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Output Image Format</label>
|
||||
<p className="text-[10px] text-muted-foreground/70 mb-1.5">
|
||||
Convert before encoding to control MIME type and size
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{OUTPUT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.value}
|
||||
type="button"
|
||||
onClick={() => setOutputFormat(fmt.value)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
outputFormat === fmt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality slider */}
|
||||
{showQuality && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">Quality</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1 accent-primary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Lower quality = smaller base64 string
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Max Width */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Max Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxWidth}
|
||||
onChange={(e) => setMaxWidth(Math.max(0, Number(e.target.value)))}
|
||||
placeholder="0 = no limit"
|
||||
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Height */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Max Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxHeight}
|
||||
onChange={(e) => setMaxHeight(Math.max(0, Number(e.target.value)))}
|
||||
placeholder="0 = no limit"
|
||||
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
Resize before encoding. Aspect ratio is preserved. 0 = no limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="base64-submit"
|
||||
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
|
||||
? "Converting..."
|
||||
: `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,16 @@ const BarcodeReadSettings = lazy(() =>
|
||||
default: m.BarcodeReadSettings,
|
||||
})),
|
||||
);
|
||||
const ImageToBase64Settings = lazy(() =>
|
||||
import("@/components/tools/image-to-base64-settings").then((m) => ({
|
||||
default: m.ImageToBase64Settings,
|
||||
})),
|
||||
);
|
||||
const ImageToBase64Results = lazy(() =>
|
||||
import("@/components/tools/image-to-base64-results").then((m) => ({
|
||||
default: m.ImageToBase64Results,
|
||||
})),
|
||||
);
|
||||
const CollageSettings = lazy(() =>
|
||||
import("@/components/tools/collage-settings").then((m) => ({ default: m.CollageSettings })),
|
||||
);
|
||||
@@ -354,6 +364,14 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
{ displayMode: "no-dropzone", Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview },
|
||||
],
|
||||
["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }],
|
||||
[
|
||||
"image-to-base64",
|
||||
{
|
||||
displayMode: "custom-results",
|
||||
Settings: ImageToBase64Settings,
|
||||
ResultsPanel: ImageToBase64Results,
|
||||
},
|
||||
],
|
||||
|
||||
// Layout & Composition
|
||||
["collage", { displayMode: "before-after", Settings: CollageSettings }],
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface Base64Result {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
originalSize: number;
|
||||
encodedSize: number;
|
||||
overheadPercent: number;
|
||||
base64: string;
|
||||
dataUri: string;
|
||||
}
|
||||
|
||||
export interface Base64Error {
|
||||
filename: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface Base64State {
|
||||
results: Base64Result[];
|
||||
errors: Base64Error[];
|
||||
processing: boolean;
|
||||
expandedIndex: number;
|
||||
|
||||
setResults: (results: Base64Result[], errors: Base64Error[]) => void;
|
||||
setProcessing: (v: boolean) => void;
|
||||
setExpandedIndex: (i: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useBase64Store = create<Base64State>((set) => ({
|
||||
results: [],
|
||||
errors: [],
|
||||
processing: false,
|
||||
expandedIndex: 0,
|
||||
|
||||
setResults: (results, errors) => set({ results, errors, expandedIndex: 0 }),
|
||||
setProcessing: (v) => set({ processing: v }),
|
||||
setExpandedIndex: (i) => set({ expandedIndex: i }),
|
||||
reset: () => set({ results: [], errors: [], processing: false, expandedIndex: 0 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user