Browse Source

Subtle connectors, zip download to context menu

handoff-20260429-1057
shrimbly 6 months ago
parent
commit
926590adeb
  1. 123
      package-lock.json
  2. 2
      package.json
  3. 75
      src/components/MultiSelectToolbar.tsx
  4. 43
      src/components/edges/EditableEdge.tsx
  5. 33
      src/components/edges/ReferenceEdge.tsx
  6. 42
      src/components/modals/PromptEditorModal.tsx

123
package-lock.json

@ -12,6 +12,7 @@
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",
"autoprefixer": "^10.4.22",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"next": "^16.0.6",
"postcss": "^8.5.6",
@ -22,6 +23,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@ -1057,6 +1059,16 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/jszip": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"jszip": "*"
}
},
"node_modules/@types/node": {
"version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@ -1072,7 +1084,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1298,7 +1309,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -1369,6 +1379,12 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1446,7 +1462,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -1757,6 +1772,18 @@
"node": ">= 14"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -1766,6 +1793,12 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -1826,6 +1859,18 @@
"bignumber.js": "^9.0.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@ -1865,8 +1910,16 @@
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"peer": true
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
@ -2319,6 +2372,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -2369,7 +2428,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -2385,6 +2443,12 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@ -2399,7 +2463,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -2453,6 +2516,27 @@
"react": "^19.2.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
@ -2507,6 +2591,12 @@
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@ -2594,6 +2684,21 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -2798,6 +2903,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

2
package.json

@ -13,6 +13,7 @@
"@tailwindcss/postcss": "^4.1.17",
"@xyflow/react": "^12.9.3",
"autoprefixer": "^10.4.22",
"jszip": "^3.10.1",
"konva": "^10.0.12",
"next": "^16.0.6",
"postcss": "^8.5.6",
@ -23,6 +24,7 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

75
src/components/MultiSelectToolbar.tsx

@ -2,7 +2,14 @@
import { useReactFlow } from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import JSZip from "jszip";
import type {
ImageInputNodeData,
AnnotationNodeData,
NanoBananaNodeData,
OutputNodeData,
} from "@/types";
const STACK_GAP = 20;
@ -156,6 +163,58 @@ export function MultiSelectToolbar() {
removeNodesFromGroup(nodeIds);
};
const handleDownloadImages = useCallback(async () => {
// Extract images from selected nodes based on node type
const images: { data: string; name: string }[] = [];
selectedNodes.forEach((node, index) => {
let imageData: string | null = null;
switch (node.type) {
case "imageInput":
imageData = (node.data as ImageInputNodeData).image;
break;
case "annotation":
imageData = (node.data as AnnotationNodeData).outputImage;
break;
case "nanoBanana":
imageData = (node.data as NanoBananaNodeData).outputImage;
break;
case "output":
imageData = (node.data as OutputNodeData).image;
break;
}
if (imageData) {
images.push({
data: imageData,
name: `image-${index + 1}.png`,
});
}
});
if (images.length === 0) return;
// Create ZIP file
const zip = new JSZip();
images.forEach(({ data, name }) => {
// Remove data URL prefix to get raw base64
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
zip.file(name, base64Data, { base64: true });
});
// Generate and download
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `images-${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [selectedNodes]);
if (!toolbarPosition || selectedNodes.length < 2) return null;
return (
@ -220,6 +279,20 @@ export function MultiSelectToolbar() {
</svg>
</button>
)}
{/* Separator */}
<div className="w-px h-4 bg-neutral-600 mx-0.5" />
{/* Download images button */}
<button
onClick={handleDownloadImages}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title="Download images as ZIP"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
</div>
);
}

43
src/components/edges/EditableEdge.tsx

@ -16,12 +16,12 @@ interface EdgeData extends WorkflowEdgeData {
offsetY?: number;
}
// Colors for different connection types
// Colors for different connection types (dimmed for softer appearance)
const EDGE_COLORS = {
image: "#10b981", // Green for image connections
prompt: "#3b82f6", // Blue for prompt connections
default: "#94a3b8", // Gray for unknown
pause: "#f97316", // Orange for paused edges
image: "#0d9668", // Dimmed green for image connections
prompt: "#2563eb", // Dimmed blue for prompt connections
default: "#64748b", // Dimmed gray for unknown
pause: "#ea580c", // Dimmed orange for paused edges
};
export function EditableEdge({
@ -38,6 +38,7 @@ export function EditableEdge({
data,
sourceHandleId,
targetHandleId,
source,
target,
}: EdgeProps) {
const { setEdges } = useReactFlow();
@ -46,6 +47,15 @@ export function EditableEdge({
const nodes = useWorkflowStore((state) => state.nodes);
const [isDragging, setIsDragging] = useState(false);
// Check if any node is selected and if this edge is connected to it
const isConnectedToSelection = useMemo(() => {
const selectedNodes = nodes.filter((n) => n.selected);
if (selectedNodes.length === 0) return false; // No selection, show all dimmed
// Check if this edge connects to any selected node
return selectedNodes.some((n) => n.id === source || n.id === target);
}, [nodes, source, target]);
const edgeData = data as EdgeData | undefined;
const offsetX = edgeData?.offsetX ?? 0;
const offsetY = edgeData?.offsetY ?? 0;
@ -71,8 +81,12 @@ export function EditableEdge({
return EDGE_COLORS.default;
}, [hasPause, sourceHandleId, targetHandleId]);
// Generate a unique gradient ID for this edge
const gradientId = `pulse-gradient-${id}`;
// Generate a unique gradient ID based on edge color and selection state
const gradientId = useMemo(() => {
const colorKey = hasPause ? "pause" : (sourceHandleId || targetHandleId || "default");
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return `edge-gradient-${colorKey}-${selectionKey}-${id}`;
}, [hasPause, sourceHandleId, targetHandleId, isConnectedToSelection, id]);
// Calculate the path based on edge style
const [edgePath, labelX, labelY] = useMemo(() => {
@ -167,13 +181,22 @@ export function EditableEdge({
return (
<>
{/* SVG gradient definition for bright-dim-bright effect */}
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
<stop offset="50%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 0.55 : 0.1} />
<stop offset="100%" stopColor={edgeColor} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
</linearGradient>
</defs>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
stroke: edgeColor,
stroke: `url(#${gradientId})`,
strokeWidth: 3,
strokeLinecap: "round",
strokeLinejoin: "round",
@ -187,7 +210,7 @@ export function EditableEdge({
<path
d={edgePath}
fill="none"
stroke={edgeColor}
stroke={`url(#${gradientId})`}
strokeWidth={10}
strokeLinecap="round"
strokeLinejoin="round"
@ -200,7 +223,7 @@ export function EditableEdge({
<path
d={edgePath}
fill="none"
stroke={edgeColor}
stroke={`url(#${gradientId})`}
strokeWidth={5}
strokeLinecap="round"
strokeLinejoin="round"

33
src/components/edges/ReferenceEdge.tsx

@ -8,8 +8,8 @@ import {
} from "@xyflow/react";
import { useWorkflowStore } from "@/store/workflowStore";
// Grey color for reference connections
const REFERENCE_COLOR = "#6b7280";
// Grey color for reference connections (dimmed for softer appearance)
const REFERENCE_COLOR = "#52525b";
export function ReferenceEdge({
id,
@ -21,8 +21,20 @@ export function ReferenceEdge({
targetPosition,
style,
markerEnd,
source,
target,
}: EdgeProps) {
const edgeStyle = useWorkflowStore((state) => state.edgeStyle);
const nodes = useWorkflowStore((state) => state.nodes);
// Check if any node is selected and if this edge is connected to it
const isConnectedToSelection = useMemo(() => {
const selectedNodes = nodes.filter((n) => n.selected);
if (selectedNodes.length === 0) return false; // No selection, show all dimmed
// Check if this edge connects to any selected node
return selectedNodes.some((n) => n.id === source || n.id === target);
}, [nodes, source, target]);
// Calculate the path - always use curved for reference edges for softer look
const [edgePath] = useMemo(() => {
@ -37,15 +49,30 @@ export function ReferenceEdge({
});
}, [sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]);
// Generate a unique gradient ID based on selection state
const gradientId = useMemo(() => {
const selectionKey = isConnectedToSelection ? "active" : "dimmed";
return `reference-gradient-${selectionKey}-${id}`;
}, [isConnectedToSelection, id]);
return (
<>
{/* SVG gradient definition for bright-dim-bright effect */}
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
<stop offset="50%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 0.55 : 0.1} />
<stop offset="100%" stopColor={REFERENCE_COLOR} stopOpacity={isConnectedToSelection ? 1 : 0.25} />
</linearGradient>
</defs>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
stroke: REFERENCE_COLOR,
stroke: `url(#${gradientId})`,
strokeWidth: 2,
strokeDasharray: "6 4",
strokeLinecap: "round",

42
src/components/modals/PromptEditorModal.tsx

@ -95,6 +95,20 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
[handleAttemptClose]
);
const handleDismissConfirmation = useCallback(() => {
setShowConfirmation(false);
}, []);
const handleConfirmationBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only dismiss if clicking the backdrop itself, not the confirmation dialog
if (e.target === e.currentTarget) {
handleDismissConfirmation();
}
},
[handleDismissConfirmation]
);
if (!isOpen) return null;
return (
@ -157,8 +171,32 @@ export const PromptEditorModal: React.FC<PromptEditorModalProps> = ({
{/* Confirmation overlay */}
{showConfirmation && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 rounded-lg">
<div className="bg-neutral-800 border border-neutral-600 rounded-lg p-6 mx-4 max-w-sm shadow-xl">
<div
className="absolute inset-0 flex items-center justify-center bg-black/60 rounded-lg"
onClick={handleConfirmationBackdropClick}
>
<div className="relative bg-neutral-800 border border-neutral-600 rounded-lg p-6 mx-4 max-w-sm shadow-xl">
{/* Close button */}
<button
onClick={handleDismissConfirmation}
className="absolute top-3 right-3 text-neutral-400 hover:text-neutral-200 transition-colors focus:outline-none"
aria-label="Close"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<p className="text-neutral-100 text-center mb-6">
You have unsaved changes
</p>

Loading…
Cancel
Save