mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(image-to-base64): progress bar, synced navigation, batch download buttons (#66)
- Process files one at a time for real per-file progress bar - Sync right panel with left panel file navigation (arrows work) - Show image preview before conversion - Add "Download All as JSON" and "Download All as Text" batch buttons - Unify batch action button styles Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
co-authored by
stirling-image
parent
c09c1be4d2
commit
b2489b0fb6
@@ -1,7 +1,8 @@
|
|||||||
import { Check, ChevronDown, ChevronRight, ClipboardCopy, Download, Loader2 } from "lucide-react";
|
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { Base64Result } from "@/stores/base64-store";
|
import type { Base64Result } from "@/stores/base64-store";
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
// -- Snippet generators -----------------------------------------------------
|
// -- Snippet generators -----------------------------------------------------
|
||||||
|
|
||||||
@@ -52,6 +53,16 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
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 -------------------------------------------------------------
|
// -- CopyButton -------------------------------------------------------------
|
||||||
|
|
||||||
function CopyButton({ text, label }: { text: string; label?: string }) {
|
function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||||
@@ -75,7 +86,7 @@ function CopyButton({ text, label }: { text: string; label?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Single file result -----------------------------------------------------
|
// -- Single file result view ------------------------------------------------
|
||||||
|
|
||||||
function FileResult({ result }: { result: Base64Result }) {
|
function FileResult({ result }: { result: Base64Result }) {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>("datauri");
|
const [activeTab, setActiveTab] = useState<TabId>("datauri");
|
||||||
@@ -154,89 +165,96 @@ function FileResult({ result }: { result: Base64Result }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- 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 ------------------------------------------------------
|
// -- Main ResultsPanel ------------------------------------------------------
|
||||||
|
|
||||||
export function ImageToBase64Results() {
|
export function ImageToBase64Results() {
|
||||||
const { results, errors, processing, expandedIndex, setExpandedIndex } = useBase64Store();
|
const { results, errors, processing, progress } = useBase64Store();
|
||||||
|
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
|
||||||
|
|
||||||
|
// -- Processing state: progress bar --
|
||||||
if (processing) {
|
if (processing) {
|
||||||
|
const pct =
|
||||||
|
progress && progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-4 w-72">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">Converting to base64...</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0) {
|
// -- 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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Upload images and click "Convert to Base64" to get started.
|
Upload images and click "Convert to Base64" to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single file - show directly
|
// -- Results ready: find result for the currently selected file --
|
||||||
if (results.length === 1 && errors.length === 0) {
|
const currentFileName = entries[selectedIndex]?.file.name ?? null;
|
||||||
return (
|
const currentResult = currentFileName
|
||||||
<div className="p-4 h-full">
|
? results.find((r) => r.filename === currentFileName)
|
||||||
<FileResult result={results[0]} />
|
: null;
|
||||||
</div>
|
const currentError = currentFileName ? errors.find((e) => e.filename === currentFileName) : null;
|
||||||
);
|
|
||||||
}
|
const hasMultiple = entries.length > 1;
|
||||||
|
|
||||||
// Batch - accordion view
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-2 overflow-auto h-full">
|
<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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{results.length} converted
|
{results.length} of {entries.length} converted
|
||||||
{errors.length > 0 ? `, ${errors.length} failed` : ""}
|
{errors.length > 0 ? ` - ${errors.length} failed` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={JSON.stringify(
|
text={JSON.stringify(
|
||||||
results.map((r) => r.dataUri),
|
results.map((r) => r.dataUri),
|
||||||
@@ -245,27 +263,63 @@ export function ImageToBase64Results() {
|
|||||||
)}
|
)}
|
||||||
label="Copy All as JSON"
|
label="Copy All as JSON"
|
||||||
/>
|
/>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
{errors.map((err) => (
|
onClick={() => {
|
||||||
<div
|
const json = JSON.stringify(
|
||||||
key={err.filename}
|
results.map((r) => ({
|
||||||
className="border border-red-500/30 rounded-md px-3 py-2 bg-red-500/5"
|
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"
|
||||||
>
|
>
|
||||||
<p className="text-xs text-red-500">
|
<FileJson className="h-3.5 w-3.5" />
|
||||||
<span className="font-medium">{err.filename}</span>: {err.error}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
{results.map((result, i) => (
|
|
||||||
<BatchItem
|
|
||||||
key={result.filename}
|
|
||||||
result={result}
|
|
||||||
expanded={expandedIndex === i}
|
|
||||||
onToggle={() => setExpandedIndex(expandedIndex === i ? -1 : i)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const OUTPUT_FORMATS = [
|
|||||||
|
|
||||||
export function ImageToBase64Settings() {
|
export function ImageToBase64Settings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processing, setProcessing, setResults, reset } = useBase64Store();
|
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
||||||
|
|
||||||
const [outputFormat, setOutputFormat] = useState("original");
|
const [outputFormat, setOutputFormat] = useState("original");
|
||||||
const [quality, setQuality] = useState(80);
|
const [quality, setQuality] = useState(80);
|
||||||
@@ -27,13 +27,18 @@ export function ImageToBase64Settings() {
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
reset();
|
reset();
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
|
const settings = JSON.stringify({ outputFormat, quality, maxWidth, maxHeight });
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
setProgress({ completed: i, total: files.length, currentFile: file.name });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of files) {
|
|
||||||
formData.append("files", file);
|
formData.append("files", file);
|
||||||
}
|
formData.append("settings", settings);
|
||||||
formData.append("settings", JSON.stringify({ outputFormat, quality, maxWidth, maxHeight }));
|
|
||||||
|
|
||||||
const res = await fetch("/api/v1/tools/image-to-base64", {
|
const res = await fetch("/api/v1/tools/image-to-base64", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -43,16 +48,23 @@ export function ImageToBase64Settings() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error || `Failed: ${res.status}`);
|
addError({ filename: file.name, error: body.error || `Failed: ${res.status}` });
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setResults(data.results, data.errors);
|
for (const r of data.results) addResult(r);
|
||||||
|
for (const e of data.errors) addError(e);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to convert");
|
addError({
|
||||||
} finally {
|
filename: file.name,
|
||||||
setProcessing(false);
|
error: err instanceof Error ? err.message : "Failed to convert",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(null);
|
||||||
|
setProcessing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = files.length > 0;
|
const hasFiles = files.length > 0;
|
||||||
|
|||||||
@@ -17,14 +17,24 @@ export interface Base64Error {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Base64Progress {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
currentFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Base64State {
|
interface Base64State {
|
||||||
results: Base64Result[];
|
results: Base64Result[];
|
||||||
errors: Base64Error[];
|
errors: Base64Error[];
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
|
progress: Base64Progress | null;
|
||||||
expandedIndex: number;
|
expandedIndex: number;
|
||||||
|
|
||||||
setResults: (results: Base64Result[], errors: Base64Error[]) => void;
|
setResults: (results: Base64Result[], errors: Base64Error[]) => void;
|
||||||
setProcessing: (v: boolean) => void;
|
setProcessing: (v: boolean) => void;
|
||||||
|
setProgress: (p: Base64Progress | null) => void;
|
||||||
|
addResult: (result: Base64Result) => void;
|
||||||
|
addError: (error: Base64Error) => void;
|
||||||
setExpandedIndex: (i: number) => void;
|
setExpandedIndex: (i: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -33,10 +43,15 @@ export const useBase64Store = create<Base64State>((set) => ({
|
|||||||
results: [],
|
results: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
processing: false,
|
processing: false,
|
||||||
|
progress: null,
|
||||||
expandedIndex: 0,
|
expandedIndex: 0,
|
||||||
|
|
||||||
setResults: (results, errors) => set({ results, errors, expandedIndex: 0 }),
|
setResults: (results, errors) => set({ results, errors, expandedIndex: 0 }),
|
||||||
setProcessing: (v) => set({ processing: v }),
|
setProcessing: (v) => set({ processing: v }),
|
||||||
|
setProgress: (p) => set({ progress: p }),
|
||||||
|
addResult: (result) => set((s) => ({ results: [...s.results, result] })),
|
||||||
|
addError: (error) => set((s) => ({ errors: [...s.errors, error] })),
|
||||||
setExpandedIndex: (i) => set({ expandedIndex: i }),
|
setExpandedIndex: (i) => set({ expandedIndex: i }),
|
||||||
reset: () => set({ results: [], errors: [], processing: false, expandedIndex: 0 }),
|
reset: () =>
|
||||||
|
set({ results: [], errors: [], processing: false, progress: null, expandedIndex: 0 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user