diff --git a/app/(dashboard)/dashboard/flows/page.tsx b/app/(dashboard)/dashboard/flows/page.tsx index 5d0eefe2..437741ed 100644 --- a/app/(dashboard)/dashboard/flows/page.tsx +++ b/app/(dashboard)/dashboard/flows/page.tsx @@ -2,6 +2,7 @@ import { getWorkspace } from "@/lib/workspace"; import Link from "next/link"; import { GitBranch, Sparkles, Plug } from "lucide-react"; import { CreateFlowButton } from "@/components/create-flow-button"; +import { ImportFlowButton, ExportFlowButton } from "@/components/flow-actions"; import type { FlowStatus } from "@/lib/types/database"; const statusConfig: Record = { @@ -38,7 +39,7 @@ export default async function FlowsPage() { const [{ data: flows }, { count: channelCount }] = await Promise.all([ supabase .from("flows") - .select("id, name, status, updated_at, nodes") + .select("id, name, description, status, updated_at, version, nodes, edges") .eq("workspace_id", workspace.id) .order("updated_at", { ascending: false }), supabase @@ -59,6 +60,7 @@ export default async function FlowsPage() {

+ -
-
- -
-
-
-

- {flow.name} -

- - {status.label} - +
+
+
+ +
+
+
+

+ {flow.name} +

+ + {status.label} + +
+

+ {nodeCount} {nodeCount === 1 ? "node" : "nodes"} +

-

- {nodeCount} {nodeCount === 1 ? "node" : "nodes"} -

+

Updated {formatDate(flow.updated_at)} diff --git a/components/flow-actions.tsx b/components/flow-actions.tsx new file mode 100644 index 00000000..23128623 --- /dev/null +++ b/components/flow-actions.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Upload, Download, Loader2, GitBranch, FileJson, X } from "lucide-react"; +import type { Json } from "@/lib/types/database"; + +interface FlowExportData { + name: string; + description?: string | null; + nodes: Json; + edges: Json; + version: number; + exportedAt: string; + source: "zernflow"; +} + +function isValidFlowData(data: unknown): data is FlowExportData { + if (!data || typeof data !== "object") return false; + const d = data as Record; + return ( + typeof d.name === "string" && + Array.isArray(d.nodes) && + Array.isArray(d.edges) + ); +} + +export function ExportFlowButton({ + flow, +}: { + flow: { id: string; name: string; nodes: Json; edges: Json; description?: string | null; version?: number }; +}) { + function handleExport(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + + const exportData: FlowExportData = { + name: flow.name, + description: flow.description || null, + nodes: flow.nodes, + edges: flow.edges, + version: flow.version || 1, + exportedAt: new Date().toISOString(), + source: "zernflow", + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.flow.json`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( + + ); +} + +export function ImportFlowButton() { + const router = useRouter(); + const fileInputRef = useRef(null); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setError(null); + + try { + const text = await file.text(); + const data = JSON.parse(text); + + if (!isValidFlowData(data)) { + throw new Error("Invalid flow file. Expected name, nodes, and edges."); + } + + setPreview(data); + } catch (err) { + if (err instanceof SyntaxError) { + setError("Invalid JSON file"); + } else { + setError(err instanceof Error ? err.message : "Invalid file"); + } + setTimeout(() => setError(null), 4000); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + } + + async function handleConfirmImport() { + if (!preview) return; + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/v1/flows", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: preview.name + " (imported)", + description: preview.description || null, + nodes: preview.nodes, + edges: preview.edges, + }), + }); + + if (!res.ok) { + let errorMsg = "Failed to import flow"; + try { + const err = await res.json(); + errorMsg = err.error || errorMsg; + } catch { + errorMsg = `Server returned ${res.status}`; + } + throw new Error(errorMsg); + } + + const flow = await res.json(); + setPreview(null); + router.push(`/dashboard/flows/${flow.id}`); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to import"); + setTimeout(() => setError(null), 4000); + } finally { + setImporting(false); + } + } + + const nodeCount = preview ? (Array.isArray(preview.nodes) ? preview.nodes.length : 0) : 0; + const edgeCount = preview ? (Array.isArray(preview.edges) ? preview.edges.length : 0) : 0; + + return ( + <> + + + + {error && ( + {error} + )} + + {/* Import preview */} + {preview && ( +

setPreview(null)}> +
e.stopPropagation()}> +
+
+ +
+
+

{preview.name}

+

+ {nodeCount} {nodeCount === 1 ? "node" : "nodes"} ยท {edgeCount} {edgeCount === 1 ? "connection" : "connections"} +

+
+
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ )} + + ); +} diff --git a/components/flow-builder/flow-canvas.tsx b/components/flow-builder/flow-canvas.tsx index 4f4c7ddc..bf1893ae 100644 --- a/components/flow-builder/flow-canvas.tsx +++ b/components/flow-builder/flow-canvas.tsx @@ -19,7 +19,7 @@ import "@xyflow/react/dist/style.css"; import { useCallback, useRef, useState, type DragEvent } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Save, Rocket, Loader2, History, Play } from "lucide-react"; +import { ArrowLeft, Save, Rocket, Loader2, History, Play, Download } from "lucide-react"; import { cn } from "@/lib/utils"; import { createClient } from "@/lib/supabase/client"; import type { Database, FlowStatus, Json } from "@/lib/types/database"; @@ -338,6 +338,30 @@ function FlowCanvasInner({ flow }: FlowCanvasProps) { History +