diff --git a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx index 9c41f88c4d..ca84b8eca1 100644 --- a/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx +++ b/studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx @@ -17,9 +17,11 @@ import { previewCanvas } from "./api"; import { BlockSheet } from "./components/block-sheet"; import { CanvasNode } from "./components/canvas-node"; import { ConfigDialog } from "./dialogs/config-dialog"; +import { ImportDialog } from "./dialogs/import-dialog"; import { useCanvasLabStore } from "./stores/canvas-lab"; import type { CanvasNodeData, SamplerConfig } from "./types"; import { isCategoryConfig } from "./utils"; +import { importCanvasPayload } from "./utils/import"; import { buildCanvasPayload } from "./utils/payload"; const NODE_TYPES: NodeTypes = { builder: CanvasNode }; @@ -43,6 +45,7 @@ export function CanvasLabPage(): ReactElement { isValidConnection, setSheetView, setDialogOpen, + loadCanvas, } = useCanvasLabStore( useShallow((state) => ({ nodes: state.nodes, @@ -62,12 +65,14 @@ export function CanvasLabPage(): ReactElement { isValidConnection: state.isValidConnection, setSheetView: state.setSheetView, setDialogOpen: state.setDialogOpen, + loadCanvas: state.loadCanvas, })), ); const [sheetContainer, setSheetContainer] = useState( null, ); const [previewLoading, setPreviewLoading] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [statusMessage, setStatusMessage] = useState<{ tone: "success" | "error"; text: string; @@ -114,6 +119,47 @@ export function CanvasLabPage(): ReactElement { } }; + const handleCopyRecipe = async (): Promise => { + setStatusMessage(null); + const { payload, errors } = buildCanvasPayload(configs, nodes, edges); + if (errors.length > 0) { + setStatusMessage({ + tone: "error", + text: errors[0] ?? "Fix config errors before copy.", + }); + return; + } + if (!navigator.clipboard) { + setStatusMessage({ + tone: "error", + text: "Clipboard not available.", + }); + return; + } + try { + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + setStatusMessage({ + tone: "success", + text: "Recipe copied to clipboard.", + }); + } catch (error) { + setStatusMessage({ + tone: "error", + text: error instanceof Error ? error.message : "Copy failed.", + }); + } + }; + + const handleImport = (value: string): string | null => { + const result = importCanvasPayload(value); + if (result.errors.length > 0 || !result.snapshot) { + return result.errors[0] ?? "Invalid payload."; + } + loadCanvas(result.snapshot); + setStatusMessage({ tone: "success", text: "Recipe imported." }); + return null; + }; + return (
@@ -139,6 +185,24 @@ export function CanvasLabPage(): ReactElement { )}
+ +
); } diff --git a/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx b/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx new file mode 100644 index 0000000000..78beb7d1bb --- /dev/null +++ b/studio/frontend/src/features/canvas-lab/dialogs/import-dialog.tsx @@ -0,0 +1,77 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { type ReactElement, useEffect, useState } from "react"; + +type ImportDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (value: string) => string | null; +}; + +export function ImportDialog({ + open, + onOpenChange, + onImport, +}: ImportDialogProps): ReactElement { + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const payloadId = "canvas-import-payload"; + + useEffect(() => { + if (!open) { + setValue(""); + setError(null); + } + }, [open]); + + const handleImport = () => { + const message = onImport(value); + if (message) { + setError(message); + return; + } + onOpenChange(false); + }; + + return ( + + + + Import recipe + +
+ +