mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: SOTA overhaul of automate pipeline page (#53)
* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails * feat(find-duplicates): add custom-results display mode and duplicate store * feat(find-duplicates): add results overview grid and detail comparison view * feat(find-duplicates): overhaul settings with sensitivity presets and download actions * feat(find-duplicates): update i18n description * chore: replace jsqr with zxing-wasm for barcode reading * feat(barcode-read): rewrite backend with zxing-wasm for all barcode types * feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n * feat(stitch): update tool name and description for redesign * feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes * feat(stitch): redesign settings UI with grid, alignment, border, radius, quality * test(stitch): add stitch to e2e tool navigation suite * feat(vectorize): redesign with dual-engine backend and preset-driven UI - Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization - Frontend: 5 presets (logo, illustration, photo, sketch, custom) - Settings: color precision, gradient step, detail, smoothing, corner threshold, invert - Updated OpenAPI spec and i18n description * feat(border): redesign with presets, shadow, padding color, swatches - Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic) - Implement proper shadow rendering with blur, offset X/Y, color, opacity - Add padding color control (was hardcoded white) - Add color swatches for quick color selection - Wrap in form for Enter key submission - Add smart validation (requires at least one effect active) - Align frontend/backend slider ranges - Organize UI with sections and collapsible shadow toggle * feat(split): overhaul image splitting with live grid overlay and tile preview - Add interactive-split display mode with SplitCanvas component - Live SVG grid overlay on uploaded image showing split boundaries - Two split modes: Grid (NxM) and Tile Size (px dimensions) - 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4) - Output format selection (original/PNG/JPG/WebP) with quality slider - Post-split tile preview thumbnails with individual download - Download All as ZIP button - HEIC/HEIF preview with loading spinner - Backend: tile-size mode, output format conversion, quality control - Zustand store for split state management * feat(split): rewrite backend and frontend settings Backend: tile-size mode, output format conversion, quality control. Frontend: split modes, presets, format selector, tile preview grid. * feat(border): add live CSS preview and remove before/after slider - Add imageWrapperStyle prop to ImageViewer for live border preview - Add onImageStyle callback through tool-page to settings components - Change border displayMode to no-comparison (no slider) - BorderControls sends live CSS styles (border, padding, radius, shadow) - Preview updates instantly as user adjusts sliders or clicks presets * fix: repair i18n file corrupted by formatter during merge conflict resolution * feat(border): enable live CSS preview in right pane as settings change * fix(border): keep CSS preview visible after processing for WYSIWYG consistency * chore: add @dnd-kit/core and @dnd-kit/sortable for pipeline drag-and-drop * feat(pipeline): add Zustand store for pipeline step management * feat(automate): add pipeline step settings summary utility with tests * feat(automate): add POST /api/v1/pipeline/batch for multi-file pipeline execution * feat(automate): add usePipelineProcessor hook for single and batch pipeline execution * fix(automate): pass settings prop to all pipeline step controls for state restoration * feat(automate): rewrite pipeline builder with dnd-kit drag-and-drop and compact step cards * feat(automate): rewrite page with two-panel layout, image preview, and batch support * test(automate): update e2e tests for new two-panel pipeline layout --------- Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
co-authored by
Siddharth Kumar Sah
parent
a1e11dff74
commit
fb33a46a64
@@ -1,226 +1,542 @@
|
||||
import { Play, Trash2, Workflow } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Play,
|
||||
Save,
|
||||
Trash2,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { PipelineBuilder, type PipelineStep } from "@/components/tools/pipeline-builder";
|
||||
import { PipelineBuilder } from "@/components/tools/pipeline-builder";
|
||||
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
|
||||
|
||||
interface SavedPipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||
createdAt: string;
|
||||
}
|
||||
export function AutomatePage() {
|
||||
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
setFiles,
|
||||
addFiles,
|
||||
reset: resetFiles,
|
||||
processedUrl,
|
||||
originalBlobUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
selectedFileName,
|
||||
selectedFileSize,
|
||||
batchZipBlob,
|
||||
batchZipFilename,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
navigateNext,
|
||||
navigatePrev,
|
||||
currentEntry,
|
||||
} = useFileStore();
|
||||
|
||||
const {
|
||||
steps,
|
||||
expandedStepId,
|
||||
savedPipelines,
|
||||
addStep,
|
||||
removeStep,
|
||||
reorderSteps,
|
||||
updateStepSettings,
|
||||
setExpandedStep,
|
||||
loadSteps,
|
||||
setSavedPipelines,
|
||||
} = usePipelineStore();
|
||||
|
||||
const { processSingle, processAll, processing, error, progress } = usePipelineProcessor();
|
||||
|
||||
// Local state
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [executionResult, setExecutionResult] = useState<{
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
stepsCompleted: number;
|
||||
} | null>(null);
|
||||
const [executionError, setExecutionError] = useState<string | null>(null);
|
||||
const [showAllSaved, setShowAllSaved] = useState(false);
|
||||
|
||||
// Load saved pipelines
|
||||
const loadPipelines = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — the list just won't show
|
||||
}
|
||||
}, []);
|
||||
const hasFile = files.length > 0;
|
||||
const hasProcessed = !!processedUrl;
|
||||
const hasMultiple = entries.length > 1;
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
// Load saved pipelines on mount
|
||||
useEffect(() => {
|
||||
loadPipelines();
|
||||
}, [loadPipelines]);
|
||||
|
||||
// Save pipeline
|
||||
const handleSave = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
setSaving(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/save", {
|
||||
method: "POST",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
}),
|
||||
const res = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadPipelines();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setExecutionError(data.error || "Failed to save pipeline");
|
||||
const data = await res.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
setExecutionError("Connection error while saving pipeline.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
// Silently fail
|
||||
}
|
||||
})();
|
||||
}, [setSavedPipelines]);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
resetFiles();
|
||||
setFiles(newFiles);
|
||||
},
|
||||
[steps, loadPipelines],
|
||||
[setFiles, resetFiles],
|
||||
);
|
||||
|
||||
// Delete pipeline
|
||||
const handleDelete = useCallback(
|
||||
const handleAddMore = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = "image/*,.heic,.heif,.hif";
|
||||
input.onchange = (e) => {
|
||||
const picked = Array.from((e.target as HTMLInputElement).files || []);
|
||||
if (picked.length > 0) addFiles(picked);
|
||||
};
|
||||
input.click();
|
||||
}, [addFiles]);
|
||||
|
||||
const handleProcess = useCallback(() => {
|
||||
if (files.length === 0 || steps.length === 0) return;
|
||||
if (files.length === 1) {
|
||||
processSingle(files[0], steps);
|
||||
} else {
|
||||
processAll(files, steps);
|
||||
}
|
||||
}, [files, steps, processSingle, processAll]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveName.trim() || steps.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/pipeline/save", {
|
||||
method: "POST",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
name: saveName.trim(),
|
||||
description: saveDescription.trim() || undefined,
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Refresh saved pipelines
|
||||
const listRes = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
setShowSaveForm(false);
|
||||
}
|
||||
} catch {
|
||||
// Save failed silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [saveName, saveDescription, steps, setSavedPipelines]);
|
||||
|
||||
const handleDeletePipeline = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/v1/pipeline/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
await loadPipelines();
|
||||
const listRes = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
setSavedPipelines(data.pipelines || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadPipelines],
|
||||
[setSavedPipelines],
|
||||
);
|
||||
|
||||
// Execute pipeline
|
||||
const handleExecute = useCallback(
|
||||
async (file: File) => {
|
||||
setExecuting(true);
|
||||
setExecutionResult(null);
|
||||
setExecutionError(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append(
|
||||
"pipeline",
|
||||
JSON.stringify({
|
||||
steps: steps.map((s) => ({
|
||||
toolId: s.toolId,
|
||||
settings: s.settings,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const handleLoadPipeline = useCallback(
|
||||
(pipeline: SavedPipeline) => {
|
||||
loadSteps(pipeline.steps);
|
||||
},
|
||||
[loadSteps],
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/pipeline/execute", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
const handleDownloadAll = useCallback(() => {
|
||||
if (!batchZipBlob) return;
|
||||
const url = URL.createObjectURL(batchZipBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = batchZipFilename ?? "batch-pipeline.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [batchZipBlob, batchZipFilename]);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setExecutionResult({
|
||||
downloadUrl: data.downloadUrl,
|
||||
originalSize: data.originalSize,
|
||||
processedSize: data.processedSize,
|
||||
stepsCompleted: data.stepsCompleted,
|
||||
});
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setExecutionError(data.error || `Pipeline failed with status ${res.status}`);
|
||||
}
|
||||
} catch {
|
||||
setExecutionError("Connection error. Please try again.");
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
const handleImageKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
navigatePrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
navigateNext();
|
||||
}
|
||||
},
|
||||
[steps],
|
||||
[navigateNext, navigatePrev],
|
||||
);
|
||||
|
||||
// Load saved pipeline into builder
|
||||
const loadSaved = useCallback((pipeline: SavedPipeline) => {
|
||||
const newSteps: PipelineStep[] = pipeline.steps.map((s) => ({
|
||||
id: generateId(),
|
||||
toolId: s.toolId,
|
||||
settings: { ...s.settings },
|
||||
}));
|
||||
setSteps(newSteps);
|
||||
setExecutionResult(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
{/* Left sidebar: saved automations */}
|
||||
{savedPipelines.length > 0 && (
|
||||
<div className="w-72 border-r border-border overflow-y-auto p-4 space-y-6 shrink-0 hidden md:block">
|
||||
{/* LEFT PANEL */}
|
||||
<div className="w-72 border-r border-border flex flex-col shrink-0 hidden md:flex">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||
Saved Automations
|
||||
<h1 className="text-lg font-semibold text-foreground">Automate</h1>
|
||||
<p className="text-xs text-muted-foreground">Chain tools into a pipeline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved pipelines strip */}
|
||||
{savedPipelines.length > 0 && (
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Saved Pipelines
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{savedPipelines.map((pipeline) => (
|
||||
<div
|
||||
key={pipeline.id}
|
||||
className="p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{showAllSaved ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{savedPipelines.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-1.5 group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadSaved(pipeline)}
|
||||
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{pipeline.name}
|
||||
{p.name}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(pipeline.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
|
||||
onClick={() => handleDeletePipeline(p.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{pipeline.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{pipeline.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{pipeline.steps.length} step{pipeline.steps.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSaved(false)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground mt-1"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedPipelines.slice(0, 3).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[120px]"
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{savedPipelines.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSaved(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
>
|
||||
+{savedPipelines.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-border shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Files
|
||||
</h3>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Drop or upload images to get started
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMore}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
+ Add more
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-1">
|
||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main builder area */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Automation Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chain multiple tools into a single workflow
|
||||
</p>
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="px-4 pt-2 shrink-0">
|
||||
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded px-2 py-1.5 flex items-start gap-1.5">
|
||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline steps (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3 pb-2">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||
Pipeline Steps
|
||||
</h3>
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
onStepsChange={setSteps}
|
||||
onSave={handleSave}
|
||||
onExecute={handleExecute}
|
||||
saving={saving}
|
||||
executing={executing}
|
||||
executionResult={executionResult}
|
||||
executionError={executionError}
|
||||
expandedStepId={expandedStepId}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
onToggleStep={setExpandedStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress card */}
|
||||
{processing && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<ProgressCard
|
||||
active={progress.phase !== "idle"}
|
||||
phase={progress.phase === "idle" ? "processing" : progress.phase}
|
||||
label={
|
||||
files.length > 1
|
||||
? `Processing ${files.length} files...`
|
||||
: "Processing pipeline..."
|
||||
}
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons (sticky bottom) */}
|
||||
<div className="p-4 border-t border-border space-y-2 shrink-0">
|
||||
{/* Process button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || steps.length === 0 || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process All (${files.length} files)`}
|
||||
</button>
|
||||
|
||||
{/* Download All ZIP */}
|
||||
{hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download All (ZIP)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save pipeline */}
|
||||
{steps.length > 0 && !showSaveForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
className="w-full py-2 rounded-lg border border-border text-muted-foreground font-medium flex items-center justify-center gap-2 hover:bg-muted hover:text-foreground text-sm"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Pipeline
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSaveForm && (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3 bg-muted/30">
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="flex-1 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSaveForm(false);
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded border border-border text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL */}
|
||||
<section
|
||||
aria-label="Image area"
|
||||
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
|
||||
{/* Nav arrows */}
|
||||
{hasMultiple && hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={navigatePrev}
|
||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={navigateNext}
|
||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
|
||||
{selectedIndex + 1} / {entries.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image display area */}
|
||||
{!hasFile && (
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
{hasFile && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
|
||||
<span className="truncate mr-2">{selectedFileName ?? files[0].name}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{hasProcessed && processedSize != null && (
|
||||
<span>
|
||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||
</span>
|
||||
)}
|
||||
{!hasProcessed && <span>{formatFileSize(selectedFileSize ?? files[0].size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail strip for multi-file */}
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user