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:
stirling-image
2026-04-14 10:17:19 +08:00
committed by GitHub
co-authored by stirling-image
parent 2f11b9e101
commit 01cbb16cd9
8 changed files with 698 additions and 0 deletions
@@ -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 `![${alt}](${r.dataUri})`;
},
},
];
// -- 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} &middot; {formatBytes(result.originalSize)} &rarr;{" "}
{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} &middot; {formatBytes(result.originalSize)} &rarr;{" "}
{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>
);
}
+18
View File
@@ -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 }],
+42
View File
@@ -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 }),
}));