diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b512c09d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ba6ce3fc6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch chrome against localhost", + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}/apps/editor" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 03d614193..41946d757 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,6 @@ }, "[dockerfile]": { "editor.defaultFormatter": "ms-azuretools.vscode-docker" - } + }, + "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }] } diff --git a/Dockerfile.editor b/Dockerfile.editor new file mode 100644 index 000000000..ad91532c6 --- /dev/null +++ b/Dockerfile.editor @@ -0,0 +1,18 @@ +FROM node:18 as build + +ENV NODE_ENV=production + +WORKDIR /app + +COPY .yarn .yarn +COPY package.json yarn.lock .yarnrc.yml ./ +RUN yarn install + +COPY . . +ARG NX_ANNOTATION_SERVICE_HUB_URL +RUN yarn build editor --prod + +FROM nginx:alpine +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/apps/editor /usr/share/nginx/html +EXPOSE 80 diff --git a/README.md b/README.md index b7d0e1932..06e9e7afa 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Omitting an app name starts the default app. After running this command, the app will be available at the URL printed in the console.
The app will automatically reload if you change any of the source files. +It is possible to use VISIAN with the annotation-service backend. The location of the backend is set via the environment variable `NX_ANNOTATION_SERVICE_HUB_URL`. Assuming the backend is located at `localhost:3000`, you can use the shortcut `yarn start:hub`. + ### `yarn format []` Runs automated code formatting on all applicable file types. diff --git a/apps/ar-demo/src/components/icons/delete.svg b/apps/ar-demo/src/components/icons/delete-ar.svg similarity index 100% rename from apps/ar-demo/src/components/icons/delete.svg rename to apps/ar-demo/src/components/icons/delete-ar.svg diff --git a/apps/ar-demo/src/components/icons/index.ts b/apps/ar-demo/src/components/icons/index.ts index 4f479ec2c..19c501117 100644 --- a/apps/ar-demo/src/components/icons/index.ts +++ b/apps/ar-demo/src/components/icons/index.ts @@ -1,6 +1,6 @@ -export { ReactComponent as SelectIcon } from "./select.svg"; +export { ReactComponent as SelectIcon } from "./select-ar.svg"; export { ReactComponent as EraserIcon } from "./eraser-ar.svg"; -export { ReactComponent as DeleteIcon } from "./delete.svg"; +export { ReactComponent as DeleteIcon } from "./delete-ar.svg"; export { ReactComponent as ClearIcon } from "./clear.svg"; export { ReactComponent as InvertSelectionIcon } from "./invertSelection.svg"; export { ReactComponent as UndoIcon } from "./undo-ar.svg"; diff --git a/apps/ar-demo/src/components/icons/select.svg b/apps/ar-demo/src/components/icons/select-ar.svg similarity index 100% rename from apps/ar-demo/src/components/icons/select.svg rename to apps/ar-demo/src/components/icons/select-ar.svg diff --git a/apps/editor/project.json b/apps/editor/project.json index b428a7b3f..26bc12135 100644 --- a/apps/editor/project.json +++ b/apps/editor/project.json @@ -6,6 +6,7 @@ "targets": { "build": { "executor": "@nrwl/webpack:webpack", + "inputs": [{ "env": "NX_ANNOTATION_SERVICE_HUB_URL" }], "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { diff --git a/apps/editor/src/app/app.tsx b/apps/editor/src/app/app.tsx index d89ee5055..facb4ccf1 100644 --- a/apps/editor/src/app/app.tsx +++ b/apps/editor/src/app/app.tsx @@ -11,16 +11,23 @@ import { Amplify } from "aws-amplify"; import { observer } from "mobx-react-lite"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; -import { Route, Routes } from "react-router-dom"; +import { ReactQueryDevtools } from "react-query/devtools"; +import { Navigate, Route, Routes } from "react-router-dom"; import { whoAwsConfigDeployment, whoAwsConfigDevelopment, whoRequiresAuthentication, } from "../constants"; -import { setUpEventHandling } from "../event-handling"; import type { RootStore } from "../models"; -import { EditorScreen } from "../screens"; +import { hubBaseUrl } from "../queries"; +import { + DatasetScreen, + EditorScreen, + JobScreen, + ProjectScreen, + ProjectsScreen, +} from "../screens"; import { setupRootStore, StoreProvider } from "./root-store"; if (isFromWHO()) { @@ -49,14 +56,8 @@ function App(): JSX.Element { const result = Promise.all([setupRootStore(), initI18n()]).then( ([rootStore]) => { rootStoreRef.current = rootStore; - const [dispatch, dispose] = setUpEventHandling(rootStore); - rootStore.pointerDispatch = dispatch; - setIsReady(true); - return () => { - dispose(); - rootStore.dispose(); - }; + return rootStore.dispose; }, ); return () => { @@ -75,12 +76,36 @@ function App(): JSX.Element { {isReady && ( - - } /> - + {hubBaseUrl ? ( + + } /> + } + /> + } + /> + } /> + } + /> + } /> + } /> + + ) : ( + + } /> + + )} )} + {process.env.NODE_ENV === "development" && ( + + )} ); diff --git a/apps/editor/src/app/root-store.ts b/apps/editor/src/app/root-store.ts index 99ec24ada..5e23fd144 100644 --- a/apps/editor/src/app/root-store.ts +++ b/apps/editor/src/app/root-store.ts @@ -1,9 +1,10 @@ import { i18n, LocalForageBackend } from "@visian/ui-shared"; -import { getWHOTaskIdFromUrl, isFromWHO, readFileFromURL } from "@visian/utils"; +import { isFromWHO, readFileFromURL } from "@visian/utils"; import React from "react"; import { storePersistInterval } from "../constants"; import { RootStore } from "../models"; +import { WHOReviewStrategy } from "../models/review-strategy"; export const storageBackend = new LocalForageBackend( storePersistInterval, @@ -55,10 +56,8 @@ export const setupRootStore = async () => { } if (isFromWHO()) { - // Load scan from WHO - // Example: http://localhost:4200/?origin=who&taskId=0b2fb698-6e1d-4682-a986-78b115178d94 - const taskId = getWHOTaskIdFromUrl(); - if (taskId) await store.loadWHOTask(taskId); + store.setReviewStrategy(new WHOReviewStrategy({ store })); + store.reviewStrategy?.loadTask(); } })(); diff --git a/apps/editor/src/assets/de.json b/apps/editor/src/assets/de.json index 38673aafd..9893ea14c 100644 --- a/apps/editor/src/assets/de.json +++ b/apps/editor/src/assets/de.json @@ -1,8 +1,9 @@ { "loading": "Lädt...", + "error": "Fehler", "reset": "Zurücksetzen", - "drop-file": "Legen Sie Scan oder Annotation hier ab", + "drop-file": "Legen Sie die Datei hier ab", "upload-file": "Datei hochladen", "untitled-document": "Unbenanntes Dokument", @@ -87,7 +88,7 @@ "data-model-outdated-alert": "Datenmodell veraltet. Zurücksetzen notwendig.", "discard-current-document-confirmation": "Aktuelles Dokument verwerfen?", - "erase-application-data-confirmation": "Alle Anwendungsdaten löschen?", + "erase-application-data-confirmation": "Aktuelle Daten im Editor verwerfen?", "layers": "Ebenen", "main-image-layer": "Haupt-Bildebene", @@ -101,6 +102,7 @@ "mark-not-annotation": "Zu nicht Annotation", "export-layer": "Ebene exportieren", "export-slice": "Aktuelle Schicht exportieren", + "duplicate-layer": "Ebene duplizieren", "rename-layer": "Ebene umbenennen", "delete-layer": "Ebene löschen", "delete-layer-confirmation": "Ebene \"{{layer}}\" löschen?", @@ -121,9 +123,17 @@ "export": "Export", "export-tooltip": "Export (Strg + E)", "exporting": "Exportieren", + "layers-to-export": "Annotationsebenen, die exportiert werden sollen", + "export-all-layers": "Alle Ebenen exportieren", + "export-layer-group": "Gruppe exportieren", + "export-as": "Exportieren als", + "no-file-to-export": "Es konnte keine Datei gefunden werden, die exportiert werden soll.", "confirm-task-annotation-tooltip": "Bestätigen", "skip-task-annotation-tooltip": "Überspringen", + "verify-annotation-tooltip": "Verifizieren", + "unverify-annotation-tooltip": "Verifizierung aufheben", + "next-task-tooltip": "Nächste Aufgebe", "browser-error": "Browserfehler", "no-webgl-2-error": "Dieser Browser unterstützt WebGL 2 nicht. Um VISIAN auszuführen ist WebGL 2 notwendig. Bitte updaten Sie Ihren Browser oder nutzen Sie einen anderen Browser.", @@ -200,6 +210,7 @@ "volume-um": "μm³", "volume-m": "m³", "copy": "Kopieren", + "copied": "Kopiert!", "info-unit": "Die Einheit ist in den Daten nicht immer spezifiziert. Bei medizinischen Daten wie MRT- und CT-Bildern handelt es sich meist um mm. Bei generischen Bilddaten wie PNG oder JPG Bildern handelt es sich lediglich um Pixel.", "info-brush": "Der Pinsel ist das einfachste Malwerkzeug von VISIAN. Die Pinselgröße kann angepasst werden und mit Links- bzw. Rechtsklick kann gemalt und radiert werden.", @@ -235,7 +246,7 @@ "save-export": "Speichern & Exportieren", "create-new-document": "Ein neues Dokument erstellen", "save-in-browser": "Im Browser speichern", - "export-current-image": "Annotation als *.nii.gz herunterladen", + "export-current-image": "Annotation als *.zip herunterladen", "export-current-slice": "Aktuelle Schicht der Annotation als *.png herunterladen", "view-types": "Ansicht", "switch-transverse": "Transverse (Axiale) Ansicht auswählen", @@ -262,5 +273,164 @@ "right-click": "Rechtsklick", "middle-click": "Mittelklick", "scroll-up": "Hoch Scrollen", - "scroll-down": "Herunter Scrollen" + "scroll-down": "Herunter Scrollen", + + "select-all": "Alles auswählen", + "deselect-all": "Auswahl aufheben", + "export-documents": "Dokumente exportieren", + "delete-documents": "Dokumente löschen", + "auto-annotate-documents": "Dokumente automatisch annotieren", + "exit-select-mode": "Auswahlmodus verlassen", + "select-mode": "Auswahlmodus", + + "mia": "MIA", + + "project": "Projekt", + "projects": "Projekte", + "dataset": "Datensatz", + "datasets": "Datensätze", + "image": "Bild", + "images": "Bilder", + "job": "Job", + "jobs": "Jobs", + + "projects-base-title": "VISIAN Projekte", + "project-base-title": "VISIAN Projekt", + "datasets-base-title": "VISIAN Datensätze", + "jobs-base-title": "VISIAN Jobs", + "dataset-base-title": "VISIAN Datensatz", + + "projects-loading-failed": "Die Projekte konnten nicht geladen werden.", + "no-projects-available": "Aktuell sind keine Projekte vorhanden.", + + "project-loading-failed": "Das Projekt konnte nicht geladen werden.", + + "datasets-loading": "Datensätze werden geladen...", + "datasets-loading-failed": "Die Datensätze konnten nicht geladen werden.", + "no-datasets-available": "Aktuell sind keine Datensätze vorhanden.", + + "dataset-loading-failed": "Der Datensatz konnte nicht geladen werden.", + + "images-loading": "Bilder werden geladen...", + "images-loading-failed": "Die Bilder konnten nicht geladen werden.", + "images-loading-error": "Fehler beim Laden der Bilder: {{statusText}} {{status}}", + "no-images-available": "Aktuell sind keine Bilder vorhanden.", + "image-open-error": "Fehler beim Öffnen des Bildes", + "image-annotated": "annotiert", + + "create-annotation-file-error": "Die Annotationsdatei konnte nicht erstellt werden.", + "annotations-loading": "Annotationen werden geladen...", + "annotations-loading-failed": "Die Annotationen konnten nicht geladen werden.", + "annotation-open-error": "Fehler beim Öffnen der Annotation", + "annotation-saving": "Aktive Annotationsgruppe speichern", + "annotation-saving-overrwite": "Existierende Annotationsdatei überschreiben", + "annotation-saving-as": "Neue Annotationsdatei erstellen", + "annotation-saving-error": "Fehler beim Speichern der Annotationsebene", + + "ml-models-loading": "Modelle werden geladen...", + "ml-models-loading-error": "Fehler beim Laden der Modelle:", + "ml-models-none-available": "Es sind keine Modelle verfügbar.", + "ml-models-not-found-error": "Modellversion konnte nicht gefunden werden.", + + "jobs-loading-failed": "Die Jobs konnten nicht geladen werden.", + "no-jobs-available": "Aktuell sind keine Jobs vorhanden.", + "job-deleted-images": "Es scheint als wurden alle Bilder des Jobs gelöscht...", + "job-details": "Details", + "job-images": "Bilder", + "job-model-name": "Modell", + "job-model-version": "Version", + "job-started": "Gestartet am", + "job-finished": "Beendet am", + "job-status": "Status", + "job-status-queued": "In Warteschlange", + "job-status-running": "Wird ausgeführt", + "job-status-succeeded": "Erfolgreich", + "job-status-canceled": "Abgebrochen", + "job-status-failed": "Fehlgeschlagen", + "job-creation-popup-title": "Einen neuen Job erstellen", + "job-creation-model-selection": "Modell und Version auswählen", + "job-creation-image-selection": "Bilder zum Annotieren auswählen", + "job-creation-error": "Beim Erstellen des Jobs ist ein Fehler aufgetreten.", + "job-creation-images-selected": "{{count}} Bild ausgewählt", + "job-creation-images-selected_plural": "{{count}} Bilder ausgewählt", + "start-job": "Job starten", + + "import-images": "Bilder importieren", + "image-import-popup-title": "Bilder importieren", + "import-selected-files": "Ausgewählte Bilder", + "import-no-files-selected": "Bitte mind. ein Bild auswählen.", + "image-import": "Bild {{current}} von {{total}} wird importiert...", + "image-import-error-title": "Import Fehler aufgetreten", + "image-import-generic-error-description": "Es wurden {{imported}} von {{total}} Bildern erfolgreich importiert.", + "image-import-duplicate-error-description": "Einige Bilder existierten bereits und wurden übersprungen. Es wurde(n) {{imported}} von {{total}} Bilder(n) erfolgreich importiert.", + "image-import-selected-is-duplicate": "Datei bereits ausgewählt, wird übersprungen", + "image-import-selected-has-invalid-type": "Ungültiger Dateityp, wird übersprungen", + "computer-upload": "Vom Computer hochladen", + "drop-files-or": "Dateien ablegen oder", + "load-from-url": "Von URL laden", + "connect-to-server": "Mit dem Server verbinden", + + "open-editor": "Editor öffnen", + "close-editor": "Editor schließen", + "home": "Zur Projektübersicht", + "back": "Zurück", + + "save": "Speichern", + "save-as": "Speichern Unter", + "saving": "Speichern...", + "saving-error": "Fehler beim Speichern", + "layers-to-save": "Zu speichernde Annotationsebenen", + + "internal-server-error": "Interner Serverfehler", + + "confirm": "Bestätigen", + "cancel": "Abbrechen", + "options": "Optionen", + "delete": "Löschen", + "delete-annotation-title": "Annotation löschen", + "delete-annotation-message": "Wollen Sie die Annotation \"{{name}}\" wirklich löschen?", + "delete-images-title": "Bilder löschen", + "delete-images-message": "Wollen Sie {{count}} Bild(er) sämtlich aller dazugehörigen Annotationen wirklich löschen?", + "delete-image-title": "Bild löschen", + "delete-image-message": "Wollen Sie das Bild \"{{name}}\" sämtlich aller dazugehörigen Annotationen wirklich löschen?", + "delete-dataset-title": "Datensatz löschen", + "delete-dataset-message": "Wollen Sie den Datensatz \"{{name}}\" sämtlich aller enthaltenen Bilder und Annotationen wirklich löschen?", + "delete-project-title": "Projekt löschen", + "delete-project-message": "Wollen sie das Projekt \"{{name}}\" sämtlich aller enthalten Datensätze, Bilder und Annotationen wirklich löschen?", + "delete-job-title": "Job löschen", + "delete-job-message": "Wollen sie den Job sämtlich der {{count}} dabei erzeugten Annotation(en) wirklich löschen?", + "cancel-job-title": "Job abbrechen", + "cancel-job-message": "Wollen sie den Job wirklich abbrechen?", + "open-job-log": "Job Log Datei öffnen", + "job-log": "Job Log", + + "create": "Erstellen", + "create-project": "Projekt erstellen", + "create-dataset": "Datensatz erstellen", + + "edit": "Bearbeiten", + "update": "Aktualisieren", + "name": "Name", + "project-name": "Projektname", + "dataset-name": "Datensatzname", + + "verified": "verifiziert", + + "progress-total": "Alle Bilder", + "progress-verified": "Verifiziert", + "progress-annotated": "Annotiert", + "annotation-progress": "Fortschritt", + "annotation-progress-no-images": "Füge Bilder hinzu, um den Fortschritt zu sehen.", + "annotated-verified-images": "Annotierte und verifizierte Bilder", + "review-annotations": "Review starten", + + "data-uri-help-message": "Die dataURI ist nicht gültig. Sie sollte das Format 'ordner/dateiname.erweiterung' haben, wobei ein Ordner und der Dateiname aus Buchstaben, Ziffern, -, oder _ bestehen kann und die Erweiterung eine der folgenden sein sollte:", + "uri-file-type-mismatch": "Die URI entspricht nicht dem Dateityp \"{{name}}\"", + + "switch-to-grid": "Zur Rasteransicht wechseln", + "switch-to-list": "Zur Listenansicht wechseln", + + "review": "Überprüfen", + "supervise": "Abnicken", + "review-description": "{{taskType}} der Annotationen des Bildes {{image}}." } diff --git a/apps/editor/src/assets/en.json b/apps/editor/src/assets/en.json index ed13b108f..a9f7e9ac4 100644 --- a/apps/editor/src/assets/en.json +++ b/apps/editor/src/assets/en.json @@ -1,8 +1,9 @@ { "loading": "Loading...", + "error": "Error", "reset": "Reset", - "drop-file": "Drop your scan or annotation here", + "drop-file": "Drop your file here", "upload-file": "Upload file", "untitled-document": "Untitled Document", @@ -87,7 +88,7 @@ "data-model-outdated-alert": "Data model outdated. Reset required.", "discard-current-document-confirmation": "Discard the current document?", - "erase-application-data-confirmation": "Erase all application data?", + "erase-application-data-confirmation": "Clear current editor data?", "layers": "Layers", "main-image-layer": "Main Image Layer", @@ -101,6 +102,7 @@ "mark-not-annotation": "Mark as not Annotation", "export-layer": "Export Layer", "export-slice": "Export Current Slice", + "duplicate-layer": "Duplicate Layer", "rename-layer": "Rename Layer", "delete-layer": "Delete Layer", "delete-layer-confirmation": "Delete layer \"{{layer}}\"?", @@ -121,9 +123,17 @@ "export": "Export", "export-tooltip": "Export (Ctrl/Cmd + E)", "exporting": "Exporting", + "layers-to-export": "Layers to export", + "export-all-layers": "Export all layers", + "export-layer-group": "Export layer group", + "export-as": "Export as", + "no-file-to-export": "No file found to export.", "confirm-task-annotation-tooltip": "Confirm", "skip-task-annotation-tooltip": "Skip", + "verify-annotation-tooltip": "Verify", + "unverify-annotation-tooltip": "Unverify", + "next-task-tooltip": "Next Task", "browser-error": "Browser Error", "no-webgl-2-error": "This browser does not support WebGL 2 which is necessary to run VISIAN. Please update your browser to the newest version or use a different browser.", @@ -200,6 +210,7 @@ "volume-um": "μm³", "volume-m": "m³", "copy": "Copy", + "copied": "Copied!", "info-unit": "The unit is not always specified in the data. For medical images such as MRI and CT it is usually mm. For generic images such as PNG or JPG it is simply pixels.", "info-brush": "The brush is the most simple drawing tool of VISIAN. You can change the size of the brush and use left- and right-click to draw and erase.", @@ -235,7 +246,7 @@ "save-export": "Save & Export", "create-new-document": "Create a new document", "save-in-browser": "Save in browser", - "export-current-image": "Download annotation as *.nii.gz", + "export-current-image": "Download annotation as *.zip", "export-current-slice": "Download the current annotation slice as *.png", "view-types": "View Types", "switch-transverse": "Switch to Transverse (Axial) view", @@ -262,5 +273,165 @@ "right-click": "Right Click", "middle-click": "Middle Click", "scroll-up": "Scroll Up", - "scroll-down": "Scroll Down" + "scroll-down": "Scroll Down", + + "select-all": "Select all", + "deselect-all": "Deselect all", + "export-documents": "Export documents", + "delete-documents": "Delete documents", + "auto-annotate-documents": "Auto-annotate documents", + "exit-select-mode": "Exit select mode", + "select-mode": "Select mode", + + "mia": "MIA", + + "project": "Project", + "projects": "Projects", + "dataset": "Dataset", + "datasets": "Datasets", + "data": "Data", + "image": "Image", + "images": "Images", + "job": "Job", + "jobs": "Jobs", + + "projects-base-title": "VISIAN Projects", + "project-base-title": "VISIAN Project", + "datasets-base-title": "VISIAN Datasets", + "jobs-base-title": "VISIAN Jobs", + "dataset-base-title": "VISIAN Dataset", + + "projects-loading-failed": "The projects could not be loaded.", + "no-projects-available": "Currently no projects are available.", + + "project-loading-failed": "The project could not be loaded.", + + "datasets-loading": "Loading datasets...", + "datasets-loading-failed": "The datasets could not be loaded.", + "no-datasets-available": "Currently no datasets are available.", + + "dataset-loading-failed": "The dataset could not be loaded.", + + "images-loading": "Loading images...", + "images-loading-failed": "The images could not be loaded.", + "images-loading-error": "Error on loading images: {{statusText}} {{status}}", + "no-images-available": "Currently no images are available.", + "image-open-error": "Error on opening image", + "image-annotated": "annotated", + + "create-annotation-file-error": "Could not create annotation file.", + "annotations-loading": "Loading annotations...", + "annotations-loading-failed": "The annotations could not be loaded.", + "annotation-open-error": "Error on opening annotation", + "annotation-saving": "Save active annotation group", + "annotation-saving-overrwite": "Overwrite existing annotation file", + "annotation-saving-as": "Create new annotation file", + "annotation-saving-error": "Error saving annotation layer", + + "ml-models-loading": "models loading...", + "ml-models-loading-error": "Error on loading ml models:", + "ml-models-none-available": "There are no models available.", + "ml-models-not-found-error": "Unable to find model version.", + + "jobs-loading-failed": "The jobs could not be loaded.", + "no-jobs-available": "Currently no jobs are available.", + "job-deleted-images": "It seems like all images for this job were deleted...", + "job-images": "Images", + "job-details": "Details", + "job-model-name": "Model", + "job-model-version": "Version", + "job-started": "Started at", + "job-finished": "Finished at", + "job-status": "Status", + "job-status-queued": "queued", + "job-status-running": "running", + "job-status-succeeded": "succeeded", + "job-status-canceled": "canceled", + "job-status-failed": "failed", + "job-creation-popup-title": "Create a new job", + "job-creation-model-selection": "Select a model version", + "job-creation-image-selection": "Select the images to annotate", + "job-creation-images-selected": "{{count}} image selected", + "job-creation-images-selected_plural": "{{count}} images selected", + "job-creation-error": "An error occurred while creating the job.", + "start-job": "Start Job", + + "import-images": "Import Images", + "image-import-popup-title": "Import Images", + "import-selected-files": "Selected Images", + "import-no-files-selected": "Please select at least one image.", + "importing-images": "Importing image {{current}} of {{total}}...", + "image-import-error-title": "Errors occurred during import", + "image-import-generic-error-description": "{{imported}} out of {{total}} images have been successfully imported.", + "image-import-duplicate-error-description": "Some images already existed and have been skipped. {{imported}} out of {{total}} images have been successfully imported.", + "image-import-selected-is-duplicate": "Duplicate, will be skipped", + "image-import-selected-has-invalid-type": "Invalid file type, will be skipped", + "computer-upload": "Upload from Computer", + "drop-files-or": "Drop files or", + "load-from-url": "Load from URL", + "connect-to-server": "Connect to Server", + + "open-editor": "Open editor", + "close-editor": "Close editor", + "home": "Go to projects overview", + "back": "Go back", + + "save": "Save", + "save-as": "Save as", + "saving": "Saving...", + "saving-error": "Saving Error", + "layers-to-save": "Layers to save", + + "internal-server-error": "Internal Server Error", + + "confirm": "Confirm", + "cancel": "Cancel", + "options": "Options", + "delete": "Delete", + "delete-annotation-title": "Delete Annotation", + "delete-annotation-message": "Do you really want to delete the annotation \"{{name}}\"?", + "delete-images-title": "Delete Images", + "delete-images-message": "Do you really want to delete {{count}} image(s) including its annotations?", + "delete-image-title": "Delete Image", + "delete-image-message": "Do you really want to delete the image \"{{name}}\" including all its annotations?", + "delete-dataset-title": "Delete Dataset", + "delete-dataset-message": "Do you really want to delete the dataset \"{{name}}\" including all its images and annotations?", + "delete-project-title": "Delete Project", + "delete-project-message": "Do you really want to delete the project \"{{name}}\" including all its datasets, Images and annotations?", + "delete-job-title": "Delete Job", + "delete-job-message": "Do you really want to delete the job including the {{count}} generated annotation(s)?", + "cancel-job-title": "Cancel Job", + "cancel-job-message": "Do you really want to cancel the job?", + "open-job-log": "Open Job Log File", + "job-log": "Job Log", + + "create": "Create", + "create-project": "Create Project", + "create-dataset": "Create Dataset", + + "edit": "Edit", + "update": "Update", + "name": "Name", + "project-name": "Project Name", + "dataset-name": "Dataset Name", + + "verified": "verified", + + "progress-total": "Total Images", + "progress-verified": "Verified", + "progress-annotated": "Annotated", + "annotation-progress": "Progress", + "annotation-progress-no-images": "Add images to see the annotation progress.", + "annotated-verified-images": "Annotated and Verified Images", + "review-annotations": "Start Review", + + "data-uri-help-message": "The dataURI is not valid. It should have a format like 'folders/filename.extension' where a folder and the filename can consist of letters, digits, -, or _ and extension should be one of the following:", + "uri-file-type-mismatch": "URI does not match file type \"{{name}}\"", + + "switch-to-grid": "Switch to grid view", + "switch-to-list": "Switch to list view", + + "review": "Review", + "supervise": "Supervise", + "review-description": "{{taskType}} the annotations of {{image}}" } diff --git a/apps/editor/src/assets/images/BraTS_Prev.png b/apps/editor/src/assets/images/BraTS_Prev.png new file mode 100644 index 000000000..0da7aef1d Binary files /dev/null and b/apps/editor/src/assets/images/BraTS_Prev.png differ diff --git a/apps/editor/src/assets/images/BraTS_Prev_seg.png b/apps/editor/src/assets/images/BraTS_Prev_seg.png new file mode 100644 index 000000000..1f7340f0c Binary files /dev/null and b/apps/editor/src/assets/images/BraTS_Prev_seg.png differ diff --git a/apps/editor/src/components/data-manager/annotation-progress/annotation-progress.tsx b/apps/editor/src/components/data-manager/annotation-progress/annotation-progress.tsx new file mode 100644 index 000000000..fd4d793cc --- /dev/null +++ b/apps/editor/src/components/data-manager/annotation-progress/annotation-progress.tsx @@ -0,0 +1,65 @@ +import { Button, ProgressBar, Sheet, space, Text } from "@visian/ui-shared"; +import { MiaProgress } from "@visian/utils"; +import styled from "styled-components"; + +const ProgressSheet = styled(Sheet)` + padding: ${space("pageSectionMarginSmall")}; + display: flex; + align-items: start; +`; + +const ProgressTitle = styled(Text)` + margin-bottom: ${space("pageSectionMarginSmall")}; + font-size: 15pt; +`; + +const ProgressButton = styled(Button)` + display: flex; + align-items: center; + justify-content: center; + align-self: end; + margin-top: ${space("pageSectionMarginSmall")}; + + svg { + width: 32px; + height: 32px; + margin-left: -7px; + } +`; + +const ButtonText = styled(Text)` + margin-left: 7px; +`; + +export const AnnotationProgress = ({ + progress, + onReviewClick, +}: { + progress: MiaProgress; + onReviewClick?: () => void; +}) => ( + + + + {onReviewClick && ( + + + + )} + +); diff --git a/apps/editor/src/components/data-manager/annotation-progress/index.ts b/apps/editor/src/components/data-manager/annotation-progress/index.ts new file mode 100644 index 000000000..55c4cfa37 --- /dev/null +++ b/apps/editor/src/components/data-manager/annotation-progress/index.ts @@ -0,0 +1 @@ +export * from "./annotation-progress"; diff --git a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts new file mode 100644 index 000000000..cc3e81c07 --- /dev/null +++ b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.props.ts @@ -0,0 +1,13 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; + +export interface ConfirmationPopUpProps extends StatefulPopUpProps { + title?: string; + titleTx?: string; + message?: string; + messageTx?: string; + confirm?: string; + confirmTx?: string; + cancel?: string; + cancelTx?: string; + onConfirm?: () => void; +} diff --git a/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx new file mode 100644 index 000000000..b86ad502c --- /dev/null +++ b/apps/editor/src/components/data-manager/confirmation-popup/confirmation-popup.tsx @@ -0,0 +1,69 @@ +import { ButtonParam, PopUp, Text } from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import { useCallback } from "react"; +import styled from "styled-components"; + +import { ConfirmationPopUpProps } from "./confirmation-popup.props"; + +const StyledTextButton = styled(ButtonParam)` + margin: 0px; + width: auto; +`; + +const InlineRow = styled.div` + display: flex; + justify-content: center; + gap: 2rem; + width: 100%; + margin-top: 30px; +`; + +const ConfirmationPopupContainer = styled(PopUp)` + align-items: left; + width: 400px; +`; + +export const ConfirmationPopup = observer( + ({ + isOpen, + onClose, + onConfirm, + title, + titleTx, + message, + messageTx, + confirm, + confirmTx, + cancel, + cancelTx, + }) => { + const handleConfirmation = useCallback(() => { + onConfirm?.(); + onClose?.(); + }, [onClose, onConfirm]); + + return ( + + + + + + + + ); + }, +); diff --git a/apps/editor/src/components/data-manager/confirmation-popup/index.ts b/apps/editor/src/components/data-manager/confirmation-popup/index.ts new file mode 100644 index 000000000..1212bad60 --- /dev/null +++ b/apps/editor/src/components/data-manager/confirmation-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./confirmation-popup"; +export * from "./confirmation-popup.props"; diff --git a/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.props.ts b/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.props.ts new file mode 100644 index 000000000..f97c4b825 --- /dev/null +++ b/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.props.ts @@ -0,0 +1,5 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; + +export interface DatasetCreationPopupProps extends StatefulPopUpProps { + onConfirm?: ({ name }: { name: string }) => void; +} diff --git a/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.tsx b/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.tsx new file mode 100644 index 000000000..9a1c3886f --- /dev/null +++ b/apps/editor/src/components/data-manager/dataset-creation-popup/dataset-creation-popup.tsx @@ -0,0 +1,97 @@ +import { ButtonParam, PopUp, TextField } from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import { useCallback, useState } from "react"; +import styled from "styled-components"; + +import { DatasetCreationPopupProps } from "./dataset-creation-popup.props"; + +const StyledTextButton = styled(ButtonParam)` + margin: 0px; + width: auto; +`; + +const InlineRow = styled.div` + display: flex; + justify-content: center; + gap: 2rem; + width: 100%; + margin-top: 30px; +`; + +const DatasetCreationPopupContainer = styled(PopUp)` + align-items: left; + width: 400px; +`; + +const TextInput = styled(TextField)` + margin: auto; + width: 100%; +`; + +const StyledForm = styled.form` + width: 100%; +`; + +export const DatasetCreationPopup = observer( + ({ isOpen, onClose, onConfirm }) => { + const [name, setName] = useState(""); + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); + + const clearInputsAndClose = useCallback(() => { + setName(""); + onClose?.(); + }, [onClose]); + + const handleCreation = useCallback(() => { + if (name !== "") { + onConfirm?.({ name }); + } + clearInputsAndClose(); + }, [name, onConfirm, clearInputsAndClose]); + + const updateName = useCallback( + (e) => { + setName(e.target.value); + setIsSubmitDisabled(false); + }, + [setName], + ); + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + if (!isSubmitDisabled) handleCreation(); + }, + [handleCreation, isSubmitDisabled], + ); + + return ( + + + + + + + + + + ); + }, +); diff --git a/apps/editor/src/components/data-manager/dataset-creation-popup/index.ts b/apps/editor/src/components/data-manager/dataset-creation-popup/index.ts new file mode 100644 index 000000000..9de7deb06 --- /dev/null +++ b/apps/editor/src/components/data-manager/dataset-creation-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./dataset-creation-popup"; +export * from "./dataset-creation-popup.props"; diff --git a/apps/editor/src/components/data-manager/dataset-page/dataset-page.tsx b/apps/editor/src/components/data-manager/dataset-page/dataset-page.tsx new file mode 100644 index 000000000..f9fd2b57d --- /dev/null +++ b/apps/editor/src/components/data-manager/dataset-page/dataset-page.tsx @@ -0,0 +1,275 @@ +import { Notification, useTranslation } from "@visian/ui-shared"; +import { MiaAnnotation, MiaDataset, MiaImage } from "@visian/utils"; +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { MiaReviewStrategy } from "../../../models/review-strategy"; +import { + useDeleteAnnotationsForImageMutation, + useDeleteImagesMutation, + useImagesByDataset, +} from "../../../queries"; +import { useDatasetProgress } from "../../../queries/use-dataset-progress"; +import { AnnotationProgress } from "../annotation-progress"; +import { ConfirmationPopup } from "../confirmation-popup"; +import { ImageImportPopup } from "../image-import-popup"; +import { ImageList } from "../image-list"; +import { JobCreationPopup } from "../job-creation-popup"; +import { PaddedPageSectionIconButton, PageSection } from "../page-section"; +import { PageTitle } from "../page-title"; +import { useImageSelection, usePopUpState } from "../util"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; +`; + +const ErrorNotification = styled(Notification)` + position: absolute; + min-width: 30%; + left: 50%; + bottom: 15%; + transform: translateX(-50%); +`; + +const ActionContainer = styled.div` + display: flex; + align-items: center; +`; + +const ActionIconButton = styled(PaddedPageSectionIconButton)` + height: 25px; +`; + +export const DatasetPage = ({ + dataset, + isDraggedOver, + onDropCompleted, +}: { + dataset: MiaDataset; + isDraggedOver: boolean; + onDropCompleted: () => void; +}) => { + const store = useStore(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { progress, isLoadingProgress } = useDatasetProgress(dataset.id); + + const { images, imagesError, isLoadingImages, refetchImages } = + useImagesByDataset(dataset.id); + + const { selectedImages, selectImages } = useImageSelection(); + + const { deleteImages } = useDeleteImagesMutation(dataset.id); + const { deleteAnnotations } = useDeleteAnnotationsForImageMutation(); + + const [annotationTobBeDeleted, setAnnotationTobBeDeleted] = + useState(); + + const [imagesTobBeDeleted, setImagesTobBeDeleted] = useState([]); + + // Delete annotation confirmation popup + const [ + isDeleteAnnotationConfirmationPopUpOpen, + openDeleteAnnotationConfirmationPopUp, + closeDeleteAnnotationConfirmationPopUp, + ] = usePopUpState(false); + + // Image delete popup + const [ + isDeleteImagesConfirmationPopUpOpen, + openDeleteImagesConfirmationPopUp, + closeDeleteImagesConfirmationPopUp, + ] = usePopUpState(false); + const openDeletePopup = useCallback( + (imagesToDelete: MiaImage[]) => { + setImagesTobBeDeleted(imagesToDelete); + openDeleteImagesConfirmationPopUp(); + }, + [setImagesTobBeDeleted, openDeleteImagesConfirmationPopUp], + ); + const closeDeleteImagesConfirmationPopUpAndClearSelection = + useCallback(() => { + closeDeleteImagesConfirmationPopUp(); + setImagesTobBeDeleted([]); + selectImages([]); + }, [closeDeleteImagesConfirmationPopUp, selectImages]); + const deleteSelectedImages = useCallback(() => { + deleteImages(imagesTobBeDeleted.map((image) => image.id)); + }, [imagesTobBeDeleted, deleteImages]); + + // Job selection popup + const [jobCreationPopUpOpenWith, setJobCreationPopUpOpenWith] = + useState(); + const openJobCreationPopUp = useCallback(() => { + setJobCreationPopUpOpenWith(dataset.id); + }, [dataset.id]); + const closeJobCreationPopUp = useCallback(() => { + setJobCreationPopUpOpenWith(undefined); + selectImages([]); + }, [selectImages]); + + // Image import popup + const [imageImportPopUpOpenWith, setImageImportPopUpOpenWith] = + useState(); + const openImageImportPopUp = useCallback(() => { + setImageImportPopUpOpenWith(dataset); + }, [dataset]); + const closeImageImportPopUp = useCallback(() => { + setImageImportPopUpOpenWith(undefined); + }, []); + + const handleImageImportDropCompleted = useCallback(() => { + setImageImportPopUpOpenWith(dataset); + onDropCompleted(); + }, [setImageImportPopUpOpenWith, dataset, onDropCompleted]); + + const deleteAnnotation = useCallback( + (annotation: MiaAnnotation) => { + setAnnotationTobBeDeleted(annotation); + openDeleteAnnotationConfirmationPopUp(); + }, + [setAnnotationTobBeDeleted, openDeleteAnnotationConfirmationPopUp], + ); + + const handleAnnotationConfirmation = useCallback(() => { + if (annotationTobBeDeleted) + deleteAnnotations({ + imageId: annotationTobBeDeleted.image, + annotationIds: [annotationTobBeDeleted.id], + }); + }, [annotationTobBeDeleted, deleteAnnotations]); + + const startReviewWithDataset = useCallback( + () => + store?.startReview( + async () => MiaReviewStrategy.fromDataset(store, dataset.id), + navigate, + ), + [navigate, dataset, store], + ); + const startReviewWithSelected = useCallback( + (imagesToReview: MiaImage[]) => + store?.startReview( + async () => + MiaReviewStrategy.fromImageIds(store, [ + ...imagesToReview.map((i) => i.id), + ]), + navigate, + ), + [navigate, store], + ); + + let listInfoTx; + if (imagesError) listInfoTx = "images-loading-failed"; + else if (images && images.length === 0) listInfoTx = "no-images-available"; + + let progressInfoTx; + if (progress?.totalImages === 0) + progressInfoTx = "annotation-progress-no-images"; + + return ( + + + + {progress && ( + + )} + + + + + } + > + {images && ( + + )} + + + + + {store?.error && ( + + )} + + + ); +}; diff --git a/apps/editor/src/components/data-manager/dataset-page/index.ts b/apps/editor/src/components/data-manager/dataset-page/index.ts new file mode 100644 index 000000000..3bc27dbe1 --- /dev/null +++ b/apps/editor/src/components/data-manager/dataset-page/index.ts @@ -0,0 +1 @@ +export * from "./dataset-page"; diff --git a/apps/editor/src/components/data-manager/datasets-section/datasets-section.tsx b/apps/editor/src/components/data-manager/datasets-section/datasets-section.tsx new file mode 100644 index 000000000..136b22cc7 --- /dev/null +++ b/apps/editor/src/components/data-manager/datasets-section/datasets-section.tsx @@ -0,0 +1,189 @@ +import { useTranslation } from "@visian/ui-shared"; +import { MiaDataset, MiaProject } from "@visian/utils"; +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import useDatasetsBy, { + useCreateDatasetMutation, + useDeleteDatasetsForProjectMutation, + useUpdateDatasetsMutation, +} from "../../../queries/use-datasets-by"; +import { ConfirmationPopup } from "../confirmation-popup"; +import { DatasetCreationPopup } from "../dataset-creation-popup"; +import { EditPopup } from "../edit-popup"; +import { PaddedPageSectionIconButton, PageSection } from "../page-section"; +import useLocalStorageToggle from "../util/use-local-storage"; +import { GridView } from "../views/grid-view"; +import { ListView } from "../views/list-view"; + +const Container = styled.div` + display: flex; + align-items: center; +`; + +const StyledIconButton = styled(PaddedPageSectionIconButton)` + height: 25px; +`; + +export const DatasetsSection = ({ project }: { project: MiaProject }) => { + const { t: translate } = useTranslation(); + const navigate = useNavigate(); + + const { datasets, isLoadingDatasets, datasetsError } = useDatasetsBy( + project.id, + ); + const [datasetTobBeDeleted, setDatasetTobBeDeleted] = useState(); + const [datasetToBeUpdated, setDatasetToBeUpdated] = useState(); + const { deleteDatasets } = useDeleteDatasetsForProjectMutation(); + const { createDataset } = useCreateDatasetMutation(); + const updateDataset = useUpdateDatasetsMutation(); + + // Delete Dataset Confirmation + const [ + isDeleteDatasetConfirmationPopUpOpen, + setIsDeleteDatasetConfirmationPopUpOpen, + ] = useState(false); + const openDeleteDatasetConfirmationPopUp = useCallback(() => { + setIsDeleteDatasetConfirmationPopUpOpen(true); + }, []); + const closeDeleteDatasetConfirmationPopUp = useCallback(() => { + setIsDeleteDatasetConfirmationPopUpOpen(false); + }, []); + + // Create Dataset + const [isCreateDatasetPopupOpen, setIsCreateDatasetPopupOpen] = + useState(false); + const openCreateDatasetPopup = useCallback( + () => setIsCreateDatasetPopupOpen(true), + [], + ); + const closeCreateDatasetPopup = useCallback( + () => setIsCreateDatasetPopupOpen(false), + [], + ); + + // Delete Dataset + const deleteDataset = useCallback( + (dataset: MiaDataset) => { + setDatasetTobBeDeleted(dataset); + openDeleteDatasetConfirmationPopUp(); + }, + [setDatasetTobBeDeleted, openDeleteDatasetConfirmationPopUp], + ); + + // Open Dataset + const openDataset = useCallback( + (dataset: MiaDataset) => { + navigate(`/datasets/${dataset.id}`); + }, + [navigate], + ); + + // Edit Dataset + const [isEditPopupOpen, setIsEditPopupOpen] = useState(false); + const openEditPopup = useCallback(() => setIsEditPopupOpen(true), []); + const closeEditPopup = useCallback(() => setIsEditPopupOpen(false), []); + + const editDataset = useCallback( + (dataset: MiaDataset) => { + setDatasetToBeUpdated(dataset); + openEditPopup(); + }, + [setDatasetToBeUpdated, openEditPopup], + ); + + const confirmDeleteDataset = useCallback(() => { + if (datasetTobBeDeleted) + deleteDatasets({ + projectId: project.id, + datasetIds: [datasetTobBeDeleted.id], + }); + }, [datasetTobBeDeleted, deleteDatasets, project]); + + const confirmCreateDataset = useCallback( + (newDatasetDto) => createDataset({ ...newDatasetDto, project: project.id }), + [createDataset, project], + ); + + // Switch between List and Grid View + const [isGridView, setIsGridView] = useLocalStorageToggle( + "isGridViewDatasets", + true, + ); + const toggleGridView = useCallback(() => { + setIsGridView((prev: boolean) => !prev); + }, [setIsGridView]); + + let datasetsInfoTx; + if (datasetsError) datasetsInfoTx = "datasets-loading-failed"; + else if (datasets && datasets.length === 0) + datasetsInfoTx = "no-datasets-available"; + + return ( + + + + + } + > + {datasets && + (isGridView ? ( + + ) : ( + + ))} + + + {datasetToBeUpdated && ( + + updateDataset.mutate({ ...datasetToBeUpdated, name: newName }) + } + /> + )} + + ); +}; diff --git a/apps/editor/src/components/data-manager/datasets-section/index.ts b/apps/editor/src/components/data-manager/datasets-section/index.ts new file mode 100644 index 000000000..5067c1693 --- /dev/null +++ b/apps/editor/src/components/data-manager/datasets-section/index.ts @@ -0,0 +1 @@ +export * from "./datasets-section"; diff --git a/apps/editor/src/components/data-manager/edit-popup/edit-popup.props.ts b/apps/editor/src/components/data-manager/edit-popup/edit-popup.props.ts new file mode 100644 index 000000000..393005113 --- /dev/null +++ b/apps/editor/src/components/data-manager/edit-popup/edit-popup.props.ts @@ -0,0 +1,6 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; + +export interface EditPopupProps extends StatefulPopUpProps { + oldName: string; + onConfirm?: (name: string) => void; +} diff --git a/apps/editor/src/components/data-manager/edit-popup/edit-popup.tsx b/apps/editor/src/components/data-manager/edit-popup/edit-popup.tsx new file mode 100644 index 000000000..adc23f1da --- /dev/null +++ b/apps/editor/src/components/data-manager/edit-popup/edit-popup.tsx @@ -0,0 +1,101 @@ +import { ButtonParam, PopUp, TextField } from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; + +import { EditPopupProps } from "./edit-popup.props"; + +const StyledTextButton = styled(ButtonParam)` + margin: 0px; + width: auto; +`; + +const InlineRow = styled.div` + display: flex; + justify-content: center; + gap: 2rem; + width: 100%; + margin-top: 30px; +`; + +const EditPopupContainer = styled(PopUp)` + align-items: left; + width: 400px; +`; + +const TextInput = styled(TextField)` + margin: auto; + width: 100%; +`; + +const StyledForm = styled.form` + width: 100%; +`; + +export const EditPopup = observer( + ({ oldName, isOpen, onClose, onConfirm }) => { + const [name, setName] = useState(oldName); + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); + + const clearInputsAndClose = useCallback(() => { + setName(oldName); + onClose?.(); + }, [onClose, oldName]); + + useEffect(() => { + setName(oldName); + }, [oldName]); + + const handleEdit = useCallback(() => { + if (name !== "" && name !== oldName) { + onConfirm?.(name); + } + clearInputsAndClose(); + }, [name, oldName, clearInputsAndClose, onConfirm]); + + const updateName = useCallback( + (e) => { + setName(e.target.value); + setIsSubmitDisabled(false); + }, + [setName], + ); + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + if (!isSubmitDisabled) handleEdit(); + }, + [handleEdit, isSubmitDisabled], + ); + + return ( + + + + + + + + + + ); + }, +); diff --git a/apps/editor/src/components/data-manager/edit-popup/index.ts b/apps/editor/src/components/data-manager/edit-popup/index.ts new file mode 100644 index 000000000..cf2a7c914 --- /dev/null +++ b/apps/editor/src/components/data-manager/edit-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./edit-popup"; +export * from "./edit-popup.props"; diff --git a/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.props.ts b/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.props.ts new file mode 100644 index 000000000..f469f7753 --- /dev/null +++ b/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.props.ts @@ -0,0 +1,9 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; +import type { MiaDataset } from "@visian/utils"; + +export interface ImageImportPopUpProps extends StatefulPopUpProps { + dataset?: MiaDataset; + onImportFinished: () => void; + isDraggedOver: boolean; + onDropCompleted: () => void; +} diff --git a/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.tsx b/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.tsx new file mode 100644 index 000000000..43bbf425a --- /dev/null +++ b/apps/editor/src/components/data-manager/image-import-popup/image-import-popup.tsx @@ -0,0 +1,343 @@ +/* eslint-disable max-len */ +import { + Button, + InvisibleButton, + LargePopUp, + LargePopUpGroup, + LargePopUpGroupTitle, + List, + ListItem, + Notification, + space, + Text, + useFilePicker, + useTranslation, +} from "@visian/ui-shared"; +import { promiseAllInBatches } from "@visian/utils"; +import { AxiosError } from "axios"; +import { observer } from "mobx-react-lite"; +import { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; + +import { postImage } from "../../../queries"; +import { DropSheet } from "../../editor"; +import { ProgressPopUp } from "../../editor/progress-popup"; +import { WarningLabel } from "../warning-label"; +import { ImageImportPopUpProps } from "./image-import-popup.props"; + +const DropZoneContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; + height: 120px; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.3); +`; + +const DropZoneLabel = styled(Text)` + font-size: 14px; + margin-right: 10px; +`; + +const ExpandingLargePopUpGroup = styled(LargePopUpGroup)` + display: flex; + flex-direction: column; + min-height: 0; + flex-grow: 1; +`; + +const InfoText = styled(Text)` + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-size: 14px; +`; + +const FileList = styled(List)` + height: 100%; + overflow-y: auto; +`; + +const FileTitleContainer = styled.div` + display: flex; + flex-direction: row; + overflow-x: hidden; +`; + +const FileEntry = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const FileEntryText = styled(Text)` + min-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const IconButton = styled(InvisibleButton)` + flex: 0 0 20px; +`; + +const Footer = styled(LargePopUpGroup)` + margin: 0; + display: flex; + justify-content: flex-end; + align-items: center; +`; + +const CancelButton = styled(InvisibleButton)` + padding: ${space("buttonPadding")}; +`; + +const ImportButton = styled(Button)` + min-width: 110px; +`; + +const ErrorNotification = styled(Notification)` + position: absolute; + min-width: 15%; + left: 50%; + bottom: 12%; + transform: translateX(-50%); +`; + +type SelectedFile = { + file: File; + hasInvalidType?: boolean; + isDuplicate?: boolean; +}; + +const sanitizeForFS = (name: string) => + name.replace(/[^a-z0-9_-]/gi, "_").toLowerCase(); + +export const ImageImportPopup = observer( + ({ + isOpen, + onClose, + dataset, + onImportFinished, + isDraggedOver, + onDropCompleted, + }) => { + const [selectedFiles, setSelectedFiles] = useState([]); + const [isImporting, setIsImporting] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState(0); + const [importError, setImportError] = useState<{ + type: "generic" | "duplicate"; + imageName: string; + totalImages: number; + }>(); + const { t } = useTranslation(); + + useEffect(() => { + if (isOpen) setImportError(undefined); + }, [isOpen]); + + const addSelectedFiles = useCallback( + (files: FileList) => { + const newFiles = Array.from(files).map((file) => { + const isDuplicate = selectedFiles.some( + (f) => f.file.name === file.name, + ); + const hasInvalidType = !file.name.match(/\.(nii\.gz|dcm|nii)$/i); + return { file, isDuplicate, hasInvalidType }; + }); + setSelectedFiles((prevFiles) => [...prevFiles, ...newFiles]); + }, + [selectedFiles], + ); + + const importFilesFromInput = useCallback( + (event: Event) => { + const { files } = event.target as HTMLInputElement; + if (!files) return; + addSelectedFiles(files); + }, + [addSelectedFiles], + ); + + const importFilesFromDrop = useCallback( + async (files: FileList) => { + addSelectedFiles(files); + onDropCompleted(); + }, + [addSelectedFiles, onDropCompleted], + ); + + const openFilePicker = useFilePicker(importFilesFromInput); + + const removeSelectedFile = useCallback( + (selection: SelectedFile) => + setSelectedFiles((files) => files.filter((f) => f !== selection)), + [], + ); + + const cancelImport = useCallback(() => { + setSelectedFiles([]); + if (onClose) onClose(); + }, [onClose]); + + const validFiles = selectedFiles.filter( + (file) => !file.hasInvalidType && !file.isDuplicate, + ); + const totalFiles = validFiles.length; + const importImages = useCallback(async () => { + if (!dataset?.id) return; + setUploadedFiles(0); + setIsImporting(true); + const batchSize = 5; + await promiseAllInBatches( + async (selectedFile) => { + try { + const datasetName = sanitizeForFS(dataset.name); + await postImage( + dataset.id, + `${datasetName}/${selectedFile.file.name}`, + selectedFile.file, + ); + setUploadedFiles((prevUploadedFiles) => prevUploadedFiles + 1); + } catch (error) { + if (!(error instanceof AxiosError)) throw error; + if (error.response?.data.message.includes("exists already")) { + setImportError({ + type: "duplicate", + imageName: selectedFile.file.name, + totalImages: totalFiles, + }); + } else { + setImportError({ + type: "generic", + imageName: selectedFile.file.name, + totalImages: totalFiles, + }); + } + // eslint-disable-next-line no-console + console.error( + `Error while uploading ${selectedFile.file.name}:`, + error, + ); + } + }, + validFiles, + batchSize, + ); + onImportFinished(); + setIsImporting(false); + setSelectedFiles([]); + if (onClose) onClose(); + }, [dataset, validFiles, onImportFinished, onClose, totalFiles]); + + const resetImportError = useCallback( + () => setImportError(undefined), + [setImportError], + ); + + if (isImporting) { + return ( + + ); + } + + const errorNotification = + importError?.type === "duplicate" ? ( + + ) : importError?.type === "generic" ? ( + + ) : undefined; + + return ( + <> + {errorNotification} + {isDraggedOver && ( + + )} + + + + + + + + + + {selectedFiles.length === 0 ? ( + + ) : ( + + {selectedFiles.map((selection, index) => ( + + + + + {selection.isDuplicate && ( + + )} + {selection.hasInvalidType && ( + + )} + + removeSelectedFile(selection)} + /> + + + ))} + + )} + +
+ +
+
+ + ); + }, +); diff --git a/apps/editor/src/components/data-manager/image-import-popup/index.ts b/apps/editor/src/components/data-manager/image-import-popup/index.ts new file mode 100644 index 000000000..eeab485d3 --- /dev/null +++ b/apps/editor/src/components/data-manager/image-import-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./image-import-popup"; +export * from "./image-import-popup.props"; diff --git a/apps/editor/src/components/data-manager/image-list/image-list-item.props.ts b/apps/editor/src/components/data-manager/image-list/image-list-item.props.ts new file mode 100644 index 000000000..5bc302cfc --- /dev/null +++ b/apps/editor/src/components/data-manager/image-list/image-list-item.props.ts @@ -0,0 +1,13 @@ +import { MiaAnnotation, MiaImage } from "@visian/utils"; + +export interface ImageListItemProps { + image: MiaImage; + areSomeSelected?: boolean; + isSelectionHovered?: boolean; + isSelected?: boolean; + onSelect?: (selected: boolean) => void; + onDelete?: (image: MiaImage) => void; + showAnnotations?: boolean; + onAnnotationDelete?: (annotation: MiaAnnotation) => void; + annotationsFilter?: (annotation: MiaAnnotation) => boolean; +} diff --git a/apps/editor/src/components/data-manager/image-list/image-list-item.tsx b/apps/editor/src/components/data-manager/image-list/image-list-item.tsx new file mode 100644 index 000000000..811121ae9 --- /dev/null +++ b/apps/editor/src/components/data-manager/image-list/image-list-item.tsx @@ -0,0 +1,204 @@ +import { color, InvisibleButton, ListDivider, Text } from "@visian/ui-shared"; +import { Fragment, useCallback, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { MiaReviewStrategy, TaskType } from "../../../models/review-strategy"; +import { useAnnotationsByImage } from "../../../queries"; +import { ImageListItemProps } from "./image-list-item.props"; + +const CollapseButton = styled(InvisibleButton)<{ isOpen: boolean }>` + width: 20px; + margin-right: 8px; + transform: rotate(${({ isOpen }) => (isOpen ? "90deg" : "0deg")}); + transition: transform 0.1s ease-in-out; +`; + +export const SelectionCheckbox = styled(InvisibleButton)<{ + emphasized?: boolean; + largerMargin?: boolean; +}>` + width: 18px; + margin-right: ${({ largerMargin }) => (largerMargin ? "12px" : "8px")}; + opacity: ${({ emphasized }) => (emphasized ? 1 : 0.4)}; + transition: opacity 0.1s ease-in-out; +`; + +const IconButton = styled(InvisibleButton)` + width: 20px; + height: 20px; +`; + +const VerifiedDot = styled.div` + width: 12px; + height: 12px; + border-radius: 50%; + background-color: ${color("Neuronic Neon")}; + margin: 0 10px; +`; + +const ClickableText = styled(Text)` + cursor: pointer; +`; + +const Actions = styled.div` + flex-grow: 1; + display: flex; + justify-content: flex-end; +`; + +const TrashButton = styled(IconButton)` + opacity: 0; + transition: opacity 0.1s ease-in-out; +`; + +const Row = styled.div` + display: flex; + align-items: center; + padding: 10px 12px; + + &:hover ${TrashButton} { + opacity: 1; + } +`; + +// Add padding for list padding, collapsible width, collapsible margin, optionally checkbox size: +const AnnotationRow = styled(Row)<{ addCheckboxMargin?: boolean }>` + padding-left: calc( + 12px + 8px + 20px + + ${({ addCheckboxMargin }) => (addCheckboxMargin ? "26px" : "0px")} + ); +`; + +const AnnotationListDivider = styled(ListDivider)<{ + addCheckboxMargin?: boolean; +}>` + margin-left: calc( + 12px + 8px + 20px + + ${({ addCheckboxMargin }) => (addCheckboxMargin ? "26px" : "0px")} + ); + width: auto; +`; + +export const ImageListItem = ({ + image, + areSomeSelected, + isSelectionHovered, + isSelected, + onSelect, + onDelete, + showAnnotations, + onAnnotationDelete, + annotationsFilter, +}: ImageListItemProps) => { + const { annotations: allAnnotations } = useAnnotationsByImage(image.id); + const annotations = allAnnotations?.filter((a) => + annotationsFilter ? annotationsFilter(a) : true, + ); + const [areAnnotationsOpen, setAnnotationsOpen] = useState(false); + + const navigate = useNavigate(); + + const hasVerifiedAnnotation = useMemo( + () => annotations?.some((annotation) => annotation.verified) ?? false, + [annotations], + ); + + const toggleShowAnnotations = useCallback( + () => setAnnotationsOpen(!areAnnotationsOpen), + [areAnnotationsOpen], + ); + + const selectImage = useCallback(() => { + if (onSelect) onSelect(!isSelected); + }, [onSelect, isSelected]); + + const store = useStore(); + const startReviewWithAnnotation = useCallback( + async (id: string) => { + store?.startReview( + () => MiaReviewStrategy.fromAnnotationId(store, id, TaskType.Create), + navigate, + ); + }, + [store, navigate], + ); + const startReviewWithImage = useCallback(() => { + store?.startReview( + () => + MiaReviewStrategy.fromImageIds( + store, + [image.id], + TaskType.Create, + annotations?.map((a) => a.id), + ), + navigate, + ); + }, [store, navigate, image.id, annotations]); + + return ( + <> + + {onSelect && ( + + )} + {showAnnotations && ( + + )} + + {image.dataUri.split("/").pop()} + + {hasVerifiedAnnotation && } + + {!areSomeSelected && onDelete && ( + onDelete(image)} + tooltipPosition="left" + /> + )} + + + {areAnnotationsOpen && + annotations && + annotations.map((annotation, index) => ( + + {index === 0 && } + + startReviewWithAnnotation(annotation.id)} + > + {annotation.dataUri.split("/").pop()} + + {annotation.verified && } + + {!areSomeSelected && onAnnotationDelete && ( + onAnnotationDelete(annotation)} + /> + )} + + + {index !== annotations.length - 1 && ( + + )} + + ))} + + ); +}; diff --git a/apps/editor/src/components/data-manager/image-list/image-list.props.tsx b/apps/editor/src/components/data-manager/image-list/image-list.props.tsx new file mode 100644 index 000000000..03dc5498f --- /dev/null +++ b/apps/editor/src/components/data-manager/image-list/image-list.props.tsx @@ -0,0 +1,13 @@ +import { MiaAnnotation, MiaImage } from "@visian/utils"; + +export interface ImageListProps { + images: MiaImage[]; + showAnnotations?: boolean; + selectedImages?: MiaImage[]; + onSelect?: (images: MiaImage[]) => void; + onImageDelete?: (images: MiaImage[]) => void; + onAnnotationDelete?: (annotation: MiaAnnotation) => void; + onStartJob?: (images: MiaImage[]) => void; + onStartReview?: (images: MiaImage[]) => void; + annotationsFilter?: (annotation: MiaAnnotation) => boolean; +} diff --git a/apps/editor/src/components/data-manager/image-list/image-list.tsx b/apps/editor/src/components/data-manager/image-list/image-list.tsx new file mode 100644 index 000000000..0b67221ae --- /dev/null +++ b/apps/editor/src/components/data-manager/image-list/image-list.tsx @@ -0,0 +1,172 @@ +import { + FloatingUIButton, + List, + ListDivider, + Sheet, + useCtrlAPress, + useShiftKey, +} from "@visian/ui-shared"; +import { MiaImage } from "@visian/utils"; +import { Fragment, useCallback, useState } from "react"; +import styled from "styled-components"; + +import { ImageListItem, SelectionCheckbox } from "./image-list-item"; +import { ImageListProps } from "./image-list.props"; + +const Container = styled.div` + width: 100%; +`; + +const ListContainer = styled(Sheet)` + box-sizing: border-box; +`; + +const ListHeader = styled.div` + display: flex; + align-items: center; + padding: 0 13px 13px 13px; +`; + +const Actions = styled.div` + display: flex; + align-items: center; + margin-left: 10px; +`; + +const ActionButton = styled(FloatingUIButton)` + margin: 0; + margin-right: 11px; +`; + +export const ImageList = ({ + images, + showAnnotations, + selectedImages, + onSelect, + onImageDelete, + onAnnotationDelete, + onStartJob, + onStartReview, + annotationsFilter, +}: ImageListProps) => { + const deleteImage = useCallback( + (image: MiaImage) => onImageDelete && onImageDelete([image]), + [onImageDelete], + ); + const deleteSelectedImages = useCallback( + () => onImageDelete && selectedImages && onImageDelete(selectedImages), + [onImageDelete, selectedImages], + ); + const startJobWithSelected = useCallback( + () => onStartJob && selectedImages && onStartJob(selectedImages), + [onStartJob, selectedImages], + ); + const startReviewWithSelected = useCallback( + () => onStartReview && selectedImages && onStartReview(selectedImages), + [onStartReview, selectedImages], + ); + + const [lastSelectedIndex, setLastSelectedIndex] = useState(); + const isShiftPressed = useShiftKey(); + + const numberOfSelectedImages = selectedImages?.length || 0; + const areAllSelected = numberOfSelectedImages === images.length; + const areSomeSelected = numberOfSelectedImages > 0; + const areNoneSelected = numberOfSelectedImages === 0; + + const selectImage = useCallback( + (image: MiaImage, index: number, selected: boolean) => { + if (!onSelect || !selectedImages) return; + if (isShiftPressed && lastSelectedIndex !== undefined) { + const start = Math.min(lastSelectedIndex, index); + const end = Math.max(lastSelectedIndex, index); + const newSelectedImages = [...selectedImages]; + for (let i = start; i <= end; i++) { + const img = images[i]; + if (!newSelectedImages.includes(img)) { + newSelectedImages.push(img); + } + } + onSelect(newSelectedImages); + } else if (selected) { + onSelect([...selectedImages, image]); + setLastSelectedIndex(index); + } else { + onSelect([...selectedImages].filter((i) => i.id !== image.id)); + } + }, + [images, isShiftPressed, lastSelectedIndex, onSelect, selectedImages], + ); + + const selectAll = useCallback(() => { + if (!onSelect) return; + if (areAllSelected) onSelect([]); + else onSelect(images); + }, [onSelect, areAllSelected, images]); + + useCtrlAPress(selectAll); + + return ( + + {onSelect && ( + + + + {onImageDelete && ( + + )} + {onStartJob && ( + + )} + {onStartReview && ( + + )} + + + )} + + + {images.map((image, index) => ( + + selectImage(image, index, selected)) + } + showAnnotations={showAnnotations} + onAnnotationDelete={onAnnotationDelete} + annotationsFilter={annotationsFilter} + /> + {index !== images.length - 1 && } + + ))} + + + + ); +}; diff --git a/apps/editor/src/components/data-manager/image-list/index.ts b/apps/editor/src/components/data-manager/image-list/index.ts new file mode 100644 index 000000000..47ca7e101 --- /dev/null +++ b/apps/editor/src/components/data-manager/image-list/index.ts @@ -0,0 +1,4 @@ +export * from "./image-list"; +export * from "./image-list-item.props"; +export * from "./image-list-item.props"; +export * from "./image-list-item.props"; diff --git a/apps/editor/src/components/data-manager/index.ts b/apps/editor/src/components/data-manager/index.ts new file mode 100644 index 000000000..f7f0ddfcc --- /dev/null +++ b/apps/editor/src/components/data-manager/index.ts @@ -0,0 +1,24 @@ +export * from "./confirmation-popup"; +export * from "./datasets-section"; +export * from "./dataset-creation-popup"; +export * from "./dataset-page"; +export * from "./edit-popup"; +export * from "./image-import-popup"; +export * from "./image-list"; +export * from "./job-creation-popup"; +export * from "./job-history"; +export * from "./job-page"; +export * from "./jobs-section"; +export * from "./menu-data-manager"; +export * from "./mia-title"; +export * from "./navbar"; +export * from "./page"; +export * from "./page-error"; +export * from "./page-loading-block"; +export * from "./page-row"; +export * from "./page-section"; +export * from "./page-title"; +export * from "./project-creation-popup"; +export * from "./util"; +export * from "./views"; +export * from "./warning-label"; diff --git a/apps/editor/src/components/data-manager/job-creation-popup/index.ts b/apps/editor/src/components/data-manager/job-creation-popup/index.ts new file mode 100644 index 000000000..097c6e59a --- /dev/null +++ b/apps/editor/src/components/data-manager/job-creation-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./job-creation-popup"; +export * from "./job-creation-popup.props"; diff --git a/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.props.ts b/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.props.ts new file mode 100644 index 000000000..94e4ca0b6 --- /dev/null +++ b/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.props.ts @@ -0,0 +1,17 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; +import type { MiaImage, MiaJob } from "@visian/utils"; +import { AxiosError } from "axios"; +import { + QueryObserverResult, + RefetchOptions, + RefetchQueryFilters, +} from "react-query"; + +export interface JobCreationPopUpProps extends StatefulPopUpProps { + projectId: string; + activeImageSelection?: Set; + openWithDatasetId?: string; + refetchJobs?: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined, + ) => Promise>>; +} diff --git a/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.tsx b/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.tsx new file mode 100644 index 000000000..6a0b1fbb2 --- /dev/null +++ b/apps/editor/src/components/data-manager/job-creation-popup/job-creation-popup.tsx @@ -0,0 +1,363 @@ +import { + Box, + Button, + color, + DropDown, + FlexRow, + Icon, + IEnumParameterOption, + List, + ListItem, + PopUp, + SectionHeader, + SubtleText, + Text, + useTranslation, +} from "@visian/ui-shared"; +import { MiaImage } from "@visian/utils"; +import { observer } from "mobx-react-lite"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import styled, { css } from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { postJob, useImagesByDataset, useMlModels } from "../../../queries"; +import { useDatasetsBy } from "../../../queries/use-datasets-by"; +import { ImageList } from "../image-list"; +import { useImageSelection } from "../util"; +import { JobCreationPopUpProps } from "./job-creation-popup.props"; + +const JobCreationPopupContainer = styled(PopUp)` + align-items: left; + width: calc(100% - 200px); + max-width: 960px; + max-height: 70vh; +`; + +const ContentContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + overflow-y: hidden; + margin-bottom: 30px; +`; + +const DropDownContainer = styled(FlexRow)` + justify-content: space-between; +`; + +const StyledDropDown = styled(DropDown)` + width: 48%; +`; + +const StyledSectionHeader = styled(SectionHeader)` + padding: 1em 0 0.5em 0; +`; + +const ImageListPlaceholder = styled.div` + width: 100%; +`; + +const ImageListContainer = styled.div` + overflow: scroll; + width: 100%; +`; + +const FileExplorer = styled.div` + display: flex; + overflow: hidden; + width: 100%; +`; + +const VerticalLine = styled.div` + border-left: 1px solid ${color("sheetBorder")}; + margin: 0 20px; +`; + +const DatasetContainer = styled.div` + overflow-y: auto; + width: 100%; +`; + +const DatasetTitle = styled(Text)` + display: inline-block; + padding: 4px 0 13px; + opacity: 0.5; +`; + +const DatasetList = styled(List)` + width: 100%; +`; + +const DatasetIcon = styled(Icon)` + width: 2rem; + height: 2rem; + padding-right: 0.8rem; +`; + +const DatasetListItem = styled(ListItem)<{ isActive?: boolean }>` + cursor: pointer; + // Fix too thick line on intersection between active items + margin: 1px 3%; + // Fix items moving by 1px on selection / deselection + ${(props) => + !props.isActive && + css` + padding: 1px 0; + `} +`; + +const ImageInfo = styled(Text)` + width: 100%; + text-align: center; + padding-top: 100px; +`; + +const StyledErrorText = styled(Text)` + width: 100%; + text-align: center; +`; + +const Footer = styled(Box)` + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + gap: 1em; +`; + +export const JobCreationPopup = observer( + ({ + isOpen, + onClose, + projectId, + activeImageSelection, + openWithDatasetId, + refetchJobs, + }) => { + const store = useStore(); + + const { mlModels, mlModelsError, isErrorMlModels, isLoadingMlModels } = + useMlModels(); + + const [selectedModelName, setSelectedModelName] = useState( + (mlModels && mlModels[0]?.name) || "", + ); + + useEffect(() => { + if (mlModels && mlModels.length > 0) { + setSelectedModelName(mlModels[0].name); + } + }, [mlModels]); + + const mlModelNameOptions: IEnumParameterOption[] = useMemo(() => { + const uniqueNames = new Set(mlModels?.map((model) => model.name)); + return Array.from(uniqueNames, (modelName) => ({ + label: modelName, + value: modelName, + })); + }, [mlModels]); + + const availableModelVersions = useMemo( + () => + mlModels + ? mlModels + .filter((model) => model.name === selectedModelName) + .map((model) => model.version) + : [], + [mlModels, selectedModelName], + ); + + const [selectedModelVersion, setSelectedModelVersion] = useState< + string | undefined + >(availableModelVersions[0]); + + useEffect(() => { + setSelectedModelVersion(availableModelVersions[0]); + }, [availableModelVersions]); + + const mlModelVersionOptions: IEnumParameterOption[] = useMemo( + () => + availableModelVersions.map((modelVersion) => ({ + label: `v${modelVersion}`, + value: modelVersion, + })), + [availableModelVersions], + ); + + const findModel = useCallback( + () => + mlModels?.find( + (model) => + model.name === selectedModelName && + model.version === selectedModelVersion, + ), + [mlModels, selectedModelName, selectedModelVersion], + ); + + const { datasets, datasetsError, isErrorDatasets, isLoadingDatasets } = + useDatasetsBy(projectId); + + const [selectedDataset, setSelectedDataset] = useState(openWithDatasetId); + + useEffect(() => setSelectedDataset(openWithDatasetId), [openWithDatasetId]); + + const { images, isErrorImages } = useImagesByDataset(selectedDataset); + + const { selectedImages, selectImages } = useImageSelection(); + const selectedDatasetImages = Array.from(selectedImages).filter( + (image) => image.dataset === selectedDataset, + ); + // When images are selected in the image list, only the ones of the current dataset + // should be affected: + const setSelectedDatasetImages = useCallback( + (imagesToBeSelected: MiaImage[]) => { + const otherDatasetImages = Array.from(selectedImages).filter( + (image) => image.dataset !== selectedDataset, + ); + selectImages([...otherDatasetImages, ...imagesToBeSelected]); + }, + [selectImages, selectedDataset, selectedImages], + ); + + // TODO: Fix this Bug + // Select all (Crtl + A) does not work correctly when adding missing dependencies openWithDatasetId and activeImageSelection + useEffect(() => { + if (openWithDatasetId && activeImageSelection && isOpen) { + selectImages([...activeImageSelection]); + } + }, [isOpen]); + + const createAutoAnnotationJob = useCallback( + async (imageSelection: string[]) => { + const selectedModel = findModel(); + if (!selectedModel) { + store?.setError({ + titleTx: "error", + descriptionTx: "ml-models-not-found-error", + }); + return; + } + + try { + await postJob(imageSelection, selectedModel, projectId); + refetchJobs?.(); + onClose?.(); + } catch (error) { + store?.setError({ + titleTx: "internal-server-error", + descriptionTx: "job-creation-error", + }); + onClose?.(); + } + }, + [findModel, store, projectId, refetchJobs, onClose], + ); + + const startJob = useCallback( + () => + createAutoAnnotationJob([...selectedImages].map((image) => image.id)), + [createAutoAnnotationJob, selectedImages], + ); + + const { t } = useTranslation(); + + const showProjectDataExplorer = !(isLoadingDatasets || isErrorDatasets); + + let imageInfoTx; + if (isErrorImages) imageInfoTx = "images-loading-failed"; + else if (images && images.length === 0) imageInfoTx = "no-images-available"; + + return ( + + + + {isLoadingMlModels && } + {isErrorMlModels && ( + {`${t("ml-models-loading-error")} ${ + mlModelsError?.response?.statusText + } (${mlModelsError?.response?.status})`} + )} + + + + + {isLoadingDatasets && } + {isErrorDatasets && ( + {`${t("datasets-loading-error")} ${ + datasetsError?.response?.statusText + } (${datasetsError?.response?.status})`} + )} + + + {datasets && ( + + + + {datasets.map((dataset) => ( + setSelectedDataset(dataset.id)} + > + + {dataset.name} + + ))} + + + )} + + {images ? ( + imageInfoTx ? ( + + ) : ( + + + + ) + ) : ( + + )} + + +
+ {selectedImages.size > 0 && ( + + )} +
+
+ ); + }, +); diff --git a/apps/editor/src/components/data-manager/job-history/index.ts b/apps/editor/src/components/data-manager/job-history/index.ts new file mode 100644 index 000000000..8c8f6494c --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/index.ts @@ -0,0 +1,4 @@ +export * from "./job-history"; +export * from "./job-table"; +export * from "./job-log-popup"; +export * from "./job-status-badge"; diff --git a/apps/editor/src/components/data-manager/job-history/job-history.tsx b/apps/editor/src/components/data-manager/job-history/job-history.tsx new file mode 100644 index 000000000..98d0ef11f --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-history.tsx @@ -0,0 +1,83 @@ +import { Modal, Notification, SquareButton, Text } from "@visian/ui-shared"; +import { useCallback, useState } from "react"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import useJobsBy from "../../../queries/use-jobs-by"; +import { JobCreationPopup } from "../job-creation-popup"; +import { JobsTable } from "./job-table"; + +const StyledModal = styled(Modal)` + width: 100%; +`; + +const ErrorMessage = styled(Text)` + margin: auto; +`; + +const StyledButton = styled(SquareButton)` + margin-left: 10px; + padding: 10px; +`; + +const ErrorNotification = styled(Notification)` + position: absolute; + min-width: 30%; + left: 50%; + bottom: 15%; + transform: translateX(-50%); +`; + +export const JobHistory = ({ + projectId, + altMessage, +}: { + projectId: string; + altMessage: string; +}) => { + const store = useStore(); + + const { jobs, refetchJobs } = useJobsBy(projectId); + + // Model selection popup + const [isModelSelectionPopUpOpen, setIsModelSelectionPopUpOpen] = + useState(false); + const openModelSelectionPopUp = useCallback(() => { + setIsModelSelectionPopUpOpen(true); + }, []); + const closeModelSelectionPopUp = useCallback(() => { + setIsModelSelectionPopUpOpen(false); + }, []); + + return ( + + } + > + {store?.error && ( + + )} + {altMessage ? ( + + ) : ( + jobs && + )} + + + ); +}; diff --git a/apps/editor/src/components/data-manager/job-history/job-log-popup/index.ts b/apps/editor/src/components/data-manager/job-history/job-log-popup/index.ts new file mode 100644 index 000000000..9249897fe --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-log-popup/index.ts @@ -0,0 +1,2 @@ +export * from "./job-log-popup"; +export * from "./job-log-popup.props"; diff --git a/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.props.ts b/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.props.ts new file mode 100644 index 000000000..60132f7ca --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.props.ts @@ -0,0 +1,6 @@ +import type { StatefulPopUpProps } from "@visian/ui-shared"; +import type { MiaJob } from "@visian/utils"; + +export interface JobLogPopUpProps extends StatefulPopUpProps { + job: MiaJob; +} diff --git a/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.tsx b/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.tsx new file mode 100644 index 000000000..64915ded7 --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-log-popup/job-log-popup.tsx @@ -0,0 +1,58 @@ +import { Divider, PopUp, SectionHeader, Text } from "@visian/ui-shared"; +import { MiaJob } from "@visian/utils"; +import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; +import styled from "styled-components"; + +import { getJobLog } from "../../../../queries"; +import { JobLogPopUpProps } from "./job-log-popup.props"; + +const PopUpContainer = styled(PopUp)` + align-items: left; + width: 70%; + max-height: 80%; +`; + +const StyledDivider = styled(Divider)` + margin-top: 0.5em; +`; + +const StyledText = styled(Text)` + overflow-y: auto; + white-space: pre-wrap; +`; + +const getLogText = async (job: MiaJob) => { + let logText = ""; + if (job.logFileUri) { + try { + logText = await getJobLog(job.id); + } catch (e) { + logText = "Error fetching job log file"; + } + } + return logText; +}; + +export const JobLogPopup = observer( + ({ isOpen, onClose, job }) => { + const [jobLogContent, setjobLogContent] = useState(""); + + useEffect(() => { + getLogText(job).then((text) => setjobLogContent(text)); + }, [job, isOpen]); + + return ( + + {job.logFileUri} + + {jobLogContent} + + ); + }, +); diff --git a/apps/editor/src/components/data-manager/job-history/job-status-badge/index.ts b/apps/editor/src/components/data-manager/job-history/job-status-badge/index.ts new file mode 100644 index 000000000..f85714e5c --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-status-badge/index.ts @@ -0,0 +1 @@ +export * from "./job-status-badge"; diff --git a/apps/editor/src/components/data-manager/job-history/job-status-badge/job-status-badge.tsx b/apps/editor/src/components/data-manager/job-history/job-status-badge/job-status-badge.tsx new file mode 100644 index 000000000..2c9e37e48 --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-status-badge/job-status-badge.tsx @@ -0,0 +1,33 @@ +import { StatusBadge } from "@visian/ui-shared"; +import { MiaJobStatus } from "@visian/utils"; + +const statusColors: Record = { + queued: "veryVeryLightGray", + running: "blueBadgeBackground", + succeeded: "greenBadgeBackground", + canceled: "orangeBadgeBackground", + failed: "redBadgeBackground", +}; + +const statusBorderColors: Record = { + queued: "sheetBorder", + running: "blueBorder", + succeeded: "greenBadgeBorder", + canceled: "orangeBadgeBorder", + failed: "redBorder", +}; + +export const JobStatusBadge = ({ + status, + full, +}: { + status: MiaJobStatus; + full?: boolean; +}) => ( + +); diff --git a/apps/editor/src/components/data-manager/job-history/job-table.tsx b/apps/editor/src/components/data-manager/job-history/job-table.tsx new file mode 100644 index 000000000..bf32451cb --- /dev/null +++ b/apps/editor/src/components/data-manager/job-history/job-table.tsx @@ -0,0 +1,111 @@ +import { + createColumnHelper, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { HeaderLabel, ListItemLabel, TableLayout } from "@visian/ui-shared"; +import { MiaJob } from "@visian/utils"; +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { getDisplayDate } from "../util/display-date"; +import { JobStatusBadge } from "./job-status-badge/job-status-badge"; + +const BadgeContainer = styled.div` + width: 10em; +`; + +function getDisplayJob(job: MiaJob): MiaJob { + return { + ...job, + modelVersion: `v${job.modelVersion}`, + startedAt: job.startedAt + ? getDisplayDate(new Date(job.startedAt)) + : undefined, + finishedAt: job.finishedAt + ? getDisplayDate(new Date(job.finishedAt)) + : undefined, + }; +} + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor("name", { + header: () => , + cell: (props) => , + }), + columnHelper.accessor("modelName", { + header: () => , + cell: (props) => , + }), + columnHelper.accessor("modelVersion", { + header: () => , + cell: (props) => , + }), + columnHelper.accessor("startedAt", { + header: () => , + cell: (props) => , + sortingFn: "datetime", + sortUndefined: -1, + }), + columnHelper.accessor("finishedAt", { + header: () => , + cell: (props) => , + sortingFn: "datetime", + sortUndefined: -1, + }), + columnHelper.accessor("status", { + header: () => , + cell: (props) => ( + + + + ), + // Make sure that queued jobs are at the top + sortingFn: (rowA, rowB, id) => { + if (rowA.getValue(id) === "queued") return -1; + return 0; + }, + }), +]; +export const JobsTable = ({ jobs }: { jobs: MiaJob[] }) => { + const data = jobs.map((job: MiaJob) => getDisplayJob(job)); + + const columnWidths = [20, 10, 25, 25, 20]; + + const navigate = useNavigate(); + + const handleOnClick = useCallback( + (job: MiaJob) => navigate(`/jobs/${job.id}`), + [navigate], + ); + + const [sorting, setSorting] = useState([ + { id: "status", desc: false }, + { id: "finishedAt", desc: true }, + { id: "startedAt", desc: true }, + ]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + initialState: { sorting }, + enableSorting: true, + enableMultiSort: true, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + return ( + + ); +}; diff --git a/apps/editor/src/components/data-manager/job-page/details-table.tsx b/apps/editor/src/components/data-manager/job-page/details-table.tsx new file mode 100644 index 000000000..0984ca9ad --- /dev/null +++ b/apps/editor/src/components/data-manager/job-page/details-table.tsx @@ -0,0 +1,38 @@ +import { Text } from "@visian/ui-shared"; +import styled from "styled-components"; + +export const DetailsTable = styled.div` + display: table; +`; + +const RowContainer = styled.div` + display: flex; + flex-wrap: nowrap; + flex-direction: row; + width: 100%; + align-items: center; + min-height: 1.8em; +`; + +const Label = styled(Text)` + width: 33%; + font-weight: bold; +`; + +export const DetailsRow = ({ + tx, + text, + value, + content, +}: { + content?: React.ReactNode; + value?: string; + tx?: string; + text?: string; +}) => ( + + +); diff --git a/apps/editor/src/components/data-manager/job-page/index.ts b/apps/editor/src/components/data-manager/job-page/index.ts new file mode 100644 index 000000000..0478e34aa --- /dev/null +++ b/apps/editor/src/components/data-manager/job-page/index.ts @@ -0,0 +1,2 @@ +export * from "./job-page"; +export * from "./details-table"; diff --git a/apps/editor/src/components/data-manager/job-page/job-page.tsx b/apps/editor/src/components/data-manager/job-page/job-page.tsx new file mode 100644 index 000000000..55daac439 --- /dev/null +++ b/apps/editor/src/components/data-manager/job-page/job-page.tsx @@ -0,0 +1,288 @@ +import { + InvisibleButton, + Sheet, + space, + Text, + TimerButton, + useTranslation, +} from "@visian/ui-shared"; +import { MiaAnnotation, MiaJob } from "@visian/utils"; +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { MiaReviewStrategy } from "../../../models/review-strategy"; +import { + useAnnotationsByJob, + useDeleteJobsForProjectMutation, + usePatchJobStatusMutation, +} from "../../../queries"; +import useImagesByJob from "../../../queries/use-images-by-jobs"; +import { useJobProgress } from "../../../queries/use-job-progress"; +import { AnnotationProgress } from "../annotation-progress"; +import { ConfirmationPopup } from "../confirmation-popup"; +import { ImageList } from "../image-list"; +import { JobLogPopup } from "../job-history/job-log-popup"; +import { JobStatusBadge } from "../job-history/job-status-badge/job-status-badge"; +import { PageRow } from "../page-row"; +import { PageSection } from "../page-section"; +import { PageTitle } from "../page-title"; +import { getDisplayDate } from "../util"; +import { DetailsRow } from "./details-table"; + +const DetailsSheet = styled(Sheet)` + padding: ${space("pageSectionMarginSmall")}; + box-sizing: border-box; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +`; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; +`; + +const IconButton = styled(InvisibleButton)` + width: 30px; +`; + +const StyledTimerButton = styled(TimerButton)` + width: 25px; +`; + +const Spacer = styled.div` + width: 5px; +`; + +const OverflowText = styled(Text)` + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 55%; +`; + +export const JobPage = ({ job }: { job: MiaJob }) => { + const { progress, isLoadingProgress } = useJobProgress(job.id); + + const { annotations } = useAnnotationsByJob(job.id); + const { images, imagesError, isLoadingImages } = useImagesByJob(job.id); + + const store = useStore(); + const { t } = useTranslation(); + const { deleteJobs } = useDeleteJobsForProjectMutation(); + const { patchJobStatus } = usePatchJobStatusMutation(); + const navigate = useNavigate(); + + // Delete job confirmation popup + const [ + isDeleteJobConfirmationPopUpOpen, + setIsDeleteJobConfirmationPopUpOpen, + ] = useState(false); + const openDeleteJobConfirmationPopUp = useCallback(() => { + setIsDeleteJobConfirmationPopUpOpen(true); + }, []); + const closeDeleteJobConfirmationPopUp = useCallback(() => { + setIsDeleteJobConfirmationPopUpOpen(false); + }, []); + + // Cancel job confirmation popup + const [ + isCancelJobConfirmationPopUpOpen, + setIsCancelJobConfirmationPopUpOpen, + ] = useState(false); + const openCancelJobConfirmationPopUp = useCallback(() => { + setIsCancelJobConfirmationPopUpOpen(true); + }, []); + const closeCancelJobConfirmationPopUp = useCallback(() => { + setIsCancelJobConfirmationPopUpOpen(false); + }, []); + + // Job log popup + const [isJobLogPopUpOpen, setIsJobLogPopUpOpen] = useState(false); + const openJobLogPopUp = useCallback(() => setIsJobLogPopUpOpen(true), []); + const closeJobLogPopUp = useCallback(() => setIsJobLogPopUpOpen(false), []); + + const jobAnnotationFilter = useCallback( + (annotation: MiaAnnotation) => annotation.job === job.id, + [job], + ); + + const confirmDeleteJob = useCallback(() => { + deleteJobs({ + projectId: job.project, + jobIds: [job.id], + }); + navigate(`/projects/${job.project}`); + }, [deleteJobs, job, navigate]); + + const confirmCancelJob = useCallback( + () => + patchJobStatus({ + projectId: job.project, + jobId: job.id, + jobStatus: "canceled", + }), + [patchJobStatus, job], + ); + + const startReviewWithJob = useCallback( + async () => + store?.startReview( + async () => MiaReviewStrategy.fromJob(store, job.id), + navigate, + ), + [navigate, job, store], + ); + + let listInfoTx; + if (imagesError) listInfoTx = "images-loading-failed"; + else if (images && images.length === 0) listInfoTx = "no-images-available"; + + let progressInfoTx; + if (progress?.totalImages === 0) + progressInfoTx = "annotation-progress-no-images"; + + const startedAt = job.startedAt + ? getDisplayDate(new Date(job.startedAt)) + : ""; + const finishedAt = job.finishedAt + ? getDisplayDate(new Date(job.finishedAt)) + : ""; + + const copyJobId = useCallback(() => { + navigator.clipboard.writeText(job.id); + }, [job.id]); + return ( + + + + {progress && ( + + )} + + ), + }, + { + width: 33, + element: ( + + + + + {job.logFileUri && ( + + )} + {["queued", "running"].includes(job.status) ? ( + + ) : ( + + )} + + } + /> + + + + + + } + /> + + + + + + ), + }, + ]} + /> + + + {images && ( + + )} + + + + + + ); +}; diff --git a/apps/editor/src/components/data-manager/jobs-section/index.ts b/apps/editor/src/components/data-manager/jobs-section/index.ts new file mode 100644 index 000000000..66d046e5a --- /dev/null +++ b/apps/editor/src/components/data-manager/jobs-section/index.ts @@ -0,0 +1 @@ +export * from "./jobs-section"; diff --git a/apps/editor/src/components/data-manager/jobs-section/jobs-section.tsx b/apps/editor/src/components/data-manager/jobs-section/jobs-section.tsx new file mode 100644 index 000000000..00c0cac52 --- /dev/null +++ b/apps/editor/src/components/data-manager/jobs-section/jobs-section.tsx @@ -0,0 +1,59 @@ +import { MiaProject } from "@visian/utils"; +import { useCallback, useState } from "react"; +import styled from "styled-components"; + +import { useJobsBy } from "../../../queries"; +import { JobCreationPopup } from "../job-creation-popup"; +import { JobsTable } from "../job-history/job-table"; +import { + PaddedPageSectionIconButton, + PageSection, + SectionSheet, +} from "../page-section"; + +const StyledIconButton = styled(PaddedPageSectionIconButton)` + height: 25px; +`; + +export const JobsSection = ({ project }: { project: MiaProject }) => { + const { jobs, jobsError, isLoadingJobs, refetchJobs } = useJobsBy(project.id); + + // Jobs + const [isModelSelectionPopUpOpen, setIsModelSelectionPopUpOpen] = + useState(false); + const openModelSelectionPopUp = useCallback(() => { + setIsModelSelectionPopUpOpen(true); + }, []); + const closeModelSelectionPopUp = useCallback(() => { + setIsModelSelectionPopUpOpen(false); + }, []); + + let jobsInfoTx; + if (jobsError) jobsInfoTx = "jobs-loading-failed"; + else if (jobs && jobs.length === 0) jobsInfoTx = "no-jobs-available"; + + return ( + + } + > + {jobs && } + + + ); +}; diff --git a/apps/editor/src/components/data-manager/menu-data-manager/index.ts b/apps/editor/src/components/data-manager/menu-data-manager/index.ts new file mode 100644 index 000000000..1169504e4 --- /dev/null +++ b/apps/editor/src/components/data-manager/menu-data-manager/index.ts @@ -0,0 +1,2 @@ +export * from "./menu-data-manager"; +export * from "./menu-data-manager.props"; diff --git a/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.props.ts b/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.props.ts new file mode 100644 index 000000000..236b1950c --- /dev/null +++ b/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.props.ts @@ -0,0 +1,3 @@ +export interface MenuDataManagerProps { + onOpenShortcutPopUp?: () => void; +} diff --git a/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.tsx b/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.tsx new file mode 100644 index 000000000..379fa90d3 --- /dev/null +++ b/apps/editor/src/components/data-manager/menu-data-manager/menu-data-manager.tsx @@ -0,0 +1,118 @@ +import { + ButtonParam, + ColoredButtonParam, + ColorMode, + Divider, + EnumParam, + FloatingUIButton, + Modal, + SupportedLanguage, + Theme, + useTranslation, +} from "@visian/ui-shared"; +import { observer } from "mobx-react-lite"; +import React, { useCallback, useState } from "react"; +import styled, { useTheme } from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { feedbackMailAddress } from "../../../constants"; +import { MenuDataManagerProps } from "./menu-data-manager.props"; + +// Styled Components +const MenuButton = styled(FloatingUIButton)` + margin-right: 16px; +`; + +// Menu Items +const themeSwitchOptions = [ + { value: "dark", labelTx: "dark" }, + { value: "light", labelTx: "light" }, +]; + +const languageSwitchOptions = [ + { label: "English", value: "en" }, + { label: "Deutsch", value: "de" }, +]; + +export const MenuDataManager: React.FC = observer( + ({ onOpenShortcutPopUp }) => { + const store = useStore(); + const { i18n } = useTranslation(); + + // Menu Toggling + const [isModalOpen, setIsModalOpen] = useState(false); + const openModal = useCallback(() => { + setIsModalOpen(true); + }, []); + const closeModal = useCallback(() => { + setIsModalOpen(false); + }, []); + + // Menu Positioning + const [buttonRef, setButtonRef] = useState(null); + + // Menu Actions + const setColorMode = useCallback( + (value: ColorMode) => store?.settings.setColorMode(value), + [store], + ); + const setLanguage = useCallback( + (language: SupportedLanguage) => store?.settings.setLanguage(language), + [store], + ); + + const sendFeedback = useCallback(() => { + const mail = document.createElement("a"); + mail.href = `mailto:${feedbackMailAddress}`; + mail.click(); + }, []); + + const openShortcutPopUp = useCallback(() => { + onOpenShortcutPopUp?.(); + }, [onOpenShortcutPopUp]); + + const theme = useTheme() as Theme; + return ( + <> + + + + + + + {feedbackMailAddress && ( + + )} + + + ); + }, +); diff --git a/apps/editor/src/components/data-manager/mia-title/index.ts b/apps/editor/src/components/data-manager/mia-title/index.ts new file mode 100644 index 000000000..159ce5fdd --- /dev/null +++ b/apps/editor/src/components/data-manager/mia-title/index.ts @@ -0,0 +1 @@ +export * from "./mia-title"; diff --git a/apps/editor/src/components/data-manager/mia-title/mia-title.tsx b/apps/editor/src/components/data-manager/mia-title/mia-title.tsx new file mode 100644 index 000000000..27de0cbbb --- /dev/null +++ b/apps/editor/src/components/data-manager/mia-title/mia-title.tsx @@ -0,0 +1,18 @@ +import { space, Title } from "@visian/ui-shared"; +import styled from "styled-components"; + +const TitleContainer = styled.div` + padding: ${space("pageSectionMargin")} 0; +`; + +const Transparent = styled.span` + opacity: 0.5; +`; + +export const MiaTitle = () => ( + + + MIA <Transparent>/ Medical Image Annotation Platform</Transparent> + + +); diff --git a/apps/editor/src/components/data-manager/navbar/index.ts b/apps/editor/src/components/data-manager/navbar/index.ts new file mode 100644 index 000000000..e4e192d57 --- /dev/null +++ b/apps/editor/src/components/data-manager/navbar/index.ts @@ -0,0 +1 @@ +export * from "./navbar"; diff --git a/apps/editor/src/components/data-manager/navbar/navbar.tsx b/apps/editor/src/components/data-manager/navbar/navbar.tsx new file mode 100644 index 000000000..51436dbef --- /dev/null +++ b/apps/editor/src/components/data-manager/navbar/navbar.tsx @@ -0,0 +1,69 @@ +import { FloatingUIButton } from "@visian/ui-shared"; +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { useStore } from "../../../app/root-store"; +import { ShortcutPopUp } from "../../editor/shortcut-popup"; +import { MenuDataManager } from "../menu-data-manager"; + +const Container = styled.div` + position: absolute; + top: 0; + left: 0; + + display: flex; + justify-content: space-between; + flex-direction: column; +`; + +const Button = styled(FloatingUIButton)` + margin-right: 16px; +`; + +export const Navbar = ({ className }: { className?: string }) => { + const navigate = useNavigate(); + + const store = useStore(); + + // Shortcut Pop Up Toggling + const [isShortcutPopUpOpen, setIsShortcutPopUpOpen] = useState(false); + const openShortcutPopUp = useCallback(() => { + setIsShortcutPopUpOpen(true); + }, []); + const closeShortcutPopUp = useCallback(() => { + setIsShortcutPopUpOpen(false); + }, []); + + const navigateToHome = useCallback(() => navigate(`/projects`), [navigate]); + const navigateToEditor = useCallback(() => { + store?.editor.setReturnUrl(window.location.pathname); + navigate(`/editor`); + }, [navigate, store]); + + return ( + +