diff --git a/src/client/cypress/e2e/editor/labeling.cy.js b/src/client/cypress/e2e/editor/labeling.cy.js new file mode 100644 index 000000000..eec0da34e --- /dev/null +++ b/src/client/cypress/e2e/editor/labeling.cy.js @@ -0,0 +1,35 @@ +import { newUneditableBorehole, startBoreholeEditing, stopBoreholeEditing } from "../helpers/testHelpers.js"; + +describe.skip("Test labeling tool", () => { + it("can show labeling panel", () => { + newUneditableBorehole().as("borehole_id"); + // only show in editing mode + cy.get('[data-cy="labeling-toggle-button"]').should("not.exist"); + + // panel is closed by default + startBoreholeEditing(); + cy.get('[data-cy="labeling-toggle-button"]').should("exist"); + cy.get('[data-cy="labeling-panel"]').should("not.exist"); + + // panel can be opened and closed + cy.get('[data-cy="labeling-toggle-button"]').click(); + cy.get('[data-cy="labeling-panel"]').should("exist"); + cy.get('[data-cy="labeling-toggle-button"]').click(); + cy.get('[data-cy="labeling-panel"]').should("not.exist"); + + // panel open state should be reset when editing is stopped, panel position should be preserved + cy.get('[data-cy="labeling-toggle-button"]').click(); + cy.get('[data-cy="labeling-panel"]').should("exist"); + cy.get('[data-cy="labeling-panel-position-right"]').should("have.class", "Mui-selected"); + cy.get('[data-cy="labeling-panel-position-bottom"]').click(); + cy.get('[data-cy="labeling-panel-position-right"]').should("not.have.class", "Mui-selected"); + cy.get('[data-cy="labeling-panel-position-bottom"]').should("have.class", "Mui-selected"); + + stopBoreholeEditing(); + cy.get('[data-cy="labeling-panel"]').should("not.exist"); + startBoreholeEditing(); + cy.get('[data-cy="labeling-panel"]').should("not.exist"); + cy.get('[data-cy="labeling-toggle-button"]').click(); + cy.get('[data-cy="labeling-panel-position-bottom"]').should("have.class", "Mui-selected"); + }); +}); diff --git a/src/client/package-lock.json b/src/client/package-lock.json index fd3e76bb0..2b1eee8bf 100644 --- a/src/client/package-lock.json +++ b/src/client/package-lock.json @@ -28,6 +28,7 @@ "i18next-http-backend": "^2.4.2", "immer": "^10.0.3", "lodash": "^4.17.21", + "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.0.1", "moment": "^2.29.4", "oidc-client-ts": "3.0.1", @@ -7465,6 +7466,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.438.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.438.0.tgz", + "integrity": "sha512-uq6yCB+IzVfgIPMK8ibkecXSWTTSOMs9UjUgZigfrDCVqgdwkpIgYg1fSYnf0XXF2AoSyCJZhoZXQwzoai7VGw==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/markdown-to-jsx": { "version": "7.4.7", "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.4.7.tgz", diff --git a/src/client/package.json b/src/client/package.json index b5a7bde0e..ab5ea73de 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -43,6 +43,7 @@ "i18next-http-backend": "^2.4.2", "immer": "^10.0.3", "lodash": "^4.17.21", + "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.0.1", "moment": "^2.29.4", "oidc-client-ts": "3.0.1", diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index ce4a70ce7..c73dcb0b3 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -19,6 +19,7 @@ import { FilterProvider } from "./pages/overview/sidePanelContent/filter/filterC import HeaderComponent from "./components/header/headerComponent.tsx"; import { AppBox } from "./components/styledComponents.ts"; import { DetailPage } from "./pages/detail/detailPage.tsx"; +import { LabelingProvider } from "./pages/detail/labeling/labelingContext.tsx"; const queryClient = new QueryClient(); @@ -64,7 +65,16 @@ class App extends React.Component { } key={0} path={"/setting"} /> - } /> + ( + + + + )} + /> { return ; diff --git a/src/client/src/AppTheme.ts b/src/client/src/AppTheme.ts index af91489c4..3539f9fd3 100644 --- a/src/client/src/AppTheme.ts +++ b/src/client/src/AppTheme.ts @@ -38,6 +38,14 @@ export const theme = createTheme({ main: "#337083", secondary: "#a65462", }, + ai: { + background: "#46596B", + main: "#5B21B6", + mainEnd: "#8B5CF6", + active: "#4F46E5", + activeEnd: "#E53940", + contrastText: "#ffffff", + }, boxShadow: "#DFE4E9", background: { default: "#ffffff", @@ -188,6 +196,45 @@ export const theme = createTheme({ backgroundColor: "rgba(0, 0, 0, 0)", }, }, + colorAi: { + color: "#ffffff", + background: "linear-gradient(#5B21B6, #8B5CF6)", + "&:hover": { + background: "linear-gradient(#4F46E5, #E53940)", + }, + "&:focus-visible": { + background: "linear-gradient(#4F46E5, #E53940)", + boxShadow: "0px 0px 0px 3px #8655F6", + }, + "&:active": { + background: "linear-gradient(#4F46E5, #E53940)", + }, + "&:disabled": { + background: "linear-gradient(#4F46E5, #E53940)", + }, + }, + }, + }, + MuiToggleButtonGroup: { + styleOverrides: { + root: { + backgroundColor: "#ffffff", + }, + }, + }, + MuiToggleButton: { + styleOverrides: { + root: { + border: "0", + borderRadius: "4px !important", + margin: "4px", + padding: "7px", + color: "#337083", + "&.Mui-selected": { + color: "#2F4356", + backgroundColor: "#D6E2E6", + }, + }, }, }, MuiSelect: { diff --git a/src/client/src/components/buttons/labelingButton.tsx b/src/client/src/components/buttons/labelingButton.tsx new file mode 100644 index 000000000..a4932c448 --- /dev/null +++ b/src/client/src/components/buttons/labelingButton.tsx @@ -0,0 +1,44 @@ +import { ButtonProps, IconButton } from "@mui/material"; +import { forwardRef } from "react"; +import { ChevronDown, ChevronRight, Sparkles } from "lucide-react"; + +export const LabelingButton = forwardRef((props, ref) => { + return ( + + + + ); +}); + +interface LabelingToggleButtonProps extends ButtonProps { + panelOpen: boolean; + panelPosition: "right" | "bottom"; +} + +export const LabelingToggleButton = forwardRef((props, ref) => { + const { panelOpen, panelPosition, ...defaultProps } = props; + return ( + + {panelOpen ? panelPosition === "right" ? : : } + + ); +}); diff --git a/src/client/src/index.css b/src/client/src/index.css index 251b756d0..a65696082 100644 --- a/src/client/src/index.css +++ b/src/client/src/index.css @@ -1,3 +1,8 @@ +.lucide { + width: 20px; + height: 20px; + stroke-width: 1.7px; +} .ol-popup { position: absolute; diff --git a/src/client/src/mui.theme.d.ts b/src/client/src/mui.theme.d.ts index 8d1093af5..9daed25a1 100644 --- a/src/client/src/mui.theme.d.ts +++ b/src/client/src/mui.theme.d.ts @@ -1,180 +1,128 @@ import { Theme, ThemeOptions } from "@mui/material/styles"; +import { Breakpoints } from "@mui/system"; +import { BreakpointsOptions } from "@mui/system/createTheme/createBreakpoints"; +import { Typography } from "@mui/material"; +import { TypographyOptions } from "@mui/material/styles/createTypography"; + +declare module "@mui/material/IconButton" { + interface IconButtonPropsColorOverrides { + ai: true; + } +} declare module "@mui/material/styles" { - interface CustomTheme extends Theme { - palette: { - action: { - disabled: string; - }; - primary: { - main: string; - contrastText: string; - }; - secondary: { - main: string; - contrastText: string; - background: string; - }; - success: { - main: string; - }; - warning: { - main: string; - }; - error: { - main: string; - dark: string; - contrastText: string; - background: string; - }; - neutral: { - main: string; - contrastText: string; - }; - hover: { - main: string; - }; - mapIcon: { - main: string; - secondary: string; - }; - boxShadow: string; - background: { - default: string; - lightgrey: string; - darkgrey: string; - dark: string; - menuItemActive: string; - filterItemActive: string; - }; - border: string; + interface AppThemePalette { + action: { + disabled: string; + }; + primary: { + main: string; + contrastText: string; + }; + secondary: { + main: string; + contrastText: string; + background: string; + }; + success: { + main: string; + }; + warning: { + main: string; + }; + error: { + main: string; + dark: string; + contrastText: string; + background: string; + }; + neutral: { + main: string; + contrastText: string; + }; + hover: { + main: string; + }; + mapIcon: { + main: string; + secondary: string; + }; + ai: { + background: string; + main: string; + mainEnd: string; + active: string; + activeEnd: string; + contrastText: string; }; - typography: { - fontFamily: string; - h6: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - h5: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - h2: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - subtitle1: { - fontSize: string; - color: string; - lineHeight: string; - }; - subtitle2: { - fontSize: string; - color: string; - lineHeight: string; - }; - body2: { - fontSize: string; - }; - fullPageMessage: { - fontSize: string; - color: string; - }; + boxShadow: string; + background: { + default: string; + lightgrey: string; + darkgrey: string; + dark: string; + menuItemActive: string; + filterItemActive: string; }; + border: string; } - // allow configuration using `createTheme` - interface CustomThemeOptions extends ThemeOptions { - palette?: { - action: { - disabled: string; - }; - primary: { - main: string; - contrastText: string; - }; - secondary: { - main: string; - contrastText: string; - background: string; - }; - success: { - main: string; - }; - warning: { - main: string; - }; - error: { - main: string; - dark: string; - contrastText: string; - background: string; - }; - neutral: { - main: string; - contrastText: string; - }; - hover: { - main: string; - }; - mapIcon: { - main: string; - secondary: string; - }; - boxShadow: string; - background: { - default: string; - lightgrey: string; - darkgrey: string; - dark: string; - menuItemActive: string; - filterItemActive: string; - }; - border: string; + + interface AppThemeTypography extends Typography { + fontFamily: string; + fullPageMessage: { + fontSize: string; + color: string; }; - typography: { - fontFamily: string; - h6: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - h5: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - h2: { - fontSize: string; - color: string; - lineHeight: string; - fontWeight: number; - }; - subtitle1: { - fontSize: string; - color: string; - lineHeight: string; - }; - subtitle2: { - fontSize: string; - color: string; - lineHeight: string; - }; - body2: { - fontSize: string; - }; - fullPageMessage: { - fontSize: string; - color: string; - }; + } + + interface AppThemeTypographyOptions extends TypographyOptions { + fontFamily: string; + fullPageMessage?: { + fontSize: string; + color: string; }; } - export function createTheme(options?: CustomThemeOptions): CustomTheme; + + interface AppThemeComponents extends Components { + MuiButtonBase: object; + MuiButton: object; + MuiIconButton: object; + MuiToggleButtonGroup: object; + MuiToggleButton: object; + MuiSelect: object; + MuiFormControl: object; + MuiTab: object; + MuiBadge: object; + MuiDialogContentText: object; + MuiTableCell: object; + } + + interface AppThemeComponentsOptions extends ComponentsOptions { + MuiButtonBase: object; + MuiButton: object; + MuiIconButton: object; + MuiToggleButtonGroup: object; + MuiToggleButton: object; + MuiSelect: object; + MuiFormControl: object; + MuiTab: object; + MuiBadge: object; + MuiDialogContentText: object; + MuiTableCell: object; + } + + interface AppTheme extends Theme { + palette: AppThemePalette; + typography: AppThemeTypography; + breakpoints: Breakpoints; + components: AppThemeComponents; + } + + interface AppThemeOptions extends ThemeOptions { + palette: AppThemePalette; + typography: AppThemeTypographyOptions; + breakpoints: BreakpointsOptions; + components: AppThemeComponentsOptions; + } + export function createTheme(options?: AppThemeOptions): AppTheme; } diff --git a/src/client/src/pages/detail/detailPage.tsx b/src/client/src/pages/detail/detailPage.tsx index 936349d5d..316fe6d51 100644 --- a/src/client/src/pages/detail/detailPage.tsx +++ b/src/client/src/pages/detail/detailPage.tsx @@ -6,6 +6,10 @@ import { Borehole, ReduxRootState } from "../../api-lib/ReduxStateInterfaces.ts" import DetailSideNav from "./detailSideNav"; import DetailPageContent from "./detailPageContent"; import DetailHeader from "./detailHeader.tsx"; +import { Box } from "@mui/material"; +import { useLabelingContext } from "./labeling/labelingInterfaces.tsx"; +import LabelingPanel from "./labeling/labelingPanel.tsx"; +import { LabelingToggleButton } from "../../components/buttons/labelingButton.tsx"; interface DetailPageContentProps { editingEnabled: boolean; @@ -18,12 +22,18 @@ export const DetailPage: FC = () => { const borehole: Borehole = useSelector((state: ReduxRootState) => state.core_borehole); const user = useSelector((state: ReduxRootState) => state.core_user); const location = useLocation(); + const { panelPosition, panelOpen, togglePanel } = useLabelingContext(); + const showLabeling = false; useEffect(() => { setEditingEnabled(borehole.data.lock !== null); }, [borehole.data.lock]); useEffect(() => { + if (!editingEnabled) { + togglePanel(false); + } + if (borehole.data.lock !== null && borehole.data.lock.id !== user.data.id) { setEditableByCurrentUser(false); return; @@ -39,7 +49,7 @@ export const DetailPage: FC = () => { const isBoreholeInEditWorkflow = borehole?.data.workflow?.role === "EDIT"; setEditableByCurrentUser(userRoleMatches && (isStatusPage || isBoreholeInEditWorkflow)); - }, [editingEnabled, user, borehole, location]); + }, [editingEnabled, user, borehole, location, togglePanel]); const props: DetailPageContentProps = { editingEnabled: editingEnabled, @@ -57,9 +67,19 @@ export const DetailPage: FC = () => { - - - + + + {editingEnabled && showLabeling && ( + togglePanel()} /> + )} + + + {editingEnabled && panelOpen && } + ); diff --git a/src/client/src/pages/detail/labeling/labelingContext.tsx b/src/client/src/pages/detail/labeling/labelingContext.tsx new file mode 100644 index 000000000..f8a72503d --- /dev/null +++ b/src/client/src/pages/detail/labeling/labelingContext.tsx @@ -0,0 +1,42 @@ +import { LabelingContextInterface, PanelPosition } from "./labelingInterfaces.tsx"; +import { createContext, FC, PropsWithChildren, useCallback, useEffect, useLayoutEffect, useState } from "react"; + +export const LabelingContext = createContext({ + panelPosition: "right", + setPanelPosition: () => {}, + panelOpen: false, + togglePanel: () => {}, +}); + +export const LabelingProvider: FC = ({ children }) => { + const [panelPosition, setPanelPosition] = useState("right"); + const [panelOpen, setPanelOpen] = useState(false); + + const panelPositionStorageName = "labelingPanelPosition"; + useLayoutEffect(() => { + const storedPosition = localStorage.getItem(panelPositionStorageName) as PanelPosition; + if (storedPosition) { + setPanelPosition(storedPosition); + } + }, []); + + useEffect(() => { + localStorage.setItem(panelPositionStorageName, panelPosition); + }, [panelPosition]); + + const togglePanel = useCallback((isOpen?: boolean) => { + setPanelOpen(prevState => (isOpen !== undefined ? isOpen : !prevState)); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/client/src/pages/detail/labeling/labelingInterfaces.tsx b/src/client/src/pages/detail/labeling/labelingInterfaces.tsx new file mode 100644 index 000000000..011ebed52 --- /dev/null +++ b/src/client/src/pages/detail/labeling/labelingInterfaces.tsx @@ -0,0 +1,13 @@ +import { LabelingContext } from "./labelingContext.tsx"; +import { useContext } from "react"; + +export type PanelPosition = "right" | "bottom"; + +export interface LabelingContextInterface { + panelPosition: PanelPosition; + setPanelPosition: (position: PanelPosition) => void; + panelOpen: boolean; + togglePanel: (isOpen?: boolean) => void; +} + +export const useLabelingContext = () => useContext(LabelingContext); diff --git a/src/client/src/pages/detail/labeling/labelingPanel.tsx b/src/client/src/pages/detail/labeling/labelingPanel.tsx new file mode 100644 index 000000000..a82732beb --- /dev/null +++ b/src/client/src/pages/detail/labeling/labelingPanel.tsx @@ -0,0 +1,41 @@ +import { Box, ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { PanelPosition, useLabelingContext } from "./labelingInterfaces.tsx"; +import { PanelBottom, PanelRight } from "lucide-react"; +import { MouseEvent } from "react"; +import { theme } from "../../../AppTheme.ts"; + +const LabelingPanel = () => { + const { panelPosition, setPanelPosition } = useLabelingContext(); + + return ( + + , nextPosition: PanelPosition) => { + setPanelPosition(nextPosition); + }} + exclusive + sx={{ + position: "absolute", + bottom: "10px", + right: "10px", + zIndex: "500", + }}> + + + + + + + + + ); +}; + +export default LabelingPanel;