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:
Siddharth Kumar Sah
2026-03-22 04:41:57 +08:00
parent 263447a81e
commit 60541c1765
2 changed files with 731 additions and 0 deletions
@@ -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>
);
}
+346
View File
@@ -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>
);
}