Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions app/(dashboard)/dashboard/flows/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowStatus, { label: string; classes: string }> = {
Expand Down Expand Up @@ -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
Expand All @@ -59,6 +60,7 @@ export default async function FlowsPage() {
</p>
</div>
<div className="flex items-center gap-3">
<ImportFlowButton />
<Link
href="/dashboard/flows/templates"
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
Expand Down Expand Up @@ -115,25 +117,28 @@ export default async function FlowsPage() {
href={`/dashboard/flows/${flow.id}`}
className="group rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium group-hover:text-primary transition-colors">
{flow.name}
</h3>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${status.classes}`}
>
{status.label}
</span>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium group-hover:text-primary transition-colors">
{flow.name}
</h3>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${status.classes}`}
>
{status.label}
</span>
</div>
<p className="text-xs text-muted-foreground">
{nodeCount} {nodeCount === 1 ? "node" : "nodes"}
</p>
</div>
<p className="text-xs text-muted-foreground">
{nodeCount} {nodeCount === 1 ? "node" : "nodes"}
</p>
</div>
<ExportFlowButton flow={flow} />
</div>
<p className="mt-4 text-xs text-muted-foreground">
Updated {formatDate(flow.updated_at)}
Expand Down
216 changes: 216 additions & 0 deletions components/flow-actions.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 (
<button
onClick={handleExport}
className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card text-muted-foreground/60 hover:bg-muted hover:text-muted-foreground transition-colors"
aria-label={`Export ${flow.name}`}
>
<Download className="h-3.5 w-3.5" />
</button>
);
}

export function ImportFlowButton() {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<FlowExportData | null>(null);

async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<>
<input
ref={fileInputRef}
type="file"
accept=".json,.flow.json"
onChange={handleFileChange}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{importing ? "Importing..." : "Import"}
</button>

{error && (
<span className="text-xs font-medium text-destructive">{error}</span>
)}

{/* Import preview */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setPreview(null)}>
<div className="mx-4 w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<GitBranch className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{preview.name}</p>
<p className="text-xs text-muted-foreground">
{nodeCount} {nodeCount === 1 ? "node" : "nodes"} · {edgeCount} {edgeCount === 1 ? "connection" : "connections"}
</p>
</div>
</div>

{error && (
<p className="mt-3 text-xs font-medium text-destructive">{error}</p>
)}

<div className="mt-4 flex gap-2">
<button
onClick={() => setPreview(null)}
className="flex-1 rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirmImport}
disabled={importing}
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{importing && <Loader2 className="h-4 w-4 animate-spin" />}
{importing ? "Importing..." : "Import"}
</button>
</div>
</div>
</div>
)}
</>
);
}
26 changes: 25 additions & 1 deletion components/flow-builder/flow-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -338,6 +338,30 @@ function FlowCanvasInner({ flow }: FlowCanvasProps) {
<History className="h-3.5 w-3.5" />
History
</button>
<button
onClick={() => {
const exportData = {
name: flowName,
description: flow.description || null,
nodes,
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 = `${flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.flow.json`;
a.click();
URL.revokeObjectURL(url);
}}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors"
>
<Download className="h-3.5 w-3.5" />
Export
</button>
<button
onClick={handleSave}
disabled={saving}
Expand Down