Skip to content

Commit 51d8738

Browse files
authored
Add save and load (#46)
* feat: Add save / open menus * feat: Enhance FileMenu with project deletion functionality and improved save logic * chore: Refactor file menu * feat: Add primitives loader
1 parent 1437773 commit 51d8738

20 files changed

+1040
-9
lines changed

motifstudio-web/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

motifstudio-web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@headlessui/react": "^1.7.17",
13+
"@heroicons/react": "^2.2.0",
1314
"@monaco-editor/react": "^4.6.0",
1415
"axios": "^1.6.2",
1516
"color-hash": "^2.0.2",

motifstudio-web/src/app/Appbar.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import motifStudioLogo from "./motif-studio.png";
22
import Image from "next/image";
3-
export function Appbar() {
3+
import { FileMenu } from "./FileMenu";
4+
import { PrimitivesMenu } from "./components/PrimitivesMenu";
5+
import { HostListing } from "./api";
6+
7+
interface AppbarProps {
8+
queryText: string;
9+
currentGraph?: HostListing;
10+
onLoad: (data: { queryText: string; graph?: HostListing }) => void;
11+
onInsertPrimitive: (dotmotif: string) => void;
12+
}
13+
14+
export function Appbar({ queryText, currentGraph, onLoad, onInsertPrimitive }: AppbarProps) {
415
return (
516
<div className="w-full items-center justify-between font-mono text-sm lg:flex p-4">
6-
<div className="flex flex-col justify-center w-full h-full p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
17+
<div className="flex flex-row justify-between items-center w-full h-full p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
18+
<div className="flex items-center space-x-4">
19+
<FileMenu queryText={queryText} currentGraph={currentGraph} onLoad={onLoad} />
20+
<PrimitivesMenu onInsertPrimitive={onInsertPrimitive} />
21+
</div>
722
<Image
823
src={motifStudioLogo}
924
alt="Motif Studio"
1025
// In dark mode, invert the logo colors. (Check theme with tailwind)
11-
className="object-contain h-8 object-left w-auto dark:filter dark:invert"
26+
className="object-contain h-8 w-auto dark:filter dark:invert"
1227
/>
1328
</div>
1429
</div>

motifstudio-web/src/app/FileMenu.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"use client";
2+
3+
import { useState, Fragment } from "react";
4+
import { Menu, Transition } from "@headlessui/react";
5+
import { ChevronDownIcon, DocumentIcon, FolderOpenIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
6+
import { FileMenuProps, SavedProject } from "./types/fileMenu";
7+
import { useLocalStorage } from "./hooks/useLocalStorage";
8+
import { SaveDialog } from "./components/SaveDialog";
9+
import { OpenDialog } from "./components/OpenDialog";
10+
import { DeleteConfirmDialog } from "./components/DeleteConfirmDialog";
11+
import { exportAsJSON } from "./utils/exportUtils";
12+
13+
export function FileMenu({ queryText, currentGraph, onLoad }: FileMenuProps) {
14+
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
15+
const [isOpenDialogOpen, setIsOpenDialogOpen] = useState(false);
16+
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
17+
const [projectToDelete, setProjectToDelete] = useState<SavedProject | null>(null);
18+
19+
const { savedProjects, saveProject, deleteProject } = useLocalStorage();
20+
21+
const handleSave = (project: SavedProject) => {
22+
saveProject(project);
23+
};
24+
25+
const handleLoad = (project: SavedProject) => {
26+
onLoad({
27+
queryText: project.queryText,
28+
graph: project.graph,
29+
});
30+
setIsOpenDialogOpen(false);
31+
};
32+
33+
const handleDeleteClick = (project: SavedProject) => {
34+
setProjectToDelete(project);
35+
setIsDeleteConfirmOpen(true);
36+
};
37+
38+
const handleDeleteConfirm = (projectId: string) => {
39+
deleteProject(projectId);
40+
setIsDeleteConfirmOpen(false);
41+
setProjectToDelete(null);
42+
};
43+
44+
const handleExport = () => {
45+
exportAsJSON(queryText, currentGraph, true);
46+
};
47+
48+
return (
49+
<>
50+
<Menu as="div" className="relative inline-block text-left">
51+
<div>
52+
<Menu.Button className="inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-50">
53+
File
54+
<ChevronDownIcon className="-mr-1 h-5 w-5 text-gray-400" aria-hidden="true" />
55+
</Menu.Button>
56+
</div>
57+
58+
<Transition
59+
as={Fragment}
60+
enter="transition ease-out duration-100"
61+
enterFrom="transform opacity-0 scale-95"
62+
enterTo="transform opacity-100 scale-100"
63+
leave="transition ease-in duration-75"
64+
leaveFrom="transform opacity-100 scale-100"
65+
leaveTo="transform opacity-0 scale-95"
66+
>
67+
<Menu.Items className="absolute left-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
68+
<div className="py-1">
69+
<Menu.Item>
70+
{({ active }) => (
71+
<button
72+
onClick={() => setIsSaveDialogOpen(true)}
73+
className={`${
74+
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
75+
} group flex w-full items-center px-4 py-2 text-sm`}
76+
>
77+
<DocumentIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
78+
Save
79+
</button>
80+
)}
81+
</Menu.Item>
82+
<Menu.Item>
83+
{({ active }) => (
84+
<button
85+
onClick={() => setIsOpenDialogOpen(true)}
86+
className={`${
87+
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
88+
} group flex w-full items-center px-4 py-2 text-sm`}
89+
>
90+
<FolderOpenIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
91+
Open
92+
</button>
93+
)}
94+
</Menu.Item>
95+
<Menu.Item>
96+
{({ active }) => (
97+
<button
98+
onClick={handleExport}
99+
className={`${
100+
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
101+
} group flex w-full items-center px-4 py-2 text-sm`}
102+
>
103+
<ArrowDownTrayIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
104+
Export
105+
</button>
106+
)}
107+
</Menu.Item>
108+
</div>
109+
</Menu.Items>
110+
</Transition>
111+
</Menu>
112+
113+
<SaveDialog
114+
isOpen={isSaveDialogOpen}
115+
onClose={() => setIsSaveDialogOpen(false)}
116+
queryText={queryText}
117+
currentGraph={currentGraph}
118+
savedProjects={savedProjects}
119+
onSave={handleSave}
120+
/>
121+
122+
<OpenDialog
123+
isOpen={isOpenDialogOpen}
124+
onClose={() => setIsOpenDialogOpen(false)}
125+
savedProjects={savedProjects}
126+
onLoad={handleLoad}
127+
onDelete={handleDeleteClick}
128+
/>
129+
130+
<DeleteConfirmDialog
131+
isOpen={isDeleteConfirmOpen}
132+
onClose={() => setIsDeleteConfirmOpen(false)}
133+
project={projectToDelete}
134+
onConfirm={handleDeleteConfirm}
135+
/>
136+
</>
137+
);
138+
}

motifstudio-web/src/app/GraphForm.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
2-
import { useState } from "react";
2+
import { useState, useEffect } from "react";
33
import { Combobox } from "@headlessui/react";
44
import useSWR from "swr";
55
import { DatabaseIcon } from "./DatabaseIcon";
66
import { HostListing, fetcher, BASE_URL } from "./api";
7+
import { useClientOnly } from "./hooks/useClientOnly";
78

89
/**
910
* Dropdown to select a host graph from a list of available graphs.
@@ -22,12 +23,20 @@ export function GraphForm({
2223
}) {
2324
// Pull graphs from web server with axios:
2425
const { data, error, isLoading } = useSWR<{ hosts: HostListing[] }>(`${BASE_URL}/providers/hostlist`, fetcher);
25-
const [selectedGraph, setSelectedGraph] = useState<HostListing>(startValue);
26+
const [selectedGraph, setSelectedGraph] = useState<HostListing | undefined>(startValue);
2627
const [query, setQuery] = useState("");
28+
const isClient = useClientOnly();
29+
30+
// Update selectedGraph when startValue changes
31+
useEffect(() => {
32+
setSelectedGraph(startValue);
33+
}, [startValue]);
2734

2835
// Simple loading/error handling.
2936
// Note that if the host cannot be reached, this is likely the first place
3037
// that the user will see an error message.
38+
// Use client-only check to avoid hydration mismatch
39+
if (!isClient) return <div>Loading...</div>;
3140
if (isLoading) return <div>Loading...</div>;
3241
if (error) return <div>Error: {JSON.stringify(error)}</div>;
3342
if (!data) return <div>No data</div>;

motifstudio-web/src/app/GraphStats.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useEffect } from "react";
33
import useSWR from "swr";
44
import { HostListing, bodiedFetcher, BASE_URL } from "./api";
5+
import { useClientOnly } from "./hooks/useClientOnly";
56

67
/**
78
* Display graph statistics and attributes when a host is selected.
@@ -23,6 +24,8 @@ export function GraphStats({
2324
graph: HostListing;
2425
onAttributesLoaded?: (attributes: { [key: string]: string }) => void;
2526
}) {
27+
const isClient = useClientOnly();
28+
2629
// Fetch graph statistics and attributes.
2730
// TODO: Perhaps these should all go in one combined query?
2831
const {
@@ -64,6 +67,8 @@ export function GraphStats({
6467
}
6568
}, [vertAttrData?.attributes, onAttributesLoaded]);
6669

70+
// Use client-only check to avoid hydration mismatch
71+
if (!isClient) return <div>Loading...</div>;
6772
if (vertIsLoading || edgeIsLoading) return <div>Loading...</div>;
6873
if (vertError || edgeError) return <div>Error: {vertError}</div>;
6974
if (!vertData || !edgeData) return <div>No data</div>;

motifstudio-web/src/app/MotifVisualizer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
6666
}
6767

6868
if (queryIsLoading) {
69-
return <div>Loading motif...</div>;
69+
return <div>Loading...</div>;
7070
}
7171

7272
if (!queryData) {

motifstudio-web/src/app/WrappedEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function WrappedEditor({
116116
onChange ? onChange(editor.getValue()) : null;
117117
}}
118118
defaultLanguage="motiflang"
119-
defaultValue={startValue || _DEFAULT_EDITOR_CONTENTS}
119+
value={startValue || _DEFAULT_EDITOR_CONTENTS}
120120
options={{
121121
fontSize: 16,
122122
fontLigatures: true,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import { Dialog } from "@headlessui/react";
4+
import { DeleteConfirmDialogProps } from "../types/fileMenu";
5+
6+
export function DeleteConfirmDialog({ isOpen, onClose, project, onConfirm }: DeleteConfirmDialogProps) {
7+
const handleConfirm = () => {
8+
if (project) {
9+
onConfirm(project.id);
10+
}
11+
};
12+
13+
return (
14+
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
15+
<div className="fixed inset-0 bg-black/25" />
16+
<div className="fixed inset-0 overflow-y-auto">
17+
<div className="flex min-h-full items-center justify-center p-4">
18+
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
19+
<div className="flex items-center space-x-3">
20+
<div className="flex-shrink-0">
21+
<svg
22+
className="h-6 w-6 text-red-600"
23+
fill="none"
24+
stroke="currentColor"
25+
viewBox="0 0 24 24"
26+
>
27+
<path
28+
strokeLinecap="round"
29+
strokeLinejoin="round"
30+
strokeWidth={2}
31+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
32+
/>
33+
</svg>
34+
</div>
35+
<div className="flex-1">
36+
<Dialog.Title className="text-lg font-medium text-gray-900">
37+
Delete Project
38+
</Dialog.Title>
39+
<div className="mt-2">
40+
<p className="text-sm text-gray-500">
41+
Are you sure you want to delete "{project?.name}"? This action cannot be undone.
42+
</p>
43+
</div>
44+
</div>
45+
</div>
46+
<div className="mt-6 flex justify-end gap-3">
47+
<button
48+
onClick={onClose}
49+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
50+
>
51+
Cancel
52+
</button>
53+
<button
54+
onClick={handleConfirm}
55+
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
56+
>
57+
Delete
58+
</button>
59+
</div>
60+
</Dialog.Panel>
61+
</div>
62+
</div>
63+
</Dialog>
64+
);
65+
}

0 commit comments

Comments
 (0)