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