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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Workflow,
|
||||||
|
Trash2,
|
||||||
|
Play,
|
||||||
|
Zap,
|
||||||
|
ShieldOff,
|
||||||
|
Globe,
|
||||||
|
User,
|
||||||
|
Stamp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
|
import {
|
||||||
|
PipelineBuilder,
|
||||||
|
type PipelineStep,
|
||||||
|
} from "@/components/tools/pipeline-builder";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Pipeline template definition. */
|
||||||
|
interface PipelineTemplate {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
color: string;
|
||||||
|
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATES: PipelineTemplate[] = [
|
||||||
|
{
|
||||||
|
name: "Social Media Ready",
|
||||||
|
description: "Resize 1080x1080, compress 200KB, strip metadata, convert to WebP",
|
||||||
|
icon: Globe,
|
||||||
|
color: "bg-blue-500/10 text-blue-500",
|
||||||
|
steps: [
|
||||||
|
{ toolId: "resize", settings: { width: 1080, height: 1080, fit: "cover" } },
|
||||||
|
{ toolId: "compress", settings: { quality: 80 } },
|
||||||
|
{ toolId: "strip-metadata", settings: {} },
|
||||||
|
{ toolId: "convert", settings: { format: "webp" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Privacy Clean",
|
||||||
|
description: "Strip all metadata and convert to JPG",
|
||||||
|
icon: ShieldOff,
|
||||||
|
color: "bg-green-500/10 text-green-500",
|
||||||
|
steps: [
|
||||||
|
{ toolId: "strip-metadata", settings: {} },
|
||||||
|
{ toolId: "convert", settings: { format: "jpg" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Web Optimization",
|
||||||
|
description: "Resize to 1920px, convert to WebP, compress to 80% quality",
|
||||||
|
icon: Zap,
|
||||||
|
color: "bg-yellow-500/10 text-yellow-500",
|
||||||
|
steps: [
|
||||||
|
{ toolId: "resize", settings: { width: 1920, fit: "inside" } },
|
||||||
|
{ toolId: "convert", settings: { format: "webp" } },
|
||||||
|
{ toolId: "compress", settings: { quality: 80 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Profile Picture",
|
||||||
|
description: "Resize to 400x400 and compress",
|
||||||
|
icon: User,
|
||||||
|
color: "bg-purple-500/10 text-purple-500",
|
||||||
|
steps: [
|
||||||
|
{ toolId: "resize", settings: { width: 400, height: 400, fit: "cover" } },
|
||||||
|
{ toolId: "compress", settings: { quality: 85 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Watermark Batch",
|
||||||
|
description: "Add text watermark, strip metadata, and compress",
|
||||||
|
icon: Stamp,
|
||||||
|
color: "bg-red-500/10 text-red-500",
|
||||||
|
steps: [
|
||||||
|
{ toolId: "watermark-text", settings: { text: "SAMPLE", opacity: 0.3 } },
|
||||||
|
{ toolId: "strip-metadata", settings: {} },
|
||||||
|
{ toolId: "compress", settings: { quality: 85 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SavedPipeline {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
steps: Array<{ toolId: string; settings: Record<string, unknown> }>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
return localStorage.getItem("stirling-token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomatePage() {
|
||||||
|
const [steps, setSteps] = useState<PipelineStep[]>([]);
|
||||||
|
const [savedPipelines, setSavedPipelines] = useState<SavedPipeline[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [executionResult, setExecutionResult] = useState<{
|
||||||
|
downloadUrl: string;
|
||||||
|
originalSize: number;
|
||||||
|
processedSize: number;
|
||||||
|
stepsCompleted: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Load saved pipelines
|
||||||
|
const loadPipelines = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/pipeline/list", {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSavedPipelines(data.pipelines || []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail — the list just won't show
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPipelines();
|
||||||
|
}, [loadPipelines]);
|
||||||
|
|
||||||
|
// Save pipeline
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (name: string, description: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/pipeline/save", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${getToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await loadPipelines();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handling could be added
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[steps, loadPipelines]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete pipeline
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/v1/pipeline/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
await loadPipelines();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadPipelines]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute pipeline
|
||||||
|
const handleExecute = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
setExecuting(true);
|
||||||
|
setExecutionResult(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 res = await fetch("/api/v1/pipeline/execute", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setExecutionResult({
|
||||||
|
downloadUrl: data.downloadUrl,
|
||||||
|
originalSize: data.originalSize,
|
||||||
|
processedSize: data.processedSize,
|
||||||
|
stepsCompleted: data.stepsCompleted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handling
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[steps]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load template into builder
|
||||||
|
const loadTemplate = useCallback(
|
||||||
|
(template: PipelineTemplate) => {
|
||||||
|
const newSteps: PipelineStep[] = template.steps.map((s) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
toolId: s.toolId,
|
||||||
|
settings: { ...s.settings },
|
||||||
|
}));
|
||||||
|
setSteps(newSteps);
|
||||||
|
setExecutionResult(null);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load saved pipeline into builder
|
||||||
|
const loadSaved = useCallback((pipeline: SavedPipeline) => {
|
||||||
|
const newSteps: PipelineStep[] = pipeline.steps.map((s) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
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: templates + saved */}
|
||||||
|
<div className="w-72 border-r border-border overflow-y-auto p-4 space-y-6 shrink-0 hidden md:block">
|
||||||
|
{/* Templates */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||||
|
Templates
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{TEMPLATES.map((tpl) => (
|
||||||
|
<button
|
||||||
|
key={tpl.name}
|
||||||
|
onClick={() => loadTemplate(tpl)}
|
||||||
|
className="w-full text-left p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className={cn("p-1.5 rounded-md", tpl.color)}>
|
||||||
|
<tpl.icon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{tpl.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{tpl.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved automations */}
|
||||||
|
{savedPipelines.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||||
|
Saved Automations
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
onClick={() => loadSaved(pipeline)}
|
||||||
|
className="text-sm font-medium text-foreground hover:text-primary flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
{pipeline.name}
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PipelineBuilder
|
||||||
|
steps={steps}
|
||||||
|
onStepsChange={setSteps}
|
||||||
|
onSave={handleSave}
|
||||||
|
onExecute={handleExecute}
|
||||||
|
saving={saving}
|
||||||
|
executing={executing}
|
||||||
|
executionResult={executionResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user