feat: add pipeline import from JSON file

Add import button to saved pipelines section with file picker,
validation, and error display with auto-dismiss. Section now
always renders with empty state text when no pipelines exist.
This commit is contained in:
SnapOtter
2026-05-08 20:59:25 +08:00
parent e5939f7b5a
commit 7d7df194bc
+87 -4
View File
@@ -11,6 +11,7 @@ import {
Plus, Plus,
Save, Save,
Trash2, Trash2,
Upload,
Workflow, Workflow,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -82,6 +83,7 @@ export function AutomatePage() {
const [showAllSaved, setShowAllSaved] = useState(false); const [showAllSaved, setShowAllSaved] = useState(false);
const [previewCollapsed, setPreviewCollapsed] = useState(false); const [previewCollapsed, setPreviewCollapsed] = useState(false);
const [mobileToolPaletteOpen, setMobileToolPaletteOpen] = useState(false); const [mobileToolPaletteOpen, setMobileToolPaletteOpen] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const hasFile = files.length > 0; const hasFile = files.length > 0;
const hasProcessed = !!processedUrl; const hasProcessed = !!processedUrl;
@@ -258,6 +260,71 @@ export function AutomatePage() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, []); }, []);
const handleImportPipeline = useCallback(async () => {
setImportError(null);
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (data.format !== "snapotter-pipeline") {
setImportError("Not a valid SnapOtter pipeline file");
return;
}
if (typeof data.version !== "number" || data.version > 1) {
setImportError("This pipeline was created with a newer version of SnapOtter");
return;
}
if (!data.name || typeof data.name !== "string") {
setImportError("Pipeline file is missing a name");
return;
}
if (!Array.isArray(data.steps) || data.steps.length === 0) {
setImportError("Pipeline file has no steps");
return;
}
const res = await fetch("/api/v1/pipeline/save", {
method: "POST",
headers: formatHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
name: data.name,
description: data.description || undefined,
steps: data.steps,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Import failed" }));
setImportError(err.error || "Import failed");
return;
}
const listRes = await fetch("/api/v1/pipeline/list", {
headers: formatHeaders(),
});
if (listRes.ok) {
const listData = await listRes.json();
setSavedPipelines(listData.pipelines || []);
}
} catch {
setImportError("Could not read pipeline file");
}
};
input.click();
}, [setSavedPipelines]);
useEffect(() => {
if (!importError) return;
const timer = setTimeout(() => setImportError(null), 5000);
return () => clearTimeout(timer);
}, [importError]);
const handleDownloadAll = useCallback(() => { const handleDownloadAll = useCallback(() => {
if (!batchZipBlob) return; if (!batchZipBlob) return;
const url = URL.createObjectURL(batchZipBlob); const url = URL.createObjectURL(batchZipBlob);
@@ -524,12 +591,29 @@ export function AutomatePage() {
<ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" /> <ToolPalette onAddStep={handleAddStep} className="flex-1 min-h-0" />
{/* Saved pipelines */} {/* Saved pipelines */}
{savedPipelines.length > 0 && (
<div className="px-3 py-2 border-t border-border shrink-0"> <div className="px-3 py-2 border-t border-border shrink-0">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
Saved Saved
</h3> </h3>
{showAllSaved ? ( <button
type="button"
onClick={handleImportPipeline}
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 transition-colors"
>
<Upload className="h-3 w-3" />
Import
</button>
</div>
{importError && (
<div className="text-xs text-red-500 bg-red-50 dark:bg-red-950/30 rounded px-2 py-1.5 mb-1.5 flex items-start gap-1.5">
<X className="h-3 w-3 shrink-0 mt-0.5" />
<span>{importError}</span>
</div>
)}
{savedPipelines.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No saved pipelines</p>
) : showAllSaved ? (
<div className="space-y-1 max-h-36 overflow-y-auto"> <div className="space-y-1 max-h-36 overflow-y-auto">
{savedPipelines.map((p) => ( {savedPipelines.map((p) => (
<div key={p.id} className="flex items-center gap-1 group"> <div key={p.id} className="flex items-center gap-1 group">
@@ -592,7 +676,6 @@ export function AutomatePage() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
{/* RIGHT PANE — Pipeline Canvas + Preview */} {/* RIGHT PANE — Pipeline Canvas + Preview */}