Files
SnapOtter/apps/web/src/components/tools/image-to-base64-results.tsx
T
Siddharth Kumar Sah 93c588b239 fix(lint): resolve all biome warnings across API, web, and image-engine
API:
- Replace string concatenation with template literals (batch, pipeline,
  tool-factory, content-aware-resize, passport-photo, remove-background)
- Remove unused imports (teams, color-adjustments, image-enhancement)
- Replace non-null assertions with guard clauses in bg-effects and stitch
- Use optional chaining in docs route

Image-engine:
- Remove unused OutputFormat imports (compress, convert)
- Use local variables instead of reassigning parameters (optimize-for-web, sharpen)

Web:
- Fix useExhaustiveDependencies: remove genuinely redundant deps, add
  biome-ignore comments for intentional patterns (src prop, cleanup fns)
- Replace non-null assertions with null-safe alternatives
- Add accessible titles to inline SVGs (color-settings, image-enhancement-settings)
- Fix noLabelWithoutControl: associate labels via htmlFor/id or use <span>
  (image-to-base64-settings, edit-metadata-settings, qr-generate-settings)
- Replace div[role="button"] with <button> (pdf-to-image-settings)
- Use stable keys instead of array indices (collage-preview, collage-settings)
- Fix noStaticElementInteractions in collage-preview (role="none")
- Remove unused function parameters and imports
2026-04-14 22:15:37 +08:00

326 lines
11 KiB
TypeScript

import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import type { Base64Result } from "@/stores/base64-store";
import { useBase64Store } from "@/stores/base64-store";
import { useFileStore } from "@/stores/file-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`;
}
function downloadFile(content: string, filename: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// -- 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 view ------------------------------------------------
function FileResult({ result }: { result: Base64Result }) {
const [activeTab, setActiveTab] = useState<TabId>("datauri");
const tab = TABS.find((t) => t.id === activeTab) ?? TABS[0];
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>
);
}
// -- Main ResultsPanel ------------------------------------------------------
export function ImageToBase64Results() {
const { results, errors, processing, progress } = useBase64Store();
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
// -- Processing state: progress bar --
if (processing) {
const pct =
progress && progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0;
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4 w-72">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
{progress ? (
<>
<p className="text-sm text-muted-foreground text-center truncate w-full">
Converting{" "}
<span className="font-medium text-foreground">{progress.currentFile}</span>
</p>
<div className="w-full">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5 text-center">
{progress.completed} of {progress.total} files
</p>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">Starting conversion...</p>
)}
</div>
</div>
);
}
// -- No results yet: show preview of selected image --
if (results.length === 0 && errors.length === 0) {
if (originalBlobUrl) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-6">
<img
src={originalBlobUrl}
alt={selectedFileName ?? "Preview"}
className="max-h-[60%] max-w-full rounded-lg object-contain bg-muted"
/>
<p className="text-sm text-muted-foreground text-center">
{entries.length > 1
? `${entries.length} files ready. Click "Convert to Base64" to start.`
: `Click "Convert to Base64" to convert.`}
</p>
</div>
);
}
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">
Upload images and click "Convert to Base64" to get started.
</p>
</div>
);
}
// -- Results ready: find result for the currently selected file --
const currentFileName = entries[selectedIndex]?.file.name ?? null;
const currentResult = currentFileName
? results.find((r) => r.filename === currentFileName)
: null;
const currentError = currentFileName ? errors.find((e) => e.filename === currentFileName) : null;
const hasMultiple = entries.length > 1;
return (
<div className="p-4 flex flex-col h-full">
{/* Batch summary bar */}
{hasMultiple && (
<div className="mb-3 pb-3 border-b border-border">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">
{results.length} of {entries.length} converted
{errors.length > 0 ? ` - ${errors.length} failed` : ""}
</p>
</div>
<div className="flex gap-1.5 flex-wrap">
<CopyButton
text={JSON.stringify(
results.map((r) => r.dataUri),
null,
2,
)}
label="Copy All as JSON"
/>
<button
type="button"
onClick={() => {
const json = JSON.stringify(
results.map((r) => ({
filename: r.filename,
mimeType: r.mimeType,
width: r.width,
height: r.height,
dataUri: r.dataUri,
})),
null,
2,
);
downloadFile(json, "base64-all.json", "application/json");
}}
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"
>
<FileJson className="h-3.5 w-3.5" />
Download All as JSON
</button>
<button
type="button"
onClick={() => {
const lines = results
.map((r) => `--- ${r.filename} ---\n${r.dataUri}\n`)
.join("\n");
downloadFile(lines, "base64-all.txt", "text/plain");
}}
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"
>
<FileText className="h-3.5 w-3.5" />
Download All as Text
</button>
</div>
</div>
)}
{/* Current file result */}
<div className="flex-1 min-h-0">
{currentResult ? (
<FileResult result={currentResult} />
) : currentError ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-sm text-red-500 font-medium mb-1">{currentError.filename}</p>
<p className="text-xs text-red-500/80">{currentError.error}</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">
No result for this file. It may not have been processed yet.
</p>
</div>
)}
</div>
</div>
);
}