mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|