mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: copy-primary for data tools, download-all label for multi-output, batch failure display
This commit is contained in:
@@ -1,8 +1,35 @@
|
||||
import { ArrowLeft, CheckCircle2, Download } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Download, FileText } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatFileSize, triggerDownload } from "@/lib/download";
|
||||
import { format } from "@/lib/format";
|
||||
|
||||
/** Tools whose primary output is text/data, not a downloadable file. */
|
||||
const DATA_OUTPUT_TOOLS = new Set([
|
||||
"ocr",
|
||||
"barcode-read",
|
||||
"info",
|
||||
"histogram",
|
||||
"color-palette",
|
||||
"transcribe-audio",
|
||||
"extract-subtitles",
|
||||
"image-to-base64",
|
||||
"pdf-to-text",
|
||||
"pdf-metadata",
|
||||
"audio-metadata",
|
||||
"video-metadata",
|
||||
]);
|
||||
|
||||
/** Tools that produce multiple output files bundled as a ZIP. */
|
||||
const MULTI_OUTPUT_TOOLS = new Set([
|
||||
"split",
|
||||
"favicon",
|
||||
"pdf-to-image",
|
||||
"video-to-frames",
|
||||
"split-audio",
|
||||
"split-csv",
|
||||
]);
|
||||
|
||||
interface ReviewPanelProps {
|
||||
filename: string;
|
||||
@@ -13,6 +40,9 @@ interface ReviewPanelProps {
|
||||
onUndo: () => void;
|
||||
onStartOver: () => void;
|
||||
currentToolId: string;
|
||||
totalCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
}
|
||||
|
||||
export function ReviewPanel({
|
||||
@@ -23,10 +53,16 @@ export function ReviewPanel({
|
||||
downloadUrl,
|
||||
onUndo,
|
||||
onStartOver,
|
||||
currentToolId: _currentToolId,
|
||||
currentToolId,
|
||||
totalCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
}: ReviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isDataOutput = DATA_OUTPUT_TOOLS.has(currentToolId);
|
||||
const isMultiOutput = MULTI_OUTPUT_TOOLS.has(currentToolId);
|
||||
|
||||
const sizeDelta = useMemo(() => {
|
||||
if (!originalSize || originalSize === 0) return 0;
|
||||
return Math.round((1 - fileSize / originalSize) * 100);
|
||||
@@ -36,6 +72,9 @@ export function ReviewPanel({
|
||||
triggerDownload(downloadUrl, filename);
|
||||
};
|
||||
|
||||
const hasBatchStats =
|
||||
totalCount != null && totalCount > 1 && successCount != null && failedCount != null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-border" />
|
||||
@@ -46,8 +85,22 @@ export function ReviewPanel({
|
||||
<span className="text-sm font-medium text-foreground">{t.toolPage.conversionComplete}</span>
|
||||
</div>
|
||||
|
||||
{/* Size delta */}
|
||||
{originalSize > 0 && (
|
||||
{/* Batch partial failure summary */}
|
||||
{hasBatchStats && failedCount > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 p-2.5 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
||||
<span className="text-amber-800 dark:text-amber-300">
|
||||
{format(t.toolPage.batchPartialSuccess, {
|
||||
success: successCount,
|
||||
total: totalCount,
|
||||
failed: failedCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Size delta -- hidden for data-output tools */}
|
||||
{!isDataOutput && originalSize > 0 && (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t.toolPage.original}</span>
|
||||
@@ -78,16 +131,39 @@ export function ReviewPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download button with format + size */}
|
||||
<button
|
||||
type="button"
|
||||
data-download-button
|
||||
onClick={handleDownload}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2 hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.toolPage.download} {fileType} ({formatFileSize(fileSize)})
|
||||
</button>
|
||||
{/* Data-output tools: results hint + secondary download */}
|
||||
{isDataOutput && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/50 p-2.5 text-xs">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{t.toolPage.dataResultsHint}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="w-full text-center text-xs text-primary hover:text-primary/80 underline underline-offset-2"
|
||||
>
|
||||
{t.toolPage.downloadAsFile}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Download button -- primary for non-data tools */}
|
||||
{!isDataOutput && (
|
||||
<button
|
||||
type="button"
|
||||
data-download-button
|
||||
onClick={handleDownload}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2 hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{isMultiOutput
|
||||
? `${t.toolPage.downloadAll} (ZIP, ${formatFileSize(fileSize)})`
|
||||
: hasBatchStats && successCount != null && successCount > 1
|
||||
? `${format(t.toolPage.downloadFiles, { count: successCount })} (ZIP, ${formatFileSize(fileSize)})`
|
||||
: `${t.toolPage.download} ${fileType} (${formatFileSize(fileSize)})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Adjust settings */}
|
||||
<button
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Download,
|
||||
FileImage,
|
||||
Loader2,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
@@ -42,7 +44,7 @@ import { useBase64Store } from "@/stores/base64-store";
|
||||
import { useCollageStore } from "@/stores/collage-store";
|
||||
import { useDuplicateStore } from "@/stores/duplicate-store";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type FileEntry, useFileStore } from "@/stores/file-store";
|
||||
import { useHtmlToImageStore } from "@/stores/html-to-image-store";
|
||||
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
||||
import { useQrStore } from "@/stores/qr-store";
|
||||
@@ -84,15 +86,31 @@ function getFileFormat(name: string): string {
|
||||
|
||||
const COLLAPSED_LIMIT = 5;
|
||||
|
||||
/** Status icon for a file entry in the batch list. */
|
||||
function FileStatusIcon({ status }: { status: FileEntry["status"] }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-3 w-3 text-emerald-600 shrink-0" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-3 w-3 text-destructive shrink-0" />;
|
||||
case "processing":
|
||||
return <Loader2 className="h-3 w-3 text-primary shrink-0 animate-spin" />;
|
||||
default:
|
||||
return <Circle className="h-3 w-3 text-muted-foreground/40 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** File selection indicator shown in left panel */
|
||||
function FileSelectionInfo({
|
||||
files,
|
||||
fileEntries,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClear,
|
||||
onAddMore,
|
||||
}: {
|
||||
files: File[];
|
||||
fileEntries: FileEntry[];
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
onClear: () => void;
|
||||
@@ -107,6 +125,9 @@ function FileSelectionInfo({
|
||||
|
||||
const showToggle = files.length > COLLAPSED_LIMIT;
|
||||
const visible = expanded ? files : files.slice(0, COLLAPSED_LIMIT);
|
||||
const hasAnyProcessed = fileEntries.some(
|
||||
(e) => e.status === "completed" || e.status === "failed",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
@@ -124,6 +145,7 @@ function FileSelectionInfo({
|
||||
<div className="space-y-0.5">
|
||||
{visible.map((file, i) => {
|
||||
const isSelected = i === selectedIndex;
|
||||
const entry = fileEntries[i];
|
||||
return (
|
||||
<button
|
||||
key={`${file.name}-${i}`}
|
||||
@@ -131,7 +153,11 @@ function FileSelectionInfo({
|
||||
onClick={() => onSelect(i)}
|
||||
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
|
||||
{hasAnyProcessed && entry ? (
|
||||
<FileStatusIcon status={entry.status} />
|
||||
) : (
|
||||
isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 min-w-0">{file.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{getFileFormat(file.name)}
|
||||
@@ -914,6 +940,11 @@ export function ToolPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Batch stats for partial failure display
|
||||
const batchTotal = entries.length;
|
||||
const batchSuccess = entries.filter((e) => e.status === "completed").length;
|
||||
const batchFailed = entries.filter((e) => e.status === "failed").length;
|
||||
|
||||
// Render the settings panel content (shared between mobile/desktop)
|
||||
function renderSettingsContent() {
|
||||
return (
|
||||
@@ -922,6 +953,7 @@ export function ToolPage() {
|
||||
<div className="space-y-2">
|
||||
<FileSelectionInfo
|
||||
files={files}
|
||||
fileEntries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onClear={reset}
|
||||
@@ -962,6 +994,9 @@ export function ToolPage() {
|
||||
onUndo={handleUndo}
|
||||
onStartOver={startOver}
|
||||
currentToolId={tool?.id ?? ""}
|
||||
totalCount={batchTotal}
|
||||
successCount={batchSuccess}
|
||||
failedCount={batchFailed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user