mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): add pipeline builder UI with saved automations and templates
Create Automate page at /automate with pipeline builder component. Includes 5 preset templates (Social Media Ready, Privacy Clean, Web Optimization, Profile Picture, Watermark Batch), saved automation management, step reordering, and pipeline execution with download.
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Save,
|
||||
Upload,
|
||||
Loader2,
|
||||
FileImage,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import * as icons from "lucide-react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
|
||||
const PIPELINE_TOOLS = TOOLS.filter(
|
||||
(t) =>
|
||||
!["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id)
|
||||
);
|
||||
|
||||
export interface PipelineStep {
|
||||
id: string;
|
||||
toolId: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PipelineBuilderProps {
|
||||
steps: PipelineStep[];
|
||||
onStepsChange: (steps: PipelineStep[]) => void;
|
||||
onSave: (name: string, description: string) => void;
|
||||
onExecute: (file: File) => void;
|
||||
saving?: boolean;
|
||||
executing?: boolean;
|
||||
executionResult?: {
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
stepsCompleted: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function PipelineBuilder({
|
||||
steps,
|
||||
onStepsChange,
|
||||
onSave,
|
||||
onExecute,
|
||||
saving = false,
|
||||
executing = false,
|
||||
executionResult = null,
|
||||
}: PipelineBuilderProps) {
|
||||
const [showToolPicker, setShowToolPicker] = useState(false);
|
||||
const [expandedStep, setExpandedStep] = useState<string | null>(null);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const addStep = useCallback(
|
||||
(toolId: string) => {
|
||||
const step: PipelineStep = {
|
||||
id: crypto.randomUUID(),
|
||||
toolId,
|
||||
settings: {},
|
||||
};
|
||||
onStepsChange([...steps, step]);
|
||||
setShowToolPicker(false);
|
||||
setExpandedStep(step.id);
|
||||
},
|
||||
[steps, onStepsChange]
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(id: string) => {
|
||||
onStepsChange(steps.filter((s) => s.id !== id));
|
||||
if (expandedStep === id) setExpandedStep(null);
|
||||
},
|
||||
[steps, onStepsChange, expandedStep]
|
||||
);
|
||||
|
||||
const moveStep = useCallback(
|
||||
(id: string, direction: "up" | "down") => {
|
||||
const idx = steps.findIndex((s) => s.id === id);
|
||||
if (idx < 0) return;
|
||||
const newIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (newIdx < 0 || newIdx >= steps.length) return;
|
||||
const newSteps = [...steps];
|
||||
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
|
||||
onStepsChange(newSteps);
|
||||
},
|
||||
[steps, onStepsChange]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
input.click();
|
||||
}, []);
|
||||
|
||||
const handleFileDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!saveName.trim()) return;
|
||||
onSave(saveName.trim(), saveDescription.trim());
|
||||
setSaveName("");
|
||||
setSaveDescription("");
|
||||
setShowSaveForm(false);
|
||||
}, [saveName, saveDescription, onSave]);
|
||||
|
||||
const handleExecute = useCallback(() => {
|
||||
if (!file) return;
|
||||
onExecute(file);
|
||||
}, [file, onExecute]);
|
||||
|
||||
const iconsMap = icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* File Upload Area */}
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleFileDrop}
|
||||
className={cn(
|
||||
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
|
||||
file
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border bg-muted/20 hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">{file.name}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({(file.size / 1024).toFixed(0)} KB)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFile(null)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleFileSelect}
|
||||
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload image to process
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Steps */}
|
||||
<div className="space-y-2">
|
||||
{steps.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
Add steps to build your automation pipeline
|
||||
</div>
|
||||
) : (
|
||||
steps.map((step, idx) => {
|
||||
const tool = TOOLS.find((t) => t.id === step.toolId);
|
||||
if (!tool) return null;
|
||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="rounded-lg border border-border bg-background overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{/* Step number */}
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
|
||||
{/* Tool icon + name */}
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground flex-1">
|
||||
{tool.name}
|
||||
</span>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Settings"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveStep(step.id, "up")}
|
||||
disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveStep(step.id, "down")}
|
||||
disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded settings */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-3 bg-muted/10">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Default settings will be used. Configure via the API for advanced options.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Step */}
|
||||
{showToolPicker ? (
|
||||
<div className="rounded-lg border border-border bg-background p-3 space-y-2 max-h-64 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-foreground">Add a step</span>
|
||||
<button
|
||||
onClick={() => setShowToolPicker(false)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{PIPELINE_TOOLS.map((tool) => {
|
||||
const Icon = iconsMap[tool.icon] || icons.FileImage;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => addStep(tool.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-foreground">{tool.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowToolPicker(true)}
|
||||
className="flex items-center gap-2 w-full justify-center px-4 py-2.5 rounded-lg border border-dashed border-border text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Step
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Execution result */}
|
||||
{executionResult && (
|
||||
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-green-700 dark:text-green-400">
|
||||
<icons.CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium text-sm">
|
||||
Pipeline completed ({executionResult.stepsCompleted} steps)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Original: {(executionResult.originalSize / 1024).toFixed(0)} KB</span>
|
||||
<span>Processed: {(executionResult.processedSize / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
<a
|
||||
href={executionResult.downloadUrl}
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Result
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={steps.length === 0 || !file || executing}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!showSaveForm ? (
|
||||
<button
|
||||
onClick={() => setShowSaveForm(true)}
|
||||
disabled={steps.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Pipeline
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={saveName}
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
placeholder="Pipeline name"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={saveDescription}
|
||||
onChange={(e) => setSaveDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!saveName.trim() || saving}
|
||||
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSaveForm(false)}
|
||||
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user