diff --git a/app/builder/Form.client.tsx b/app/builder/Form.client.tsx
index ae8dd7d2..8f3dd5a4 100644
--- a/app/builder/Form.client.tsx
+++ b/app/builder/Form.client.tsx
@@ -34,6 +34,43 @@ const App = () => {
}
}, [archive, activetCatalog]); // eslint-disable-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ // on builder page check for zip in browser storage and loads it
+ if (activetCatalog.title === "") {
+ return;
+ }
+ if (typeof indexedDB === "undefined") {
+ console.error(
+ "IndexedDB not supported, unable to save workflow.zip file."
+ );
+ return;
+ }
+ const open = indexedDB.open("haddock3", 1);
+ open.onsuccess = function () {
+ const db = open.result;
+ const tx = db.transaction("zips", "readwrite");
+ const zips = tx.objectStore("zips");
+ const request = zips.get("workflow.zip");
+ request.onsuccess = function () {
+ const zip: Blob = request.result;
+ console.log("zip", zip);
+ if (zip === undefined) {
+ return;
+ }
+ const url = URL.createObjectURL(zip);
+ loadWorkflowArchive(url)
+ .finally(() => {
+ URL.revokeObjectURL(url);
+ })
+ .catch((error) => {
+ console.error("Error loading workflow from indexeddb", error);
+ });
+ // remove zip from indexeddb so next visit to builder page loads nothing
+ zips.delete("workflow.zip");
+ };
+ };
+ }, [activetCatalog]); // eslint-disable-line react-hooks/exhaustive-deps
+
return (
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
index ded43019..169be816 100644
--- a/app/components/Navbar.tsx
+++ b/app/components/Navbar.tsx
@@ -82,6 +82,7 @@ export const Navbar = () => {
Builder
+ Scenario
Upload
Manage
About
diff --git a/app/components/ui/dialog.tsx b/app/components/ui/dialog.tsx
new file mode 100644
index 00000000..38819f51
--- /dev/null
+++ b/app/components/ui/dialog.tsx
@@ -0,0 +1,120 @@
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "~/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx
index 89461cd5..dd864840 100644
--- a/app/components/ui/input.tsx
+++ b/app/components/ui/input.tsx
@@ -11,7 +11,7 @@ const Input = React.forwardRef(
+
+
+ {title}
+
+ {description}
+
+
+ );
+}
+
+export default function ScenariosIndex() {
+ return (
+
+ Scenarios
+
+ {scenarios.map((scenario) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/routes/scenarios.antibody-antigen.tsx b/app/routes/scenarios.antibody-antigen.tsx
new file mode 100644
index 00000000..cb7dd54f
--- /dev/null
+++ b/app/routes/scenarios.antibody-antigen.tsx
@@ -0,0 +1,218 @@
+import { useActionData, useNavigate, useSubmit } from "@remix-run/react";
+import JSZip from "jszip";
+import { Output, instance, object } from "valibot";
+import { WORKFLOW_CONFIG_FILENAME } from "~/bartender-client/constants";
+
+import { Input } from "~/components/ui/input";
+import { action as uploadaction } from "./upload";
+import { FormItem } from "../scenarios/FormItem";
+import { FormDescription } from "../scenarios/FormDescription";
+import { PDBFileInput } from "../scenarios/PDBFileInput";
+import { ActionButtons, handleActionButton } from "~/scenarios/actions";
+import { parseFormData } from "~/scenarios/schema";
+
+export const action = uploadaction;
+
+const Schema = object({
+ antibody: instance(File, "Antibody structure as PDB file", []),
+ antigen: instance(File, "Antibody structure as PDB file", []),
+ // restraints get type==='' so cannot check for file type
+ ambig_fname: instance(File, "Ambiguous restraints as TBL file"),
+ unambig_fname: instance(File, "Unambiguous restraints as TBL file"),
+ reference_fname: instance(File, "Reference structure as PDB file", []),
+});
+type Schema = Output;
+
+function generateWorkflow(data: Schema) {
+ // create workflow.cfg with form data as values for filename fields
+
+ // Workflow based on
+ // scenario2a-NMR-epitope-pass-short.cfg
+ // in https://surfdrive.surf.nl/files/index.php/s/HvXxgxCTY1DiPsV
+ // from
+ // https://www.bonvinlab.org/education/HADDOCK3/HADDOCK3-antibody-antigen/#setuprequirements
+ // but made valid for easy expertise level
+ return `
+# ====================================================================
+# Antibody-antigen docking example with restraints from the antibody
+# paratope to the NMR-identified epitope on the antigen (as passive)
+# ====================================================================
+
+# directory name of the run
+run_dir = "scenario2a-NMR-epitope-pass-short"
+
+# Compute mode
+mode = "local"
+# 10 cores
+ncores = 10
+
+# Self contained rundir
+#self_contained = false
+
+# Post-processing to generate statistics and plots
+postprocess = true
+
+# Cleaning
+clean = true
+
+# molecules to be docked
+molecules = [
+ "${data.antibody.name}",
+ "${data.antigen.name}"
+ ]
+
+# ====================================================================
+# Parameters for each stage are defined below, prefer full paths
+# ====================================================================
+[topoaa]
+
+[rigidbody]
+# number of models to generate
+sampling = 200
+# paratope to surface ambig restraints
+ambig_fname = "${data.ambig_fname.name}"
+# Restraints to keep the antibody chains together
+unambig_fname = "${data.unambig_fname.name}"
+# Turn off ramdom removal of restraints
+# randremoval = false
+
+[clustfcc]
+min_population = 10
+
+[seletopclusts]
+## select all the clusters
+top_cluster = 500
+## select the best 10 models of each cluster
+top_models = 10
+
+[caprieval]
+# this is only for this tutorial to check the performance at the rigidbody stage
+reference_fname = "${data.reference_fname.name}"
+
+[flexref]
+# Acceptable percentage of model failures
+# tolerance = 5
+# paratope to surface ambig restraints
+ambig_fname = "${data.ambig_fname.name}"
+# Restraints to keep the antibody chains together
+unambig_fname = "${data.unambig_fname.name}"
+# Turn off ramdom removal of restraints
+# randremoval = false
+
+[emref]
+# paratope to surface ambig restraints
+ambig_fname = "${data.ambig_fname.name}"
+# Restraints to keep the antibody chains together
+unambig_fname = "${data.unambig_fname.name}"
+# Turn off ramdom removal of restraints
+# randremoval = false
+
+[clustfcc]
+
+[seletopclusts]
+top_cluster = 500
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+# ====================================================================
+
+`;
+}
+
+async function createZip(workflow: string, data: Schema) {
+ const zip = new JSZip();
+ zip.file(WORKFLOW_CONFIG_FILENAME, workflow);
+ zip.file(data.antibody.name, data.antibody);
+ zip.file(data.antigen.name, data.antigen);
+ zip.file(data.ambig_fname.name, data.ambig_fname);
+ zip.file(data.unambig_fname.name, data.unambig_fname);
+ zip.file(data.reference_fname.name, data.reference_fname);
+ return zip.generateAsync({ type: "blob" });
+}
+
+export default function AntibodyAntigenScenario() {
+ const actionData = useActionData();
+ const submit = useSubmit();
+ const navigate = useNavigate();
+
+ function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const formData = new FormData(form);
+ const data = parseFormData(formData, Schema);
+ const workflow = generateWorkflow(data);
+ const zipPromise = createZip(workflow, data);
+ handleActionButton(event.nativeEvent, zipPromise, navigate, submit);
+ }
+
+ return (
+ <>
+ Antibody-antigen scenario
+
+ Based on{" "}
+
+ HADDOCK3 Antibody Antigen tutorial
+
+ .
+
+
+ >
+ );
+}
diff --git a/app/routes/scenarios.protein-protein.tsx b/app/routes/scenarios.protein-protein.tsx
new file mode 100644
index 00000000..1493e117
--- /dev/null
+++ b/app/routes/scenarios.protein-protein.tsx
@@ -0,0 +1,184 @@
+import { useActionData, useSubmit, useNavigate } from "@remix-run/react";
+import { action as uploadaction } from "./upload";
+import { ActionButtons, handleActionButton } from "~/scenarios/actions";
+import { object, instance, Output } from "valibot";
+import { parseFormData } from "~/scenarios/schema";
+import { WORKFLOW_CONFIG_FILENAME } from "~/bartender-client/constants";
+import JSZip from "jszip";
+import { FormDescription } from "~/scenarios/FormDescription";
+import { FormItem } from "~/scenarios/FormItem";
+import { PDBFileInput } from "~/scenarios/PDBFileInput";
+import { Input } from "~/components/ui/input";
+
+export const action = uploadaction;
+
+const Schema = object({
+ protein1: instance(File, "First protein structure as PDB file"),
+ protein2: instance(File, "Second protein structure as PDB file"),
+ ambig_fname: instance(File, "Ambiguous restraints as TBL file"),
+ reference_fname: instance(File, "Reference structure as PDB file"),
+});
+type Schema = Output;
+
+function generateWorkflow(data: Schema) {
+ // Workflow based on
+ // https://github.com/haddocking/haddock3/blob/main/examples/docking-protein-protein/docking-protein-protein-full.cfg
+ // made valid for easy expertise level
+ return `
+# ====================================================================
+# Protein-protein docking example with NMR-derived ambiguous interaction restraints
+
+# directory in which the scoring will be done
+run_dir = "run1-full"
+
+# execution mode
+mode = "batch"
+# it will take the system's default
+# queue = "short"
+# concatenate models inside each job, concat = 5 each .job will produce 5 models
+concat = 5
+# Limit the number of concurrent submissions to the queue
+queue_limit = 100
+
+# molecules to be docked
+molecules = [
+ "${data.protein1.name}",
+ "${data.protein2.name}"
+ ]
+
+# ====================================================================
+# Parameters for each stage are defined below, prefer full paths
+# ====================================================================
+[topoaa]
+
+[rigidbody]
+ambig_fname = "${data.ambig_fname.name}"
+sampling = 1000
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+[seletop]
+select = 200
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+[flexref]
+ambig_fname = "${data.ambig_fname.name}"
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+[emref]
+ambig_fname = "${data.ambig_fname.name}"
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+[clustfcc]
+
+[seletopclusts]
+top_models = 4
+
+[caprieval]
+reference_fname = "${data.reference_fname.name}"
+
+# ====================================================================
+
+
+ `;
+}
+
+async function createZip(workflow: string, data: Schema) {
+ const zip = new JSZip();
+ zip.file(WORKFLOW_CONFIG_FILENAME, workflow);
+ zip.file(data.protein1.name, data.protein1);
+ zip.file(data.protein2.name, data.protein2);
+ zip.file(data.ambig_fname.name, data.ambig_fname);
+ zip.file(data.reference_fname.name, data.reference_fname);
+ return zip.generateAsync({ type: "blob" });
+}
+
+export default function ProteinProteinScenario() {
+ const actionData = useActionData();
+ const submit = useSubmit();
+ const navigate = useNavigate();
+
+ function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const formData = new FormData(form);
+ const data = parseFormData(formData, Schema);
+ const workflow = generateWorkflow(data);
+ const zipPromise = createZip(workflow, data);
+ handleActionButton(event.nativeEvent, zipPromise, navigate, submit);
+ }
+
+ return (
+ <>
+ Protein-protein docking scenario
+
+ Based on{" "}
+
+ HADDOCK2.4 Protein-protein docking tutorial
+ {" "}
+ and the{" "}
+
+ HADDOCK3 example
+
+ .
+
+
+ >
+ );
+}
diff --git a/app/scenarios/FormDescription.tsx b/app/scenarios/FormDescription.tsx
new file mode 100644
index 00000000..c52ce1cf
--- /dev/null
+++ b/app/scenarios/FormDescription.tsx
@@ -0,0 +1,5 @@
+import { PropsWithChildren } from "react";
+
+export function FormDescription({ children }: PropsWithChildren): JSX.Element {
+ return {children}
;
+}
diff --git a/app/scenarios/FormItem.tsx b/app/scenarios/FormItem.tsx
new file mode 100644
index 00000000..b295ec58
--- /dev/null
+++ b/app/scenarios/FormItem.tsx
@@ -0,0 +1,23 @@
+import { PropsWithChildren } from "react";
+import { FlatErrors } from "valibot";
+import { ErrorMessages } from "~/components/ErrorMessages";
+import { Label } from "~/components/ui/label";
+
+export function FormItem({
+ name,
+ label,
+ children,
+ errors,
+}: PropsWithChildren<{
+ name: string;
+ label: string;
+ errors?: FlatErrors;
+}>) {
+ return (
+
+
+ {children}
+ {errors && }
+
+ );
+}
diff --git a/app/scenarios/PDBFileInput.tsx b/app/scenarios/PDBFileInput.tsx
new file mode 100644
index 00000000..fbfb9056
--- /dev/null
+++ b/app/scenarios/PDBFileInput.tsx
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Viewer } from "~/scenarios/Viewer.client";
+import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
+
+export function PDBFileInput({
+ name,
+ required,
+}: {
+ name: string;
+ required?: boolean;
+}) {
+ const [file, setFile] = useState(undefined);
+ const [open, setOpen] = useState(false);
+
+ function onChange(event: React.ChangeEvent) {
+ event.preventDefault();
+ setFile(event.target.files?.[0]);
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/app/scenarios/Viewer.client.tsx b/app/scenarios/Viewer.client.tsx
new file mode 100644
index 00000000..18c9d708
--- /dev/null
+++ b/app/scenarios/Viewer.client.tsx
@@ -0,0 +1,58 @@
+import { useEffect, useRef, useState } from "react";
+import { Stage } from "ngl";
+
+export function Viewer({ file }: { file: File | undefined }) {
+ const viewportRef = useRef(null);
+ const stage = useRef(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ if (isLoaded) {
+ return;
+ }
+ if (!viewportRef.current) {
+ return;
+ }
+
+ // Create Stage object
+ stage.current = new Stage(viewportRef.current);
+ let backgroundColor = "white";
+ if (document?.documentElement?.classList.contains("dark")) {
+ backgroundColor = "black";
+ } else if (document?.documentElement?.classList.contains("light")) {
+ backgroundColor = "white";
+ } else if (window?.matchMedia("(prefers-color-scheme: dark)").matches) {
+ backgroundColor = "black";
+ }
+ stage.current.setParameters({ backgroundColor });
+
+ if (stage.current === null || file === undefined) {
+ return;
+ }
+ stage.current.loadFile(file, { ext: "pdb" }).then((o) => {
+ if (o === undefined) {
+ console.error("Could not load file");
+ return;
+ }
+ o.addRepresentation("cartoon", { sele: "protein" });
+ o.addRepresentation("ball+stick", { sele: "ligand" });
+ o.addRepresentation("base", { sele: "nucleic" });
+
+ o.autoView();
+ setIsLoaded(true);
+ });
+
+ // TODO clean up messes up the rendering, need to figure out why
+ // return () => {
+ // if (stage.current && file) {
+ // const comps = stage.current.getComponentsByName(file.name);
+ // comps.dispose();
+ // }
+ // if (stage.current) {
+ // stage.current.dispose();
+ // }
+ // };
+ }, [file, isLoaded]);
+
+ return ;
+}
diff --git a/app/scenarios/actions.tsx b/app/scenarios/actions.tsx
new file mode 100644
index 00000000..818ba93a
--- /dev/null
+++ b/app/scenarios/actions.tsx
@@ -0,0 +1,101 @@
+import { NavigateFunction, SubmitFunction } from "@remix-run/react";
+import { Button } from "~/components/ui/button";
+
+export function doUpload(zipPromise: Promise, submit: SubmitFunction) {
+ zipPromise.then((zip) => {
+ // upload archive to server
+ const formData = new FormData();
+ formData.set("upload", zip);
+ submit(formData, {
+ method: "post",
+ encType: "multipart/form-data",
+ });
+ });
+}
+
+export function onRefine(
+ zipPromise: Promise,
+ navigate: NavigateFunction
+) {
+ // add zip to browsers indexeddb haddock3 db, zips object store, key workflow.zip
+ if (typeof indexedDB === "undefined") {
+ console.error("IndexedDB not supported, unable to save workflow.zip file.");
+ return;
+ }
+
+ const open = indexedDB.open("haddock3", 1);
+ open.onerror = function () {
+ console.error("Error opening indexeddb", open.error);
+ };
+ open.onupgradeneeded = function () {
+ const db = open.result;
+ db.createObjectStore("zips");
+ };
+ open.onblocked = function () {
+ console.error("Error opening indexeddb, blocked");
+ };
+ open.onsuccess = function () {
+ zipPromise.then((zip) => {
+ const db = open.result;
+ const tx = db.transaction("zips", "readwrite");
+ const zips = tx.objectStore("zips");
+ const putRequest = zips.put(zip, "workflow.zip");
+ putRequest.onerror = function () {
+ console.error("Error putting zip in indexeddb", putRequest.error);
+ };
+ putRequest.onsuccess = function () {
+ navigate("/builder");
+ };
+ });
+ };
+}
+
+export function onDownload(zipPromise: Promise) {
+ zipPromise.then((zip) => {
+ const url = URL.createObjectURL(zip);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "workflow.zip";
+ a.click();
+ URL.revokeObjectURL(url);
+ });
+}
+
+export function handleActionButton(
+ event: Event,
+ zipPromise: Promise,
+ navigate: NavigateFunction,
+ submit: SubmitFunction
+) {
+ const submitEvent = event as SubmitEvent;
+ const kind = submitEvent.submitter?.getAttribute("value");
+ // TODO validate zip against catalog
+ // now done on server for kind=upload
+ // or on /builder page in devtools console for kind=refine
+ if (kind === "refine") {
+ onRefine(zipPromise, navigate);
+ } else if (kind === "download") {
+ onDownload(zipPromise);
+ } else {
+ doUpload(zipPromise, submit);
+ }
+}
+
+export function ActionButtons() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/app/scenarios/schema.ts b/app/scenarios/schema.ts
new file mode 100644
index 00000000..ffe4deb0
--- /dev/null
+++ b/app/scenarios/schema.ts
@@ -0,0 +1,9 @@
+import { BaseSchema, parse } from "valibot";
+
+export function parseFormData(
+ formData: FormData,
+ schema: T
+) {
+ const obj = Object.fromEntries(formData.entries());
+ return parse(schema, obj);
+}
diff --git a/package-lock.json b/package-lock.json
index 4678c73d..88762bcc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@i-vresse/wb-core": "^1.2.3",
"@i-vresse/wb-form": "^1.1.3",
"@radix-ui/react-checkbox": "^1.0.4",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
@@ -36,6 +37,7 @@
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lucide-react": "^0.321.0",
+ "ngl": "^2.2.2",
"openapi-fetch": "^0.8.2",
"plotly.js": "^2.26.1",
"postgres": "^3.4.3",
@@ -2626,6 +2628,42 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
+ "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.1",
+ "@radix-ui/react-compose-refs": "1.0.1",
+ "@radix-ui/react-context": "1.0.1",
+ "@radix-ui/react-dismissable-layer": "1.0.5",
+ "@radix-ui/react-focus-guards": "1.0.1",
+ "@radix-ui/react-focus-scope": "1.0.4",
+ "@radix-ui/react-id": "1.0.1",
+ "@radix-ui/react-portal": "1.0.4",
+ "@radix-ui/react-presence": "1.0.1",
+ "@radix-ui/react-primitive": "1.0.3",
+ "@radix-ui/react-slot": "1.0.2",
+ "@radix-ui/react-use-controllable-state": "1.0.1",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz",
@@ -10101,6 +10139,14 @@
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
+ "node_modules/immutable": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
+ "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
+ "dev": true,
+ "optional": true,
+ "peer": true
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -15517,6 +15563,25 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/sass": {
+ "version": "1.72.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.72.0.tgz",
+ "integrity": "sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==",
+ "dev": true,
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/sax": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz",
diff --git a/package.json b/package.json
index 6c34e060..564dd60b 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"@i-vresse/wb-core": "^1.2.3",
"@i-vresse/wb-form": "^1.1.3",
"@radix-ui/react-checkbox": "^1.0.4",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
@@ -60,6 +61,7 @@
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"lucide-react": "^0.321.0",
+ "ngl": "^2.2.2",
"openapi-fetch": "^0.8.2",
"plotly.js": "^2.26.1",
"postgres": "^3.4.3",