mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: redesign Automate page layout — tool palette + pipeline canvas
Restructure the pipeline builder UX from a cramped sidebar to a spacious two-pane layout: - Left pane: categorized, searchable Tool Palette (click-to-add) - Right pane: Pipeline Canvas with sortable steps, inline action bar, and collapsible preview panel with before/after slider - Mobile: full-screen canvas with FAB + bottom sheet for tool selection, image preview, and thumbnail strip (previously hidden entirely) - Fix nested <button> HTML violation in SortableStep - Fix stray semicolon rendering in step cards
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
Layers,
|
||||
Play,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Workflow,
|
||||
@@ -17,9 +21,12 @@ import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { PipelineBuilder } from "@/components/tools/pipeline-builder";
|
||||
import { ToolPalette } from "@/components/tools/tool-palette";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
|
||||
|
||||
@@ -59,13 +66,15 @@ export function AutomatePage() {
|
||||
} = usePipelineStore();
|
||||
|
||||
const { processSingle, processAll, processing, error, progress } = usePipelineProcessor();
|
||||
const isMobile = useMobile();
|
||||
|
||||
// Local state
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showAllSaved, setShowAllSaved] = useState(false);
|
||||
const [previewCollapsed, setPreviewCollapsed] = useState(false);
|
||||
const [mobileToolPaletteOpen, setMobileToolPaletteOpen] = useState(false);
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasProcessed = !!processedUrl;
|
||||
@@ -73,7 +82,6 @@ export function AutomatePage() {
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
// Load saved pipelines on mount
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -133,7 +141,6 @@ export function AutomatePage() {
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Refresh saved pipelines
|
||||
const listRes = await fetch("/api/v1/pipeline/list", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
@@ -203,32 +210,238 @@ export function AutomatePage() {
|
||||
[navigateNext, navigatePrev],
|
||||
);
|
||||
|
||||
const handleAddStep = useCallback(
|
||||
(toolId: string) => {
|
||||
addStep(toolId);
|
||||
if (isMobile) setMobileToolPaletteOpen(false);
|
||||
},
|
||||
[addStep, isMobile],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mobile Layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
||||
<Workflow className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold text-foreground flex-1">Automate</h1>
|
||||
{hasFile && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{files.length} file{files.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile pipeline steps */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{!hasFile && (
|
||||
<div className="mb-4">
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && (
|
||||
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-muted/50">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<span className="text-sm text-foreground truncate flex-1">
|
||||
{selectedFileName ?? files[0].name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMore}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile image preview / result */}
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<div className="mb-3 rounded-lg border border-border overflow-hidden">
|
||||
<div className="relative h-48">
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
{processedSize != null && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground">
|
||||
<span className="truncate">{selectedFileName ?? files[0].name}</span>
|
||||
<span>
|
||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
|
||||
<div className="mb-3 rounded-lg border border-border overflow-hidden h-40 flex items-center justify-center bg-muted/20">
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="mb-3 rounded-lg border border-border p-3 text-center">
|
||||
<p className="text-sm text-red-500">
|
||||
{currentEntry.error ?? "Processing failed for this file"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMultiple && (
|
||||
<div className="mb-3">
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
expandedStepId={expandedStepId}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
onToggleStep={setExpandedStep}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 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>
|
||||
)}
|
||||
|
||||
{processing && (
|
||||
<div className="mt-3">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile action bar */}
|
||||
<div className="p-3 border-t border-border shrink-0 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || steps.length === 0 || processing}
|
||||
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process (${files.length})`}
|
||||
</button>
|
||||
{hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="px-4 py-2.5 rounded-lg border border-primary text-primary"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB for tool palette */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileToolPaletteOpen(true)}
|
||||
className="fixed bottom-24 right-4 z-20 w-14 h-14 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center hover:bg-primary/90 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Mobile tool palette bottom sheet */}
|
||||
{mobileToolPaletteOpen && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileToolPaletteOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70vh] flex flex-col animate-in slide-in-from-bottom">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Add Tool</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileToolPaletteOpen(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Desktop Layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
{/* LEFT PANEL */}
|
||||
<div className="w-72 border-r border-border flex flex-col shrink-0 hidden md:flex">
|
||||
{/* Header */}
|
||||
{/* LEFT PANE — Tool Palette */}
|
||||
<div className="w-72 border-r border-border flex flex-col shrink-0">
|
||||
{/* Palette 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" />
|
||||
<Layers className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Automate</h1>
|
||||
<p className="text-xs text-muted-foreground">Chain tools into a pipeline</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">Tool Palette</h2>
|
||||
<p className="text-xs text-muted-foreground">Click to add to pipeline</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved pipelines strip */}
|
||||
{/* Tool catalog */}
|
||||
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Saved pipelines */}
|
||||
{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
|
||||
<div className="px-3 py-2 border-t border-border shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1.5">
|
||||
Saved
|
||||
</h3>
|
||||
{showAllSaved ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto">
|
||||
{savedPipelines.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-1.5 group">
|
||||
<div key={p.id} className="flex items-center gap-1 group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPipeline(p)}
|
||||
@@ -257,13 +470,13 @@ export function AutomatePage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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]"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors truncate max-w-[110px]"
|
||||
>
|
||||
<Play className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
@@ -273,7 +486,7 @@ export function AutomatePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSaved(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5"
|
||||
>
|
||||
+{savedPipelines.length - 3} more
|
||||
</button>
|
||||
@@ -282,153 +495,153 @@ export function AutomatePage() {
|
||||
)}
|
||||
</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
|
||||
{/* RIGHT PANE — Pipeline Canvas + Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Canvas header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b border-border shrink-0">
|
||||
<Workflow className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">Pipeline Builder</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{steps.length === 0
|
||||
? "Add tools from the palette to get started"
|
||||
: `${steps.length} step${steps.length !== 1 ? "s" : ""} configured`}
|
||||
</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" : ""}
|
||||
</div>
|
||||
|
||||
{/* File badge */}
|
||||
{hasFile ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs bg-muted rounded-full px-3 py-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-foreground max-w-[120px] truncate">
|
||||
{files.length > 1
|
||||
? `${files.length} files`
|
||||
: (selectedFileName ?? files[0].name)}
|
||||
</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">
|
||||
<span className="text-muted-foreground">
|
||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMore}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic shrink-0">No files loaded</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Canvas body */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* Pipeline steps area */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 min-h-0">
|
||||
{error && (
|
||||
<div className="mb-3 text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2 flex items-start gap-1.5">
|
||||
<X className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</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}
|
||||
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}
|
||||
<PipelineBuilder
|
||||
steps={steps}
|
||||
expandedStepId={expandedStepId}
|
||||
onRemoveStep={removeStep}
|
||||
onReorderSteps={reorderSteps}
|
||||
onUpdateSettings={updateStepSettings}
|
||||
onToggleStep={setExpandedStep}
|
||||
/>
|
||||
|
||||
{processing && (
|
||||
<div className="mt-4">
|
||||
<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>
|
||||
)}
|
||||
</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 && (
|
||||
{/* Action bar */}
|
||||
<div className="px-5 py-3 border-t border-border shrink-0 flex items-center gap-2">
|
||||
<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"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || steps.length === 0 || processing}
|
||||
className="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download All (ZIP)
|
||||
<Play className="h-4 w-4" />
|
||||
{files.length <= 1 ? "Process" : `Process All (${files.length})`}
|
||||
</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>
|
||||
)}
|
||||
{hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download ZIP
|
||||
</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">
|
||||
<span className="flex-1" />
|
||||
|
||||
{steps.length > 0 && !showSaveForm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
className="px-4 py-2 rounded-lg border border-border text-muted-foreground font-medium flex items-center gap-2 hover:bg-muted hover:text-foreground text-sm"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSaveForm && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description"
|
||||
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
|
||||
/>
|
||||
<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"
|
||||
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? "..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -437,106 +650,127 @@ export function AutomatePage() {
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
}}
|
||||
className="px-3 py-1.5 rounded border border-border text-sm text-muted-foreground hover:bg-muted"
|
||||
className="px-2 py-1.5 rounded text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</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 && (
|
||||
{/* Preview panel (collapsible) */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-t border-border shrink-0 flex flex-col transition-all",
|
||||
previewCollapsed ? "h-10" : "h-[38%] min-h-[200px]",
|
||||
)}
|
||||
>
|
||||
{/* Preview header with toggle */}
|
||||
<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"
|
||||
onClick={() => setPreviewCollapsed(!previewCollapsed)}
|
||||
className="flex items-center gap-2 px-5 py-2 shrink-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
{previewCollapsed ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Preview
|
||||
</span>
|
||||
{hasFile && hasProcessed && processedSize != null && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatFileSize(originalSize ?? 0)} → {formatFileSize(processedSize)}
|
||||
</span>
|
||||
)}
|
||||
{!hasProcessed && <span>{formatFileSize(selectedFileSize ?? files[0].size)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Thumbnail strip for multi-file */}
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{/* Preview content */}
|
||||
{!previewCollapsed && (
|
||||
<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 px-6 py-2 min-h-0">
|
||||
{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-2 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>
|
||||
)}
|
||||
|
||||
{!hasFile && (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 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>
|
||||
|
||||
{hasMultiple && (
|
||||
<ThumbnailStrip
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user