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
10 changes: 10 additions & 0 deletions motifstudio-web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions motifstudio-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.2.0",
"@monaco-editor/react": "^4.6.0",
"axios": "^1.6.2",
"color-hash": "^2.0.2",
Expand Down
21 changes: 18 additions & 3 deletions motifstudio-web/src/app/Appbar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import motifStudioLogo from "./motif-studio.png";
import Image from "next/image";
export function Appbar() {
import { FileMenu } from "./FileMenu";
import { PrimitivesMenu } from "./components/PrimitivesMenu";
import { HostListing } from "./api";

interface AppbarProps {
queryText: string;
currentGraph?: HostListing;
onLoad: (data: { queryText: string; graph?: HostListing }) => void;
onInsertPrimitive: (dotmotif: string) => void;
}

export function Appbar({ queryText, currentGraph, onLoad, onInsertPrimitive }: AppbarProps) {
return (
<div className="w-full items-center justify-between font-mono text-sm lg:flex p-4">
<div className="flex flex-col justify-center w-full h-full p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
<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">
<div className="flex items-center space-x-4">
<FileMenu queryText={queryText} currentGraph={currentGraph} onLoad={onLoad} />
<PrimitivesMenu onInsertPrimitive={onInsertPrimitive} />
</div>
<Image
src={motifStudioLogo}
alt="Motif Studio"
// In dark mode, invert the logo colors. (Check theme with tailwind)
className="object-contain h-8 object-left w-auto dark:filter dark:invert"
className="object-contain h-8 w-auto dark:filter dark:invert"
/>
</div>
</div>
Expand Down
138 changes: 138 additions & 0 deletions motifstudio-web/src/app/FileMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import { useState, Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { ChevronDownIcon, DocumentIcon, FolderOpenIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { FileMenuProps, SavedProject } from "./types/fileMenu";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { SaveDialog } from "./components/SaveDialog";
import { OpenDialog } from "./components/OpenDialog";
import { DeleteConfirmDialog } from "./components/DeleteConfirmDialog";
import { exportAsJSON } from "./utils/exportUtils";

export function FileMenu({ queryText, currentGraph, onLoad }: FileMenuProps) {
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isOpenDialogOpen, setIsOpenDialogOpen] = useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<SavedProject | null>(null);

const { savedProjects, saveProject, deleteProject } = useLocalStorage();

const handleSave = (project: SavedProject) => {
saveProject(project);
};

const handleLoad = (project: SavedProject) => {
onLoad({
queryText: project.queryText,
graph: project.graph,
});
setIsOpenDialogOpen(false);
};

const handleDeleteClick = (project: SavedProject) => {
setProjectToDelete(project);
setIsDeleteConfirmOpen(true);
};

const handleDeleteConfirm = (projectId: string) => {
deleteProject(projectId);
setIsDeleteConfirmOpen(false);
setProjectToDelete(null);
};

const handleExport = () => {
exportAsJSON(queryText, currentGraph, true);
};

return (
<>
<Menu as="div" className="relative inline-block text-left">
<div>
<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">
File
<ChevronDownIcon className="-mr-1 h-5 w-5 text-gray-400" aria-hidden="true" />
</Menu.Button>
</div>

<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<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">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsSaveDialogOpen(true)}
className={`${
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
} group flex w-full items-center px-4 py-2 text-sm`}
>
<DocumentIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
Save
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsOpenDialogOpen(true)}
className={`${
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
} group flex w-full items-center px-4 py-2 text-sm`}
>
<FolderOpenIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
Open
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={handleExport}
className={`${
active ? "bg-gray-100 text-gray-900" : "text-gray-700"
} group flex w-full items-center px-4 py-2 text-sm`}
>
<ArrowDownTrayIcon className="mr-3 h-5 w-5 text-gray-400 group-hover:text-gray-500" />
Export
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>

<SaveDialog
isOpen={isSaveDialogOpen}
onClose={() => setIsSaveDialogOpen(false)}
queryText={queryText}
currentGraph={currentGraph}
savedProjects={savedProjects}
onSave={handleSave}
/>

<OpenDialog
isOpen={isOpenDialogOpen}
onClose={() => setIsOpenDialogOpen(false)}
savedProjects={savedProjects}
onLoad={handleLoad}
onDelete={handleDeleteClick}
/>

<DeleteConfirmDialog
isOpen={isDeleteConfirmOpen}
onClose={() => setIsDeleteConfirmOpen(false)}
project={projectToDelete}
onConfirm={handleDeleteConfirm}
/>
</>
);
}
13 changes: 11 additions & 2 deletions motifstudio-web/src/app/GraphForm.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Combobox } from "@headlessui/react";
import useSWR from "swr";
import { DatabaseIcon } from "./DatabaseIcon";
import { HostListing, fetcher, BASE_URL } from "./api";
import { useClientOnly } from "./hooks/useClientOnly";

/**
* Dropdown to select a host graph from a list of available graphs.
Expand All @@ -22,12 +23,20 @@ export function GraphForm({
}) {
// Pull graphs from web server with axios:
const { data, error, isLoading } = useSWR<{ hosts: HostListing[] }>(`${BASE_URL}/providers/hostlist`, fetcher);
const [selectedGraph, setSelectedGraph] = useState<HostListing>(startValue);
const [selectedGraph, setSelectedGraph] = useState<HostListing | undefined>(startValue);
const [query, setQuery] = useState("");
const isClient = useClientOnly();

// Update selectedGraph when startValue changes
useEffect(() => {
setSelectedGraph(startValue);
}, [startValue]);

// Simple loading/error handling.
// Note that if the host cannot be reached, this is likely the first place
// that the user will see an error message.
// Use client-only check to avoid hydration mismatch
if (!isClient) return <div>Loading...</div>;
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {JSON.stringify(error)}</div>;
if (!data) return <div>No data</div>;
Expand Down
5 changes: 5 additions & 0 deletions motifstudio-web/src/app/GraphStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect } from "react";
import useSWR from "swr";
import { HostListing, bodiedFetcher, BASE_URL } from "./api";
import { useClientOnly } from "./hooks/useClientOnly";

/**
* Display graph statistics and attributes when a host is selected.
Expand All @@ -23,6 +24,8 @@ export function GraphStats({
graph: HostListing;
onAttributesLoaded?: (attributes: { [key: string]: string }) => void;
}) {
const isClient = useClientOnly();

// Fetch graph statistics and attributes.
// TODO: Perhaps these should all go in one combined query?
const {
Expand Down Expand Up @@ -64,6 +67,8 @@ export function GraphStats({
}
}, [vertAttrData?.attributes, onAttributesLoaded]);

// Use client-only check to avoid hydration mismatch
if (!isClient) return <div>Loading...</div>;
if (vertIsLoading || edgeIsLoading) return <div>Loading...</div>;
if (vertError || edgeError) return <div>Error: {vertError}</div>;
if (!vertData || !edgeData) return <div>No data</div>;
Expand Down
2 changes: 1 addition & 1 deletion motifstudio-web/src/app/MotifVisualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
}

if (queryIsLoading) {
return <div>Loading motif...</div>;
return <div>Loading...</div>;
}

if (!queryData) {
Expand Down
2 changes: 1 addition & 1 deletion motifstudio-web/src/app/WrappedEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function WrappedEditor({
onChange ? onChange(editor.getValue()) : null;
}}
defaultLanguage="motiflang"
defaultValue={startValue || _DEFAULT_EDITOR_CONTENTS}
value={startValue || _DEFAULT_EDITOR_CONTENTS}
options={{
fontSize: 16,
fontLigatures: true,
Expand Down
65 changes: 65 additions & 0 deletions motifstudio-web/src/app/components/DeleteConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client";

import { Dialog } from "@headlessui/react";
import { DeleteConfirmDialogProps } from "../types/fileMenu";

export function DeleteConfirmDialog({ isOpen, onClose, project, onConfirm }: DeleteConfirmDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
}
};

return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
<div className="fixed inset-0 bg-black/25" />
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<svg
className="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-medium text-gray-900">
Delete Project
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to delete "{project?.name}"? This action cannot be undone.
</p>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</Dialog.Panel>
</div>
</div>
</Dialog>
);
}
Loading