diff --git a/apps/mx/index.html b/apps/mx/index.html index 0cc4e38aa7eafffd58d3c075ba5d3c3be0046a4b..01c27028866476042fb385cd6b4586d02696de6e 100644 --- a/apps/mx/index.html +++ b/apps/mx/index.html @@ -1,2 +1,13 @@ <!doctype html> -<html lang="en"></html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Data Portal</title> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/apps/mx/package.json b/apps/mx/package.json index 375756ce91500ee9f3341a41dc17c3582fd7f2f3..9fae583ecc3b97d214520a0a4e2bdeb22b6940e3 100644 --- a/apps/mx/package.json +++ b/apps/mx/package.json @@ -24,6 +24,9 @@ "@fortawesome/free-solid-svg-icons": "^6.6.0", "@fortawesome/react-fontawesome": "^0.2.2", "@tanstack/react-query": "^5.52.1", + "ajv": "^8.17.1", + "bootstrap": "^5.3.3", + "bootswatch": "^5.3.3", "lodash": "^4.17.21", "papaparse": "^5.4.1", "react": "^18.3.1", diff --git a/apps/mx/public/config/api.config.json b/apps/mx/public/config/api.config.json new file mode 100644 index 0000000000000000000000000000000000000000..fa5cfdd9063ff7a993a82fd05574f4fdf8f65ff4 --- /dev/null +++ b/apps/mx/public/config/api.config.json @@ -0,0 +1,35 @@ +{ + "icat_url": "https://icatplus.esrf.fr/", + "authentication": { + "anonymous": { + "plugin": "db", + "username": "reader", + "password": "reader" + }, + "autoRefresh": true, + "autoRefreshThresholdMinutes": "60", + "authenticators": [ + { + "name": "OpenID", + "enabled": true, + "title": "ESRF Single Sign On", + "plugin": "esrf", + "message": "Login with ESRF SSO", + "openidLogoutTransferToApp": true, + "refreshToken": true, + "minValidity": 600, + "configuration": { + "authority": "https://websso.esrf.fr/auth/realms/ESRF", + "clientId": "icat" + } + }, + { + "name": "Database", + "title": "Database", + "enabled": true, + "message": "", + "plugin": "db" + } + ] + } +} diff --git a/apps/mx/public/config/ui.config.json b/apps/mx/public/config/ui.config.json new file mode 100644 index 0000000000000000000000000000000000000000..99dc23f62c7078e9a91824dd27a03ef9907a0995 --- /dev/null +++ b/apps/mx/public/config/ui.config.json @@ -0,0 +1,169 @@ +{ + "applicationTitle": "Data Portal", + "facilityName": "ESRF", + "homePage": { + "policyMessage": "Public data is accessible to anyone. You need to be logged-in to visualize your data when it is under embargo. See <a href=\"https://www.esrf.fr/fr/home/UsersAndScience/UserGuide/esrf-data-policy.html\" target=\"_blank\" rel=\"noreferrer\">ESRF data policy</a> for more details." + }, + "linkToPreviousVersion": { + "name": "Back to Data Portal V1", + "url": "https://data1.esrf.fr" + }, + "knowledgeBasePage": "https://confluence.esrf.fr/display/DATAPOLWK/Knowledge+Base", + "loginForm": { + "accountCreationLink": "https://smis.esrf.fr/misapps/SMISWebClient/accountManager/searchExistingAccount.do?action=search", + "note": { + "enabled": true, + "title": "Important note", + "notes": [ + { + "text": "In order to login to the Data Portal of the ESRF to send samples or browse embargoed data you need to be declared a member of a proposal on the ESRF User Portal. Once this is done your account will be activated <b>45 days</b> before the first experiment starts. If you need access earlier please contact the <a rel=\"noopener noreferrer\" target=\"_blank\" href=\"http://www.esrf.eu/UsersAndScience/UserGuide/Contacts\" >ESRF User Office</a>.<br/>Anonymous login to browse open data is always possible." + }, + { + "text": "During 2019 and according to the General Data Protection Regulation, all portal users who did not consent to the <a href=\"http://www.esrf.fr/GDPR\" rel=\"noopener noreferrer\" target=\"_blank\" > User Portal Privacy Statement</a> have had their account deactivated. Please contact the <a rel=\"noopener noreferrer\" target=\"_blank\" href=\"http://www.esrf.eu/UsersAndScience/UserGuide/Contacts\" >User Office</a> if you wish to reactivate it." + } + ] + } + }, + "userPortal": { + "investigationParameterPkName": "Id", + "link": "https://smis.esrf.fr/misapps/SMISWebClient/protected/aform/manageAForm.do?action=view&expSessionVO.pk=" + }, + "doi": { + "link": "https://doi.esrf.fr/", + "minimalAbstractLength": 1000, + "minimalTitleLength": 40, + "facilityPrefix": "10.15151", + "facilitySuffix": "ESRF-DC", + "referenceDoi": "https://doi.esrf.fr/10.15151/ESRF-DC-2011729981" + }, + "fileBrowser": { + "maxFileNb": 1000 + }, + "imageViewer": { + "fileExtensions": [".png", ".jpg", ".jpeg", ".tiff", ".gif"] + }, + "h5Viewer": { + "url": "https://hibou.esrf.fr", + "fileExtensions": [".hdf5", ".h5", ".nexus", ".nx", ".nxs", ".cxi"] + }, + "galleryViewer": { + "fileExtensions": ["jpeg", "jpg", "gif", "png", "svg"] + }, + "textViewer": { + "maxFileSize": 5000000, + "fileExtensions": [ + ".txt", + ".log", + ".json", + ".xml", + ".csv", + ".dat", + ".inp", + ".xds", + ".descr", + ".lp", + ".hkl", + ".site", + ".asc", + ".ini" + ] + }, + "feedback": { + "email": "dataportalrequests@esrf.fr", + "subject": "Feedback", + "body": "Hi,\n\n<< Please provide your feedback here. >>\n<< To report an issue, please include screenshots, reproduction steps, proposal number, beamline, etc. >>\n<< To suggest a new feature, please describe the needs this feature would fulfill. >>\n\nThanks" + }, + "footer": { + "images": [ + { + "src": "/images/esrf.jpg", + "alt": "ESRF", + "href": "https://www.esrf.fr/" + }, + { + "src": "/images/CoreTrustSeal.png", + "alt": "CoreTrustSeal", + "href": "https://www.coretrustseal.org/" + } + ] + }, + "handsonTableLicenseKey": "non-commercial-and-evaluation", + "globus": { + "enabled": true, + "url": "https://app.globus.org/file-manager?", + "collections": [ + { + "root": "/data/visitor/", + "origin": "/data", + "originId": "bfc3eff4-f5ca-4b4d-8532-e9a155f3613f" + }, + { + "root": "/data/projects/hop", + "origin": "/data/projects/hop", + "originId": "340dc883-4b0d-476c-abb0-969e1ddd9dc0" + }, + { + "root": "/data/projects/open-datasets", + "origin": "/data/projects/open-datasets/", + "originId": "8cbe8cdc-048a-48cf-b8a0-046a6af3ba44" + }, + { + "root": "/data/projects/paleo", + "origin": "/data/projects/paleo/public", + "originId": "e8fb6c4b-9ab0-4a1c-a79d-d51cef0b8c3d" + } + ], + "messageAlert": { + "enabled": true, + "message": "For users who want to download large volume of experimental data <strong>(>2GB)</strong>, ESRF users can access the Globus service, please read the <a href=\"https://confluence.esrf.fr/display/SCKB/Globus\" target=\"_blank\">documentation</a> for proceeding." + } + }, + "sample": { + "pageTemplateURL": "https://smis.esrf.fr/misapps/SMISWebClient/protected/samplesheet/view.do?pk=", + "editable": true, + "descriptionParameterName": "Sample_description", + "nonEditableParameterName": "Id", + "notesParameterName": "Sample_notes" + }, + "logbook": { + "help": "https://confluence.esrf.fr/display/DATAPOLWK/Electronic+Logbook", + "defaultMonthPeriodForStaff": 1 + }, + "projects": [ + { + "title": "The Human Organ Atlas", + "key": "The Human Organ Atlas", + "url": "https://human-organ-atlas.esrf.eu/datasets/{datasetId}", + "homepage": "https://human-organ-atlas.esrf.eu" + }, + { + "title": "Paleontology database", + "key": "paleo", + "url": "http://paleo.esrf.fr/datasets/{datasetId}", + "homepage": "http://paleo.esrf.fr" + } + ], + "features": { + "reprocessing": true, + "logbook": true, + "dmp": true, + "logistics": true + }, + "tracking": { + "enabled": true, + "url": "https://matomo-srv-1.esrf.fr/", + "siteId": "14", + "tracker": "piwik.php", + "script": "piwik.js" + }, + "mx": { + "pdb_map_mtz_viewer_url": "https://moorhen.esrf.fr" + }, + "logistics": { + "transportOrganizationEnabled": false, + "facilityReimbursmentEnabled": true, + "facilityForwarderName": "FedEX", + "facilityForwarderAccount": "388310561", + "facilityForwarderNamePickup": ["FEDEX", "DHL express", "UPS"] + } +} diff --git a/apps/mx/src/App.tsx b/apps/mx/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ef6e18e49a59b668a174c7575c96bc2213ebb785 --- /dev/null +++ b/apps/mx/src/App.tsx @@ -0,0 +1,50 @@ +import { + AuthenticatedAPIProvider, + AuthenticatorProvider, + OpenIDProvider, + SideNavProvider, + UnauthenticatedAPIProvider, +} from '@edata-portal/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AppRouter } from 'standalone/routing/AppRouter'; +import { ViewersProvider } from 'standalone/providers/ViewersProvider'; +import { ConfigProvider } from 'standalone/providers/ConfigProvider'; +import { Navigation } from 'standalone/routing/Navigation'; +import 'standalone/scss/main.scss'; + +const queryClient = new QueryClient(); + +function App() { + return ( + <> + <h1>MX Microfrontend Development</h1> + <QueryClientProvider client={queryClient}> + <ConfigProvider> + <AppContextProviders> + <ViewersProvider> + <SideNavProvider> + <AppRouter> + <Navigation /> + </AppRouter> + </SideNavProvider> + </ViewersProvider> + </AppContextProviders> + </ConfigProvider> + </QueryClientProvider> + </> + ); +} + +function AppContextProviders({ children }: { children: React.ReactNode }) { + return ( + <UnauthenticatedAPIProvider> + <AuthenticatorProvider> + <OpenIDProvider> + <AuthenticatedAPIProvider>{children}</AuthenticatedAPIProvider> + </OpenIDProvider> + </AuthenticatorProvider> + </UnauthenticatedAPIProvider> + ); +} + +export default App; diff --git a/apps/mx/src/main.tsx b/apps/mx/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dcc8a0d47118c65d1c4a1eb2db2fb71c4f2828f4 --- /dev/null +++ b/apps/mx/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from 'App'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + <React.StrictMode> + <App /> + </React.StrictMode>, +); diff --git a/apps/mx/src/standalone/InvestigationDatasets.tsx b/apps/mx/src/standalone/InvestigationDatasets.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c41646e3969fb462173492d349300bbb955d4264 --- /dev/null +++ b/apps/mx/src/standalone/InvestigationDatasets.tsx @@ -0,0 +1,32 @@ +import { usePath, useViewers } from '@edata-portal/core'; +import { + useGetEndpoint, + INVESTIGATION_LIST_ENDPOINT, +} from '@edata-portal/icat-plus-api'; +import { Alert } from 'react-bootstrap'; + +/** + * Fetches and displays the datasets related to a specific investigation. + * + * This component: + * - Retrieves the `investigationId` from the URL path. + * - Fetches the corresponding investigation using `useGetEndpoint`. + * - Renders an alert if no investigation is found. + * - Uses `viewInvestigation` from `useViewers()` to render the investigation details. + * + * @returns {JSX.Element} Investigation details or an error alert. + */ +export default function InvestigationDatasets(): JSX.Element { + const investigationId = usePath('investigationId'); + const investigations = useGetEndpoint({ + endpoint: INVESTIGATION_LIST_ENDPOINT, + params: { ids: investigationId }, + }); + const { viewInvestigation } = useViewers(); + + if (!investigations || investigations.length === 0) { + return <Alert variant="danger">Could not find investigation</Alert>; + } + + return viewInvestigation(investigations[0]); +} diff --git a/apps/mx/src/standalone/providers/ConfigProvider.tsx b/apps/mx/src/standalone/providers/ConfigProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d1f07f5bdb487bbbadb1487e2800201b61e40ce --- /dev/null +++ b/apps/mx/src/standalone/providers/ConfigProvider.tsx @@ -0,0 +1,53 @@ +import { ConfigContext } from '@edata-portal/core'; +import { useSuspenseQueries } from '@tanstack/react-query'; +import { useMemo } from 'react'; +declare global { + interface Window { + remoteUrls: any; + } +} + +export function ConfigProvider({ children }: { children: React.ReactNode }) { + const [api, ui, techniques] = useSuspenseQueries({ + queries: [ + { + queryKey: ['api.config'], + queryFn: async () => { + const response = await fetch('../config/api.config.json'); + return await response.json(); + }, + }, + { + queryKey: ['ui.config'], + queryFn: async () => { + const response = await fetch('../config/ui.config.json'); + return await response.json(); + }, + }, + { + queryKey: ['techniques.config'], + queryFn: async () => { + return [ + { + name: 'Crystallography', + shortname: 'MX', + }, + ]; + }, + }, + ], + }); + + const value = useMemo( + () => ({ + api: api.data, + ui: ui.data, + techniques: techniques.data, + }), + [api.data, techniques.data, ui.data], + ); + + return ( + <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider> + ); +} diff --git a/apps/mx/src/standalone/providers/ViewersProvider.tsx b/apps/mx/src/standalone/providers/ViewersProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..52b191cb83382968accd2944f2bb431f532dca83 --- /dev/null +++ b/apps/mx/src/standalone/providers/ViewersProvider.tsx @@ -0,0 +1,33 @@ +import { ViewersContext } from '@edata-portal/core'; +import { DatasetViewer } from 'standalone/viewers/DatasetViewer'; +import { InvestigationViewer } from 'standalone/viewers/InvestigationViewer'; +import { SampleViewer } from 'standalone/viewers/SampleViewer'; + +export function ViewersProvider({ children }: { children: React.ReactNode }) { + return ( + <ViewersContext.Provider + value={{ + viewSample: (sample, props) => ( + <SampleViewer key={sample.id} sample={sample} props={props} /> + ), + viewDataset: (dataset, type, props) => ( + <DatasetViewer + key={dataset.id} + dataset={dataset} + type={type} + props={props} + /> + ), + viewInvestigation: (investigation, props) => ( + <InvestigationViewer + key={investigation.id} + investigation={investigation} + props={props} + /> + ), + }} + > + {children} + </ViewersContext.Provider> + ); +} diff --git a/apps/mx/src/standalone/routing/AppRouter.tsx b/apps/mx/src/standalone/routing/AppRouter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1eae24f459d3e8a416fd50fd14fbf4a0995656a0 --- /dev/null +++ b/apps/mx/src/standalone/routing/AppRouter.tsx @@ -0,0 +1,33 @@ +import InvestigationDatasets from 'standalone/InvestigationDatasets'; +import { useMemo } from 'react'; +import { + createBrowserRouter, + RouteObject, + RouterProvider, +} from 'react-router-dom'; + +export const routes: RouteObject[] = [ + { + path: '/investigation/:investigationId', + element: <InvestigationDatasets></InvestigationDatasets>, + }, + { + path: '*', + element: <div>Cheers</div>, + }, +]; + +export function AppRouter({ children }: { children: JSX.Element }) { + const router = useMemo(() => { + return createBrowserRouter([ + { + element: <>{children}</>, + children: routes, + id: 'home', + handle: { breadcrumb: 'Home' }, + }, + ]); + }, [children]); + + return <RouterProvider router={router} />; +} diff --git a/apps/mx/src/standalone/routing/Navigation.tsx b/apps/mx/src/standalone/routing/Navigation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df81786783e3d283c6607b8e6879b690fe7e92ab --- /dev/null +++ b/apps/mx/src/standalone/routing/Navigation.tsx @@ -0,0 +1,38 @@ +import { Container } from 'react-bootstrap'; +import { Outlet } from 'react-router-dom'; +import { SideNavRenderer } from '@edata-portal/core'; + +export function Navigation() { + return ( + <div + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }} + > + <Container + style={{ + paddingTop: 20, + overflow: 'auto', + height: '100%', + position: 'relative', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }} + fluid + className="main" + > + <SideNavRenderer> + <Outlet /> + </SideNavRenderer> + </Container> + </div> + ); +} diff --git a/apps/mx/src/standalone/scss/colors.scss b/apps/mx/src/standalone/scss/colors.scss new file mode 100644 index 0000000000000000000000000000000000000000..210d6faf4d6d5865d5c55e09b542a71294ab1b0a --- /dev/null +++ b/apps/mx/src/standalone/scss/colors.scss @@ -0,0 +1,29 @@ +// custom theme colors + +$primary: #2c3e50; +$secondary: #456c74; +$success: #18bc9c; +$info: #108cdf; +$warning: #f39c12; +$danger: #e74c3c; +$light: #ecf0f1; +$dark: #222222; + +$dataset-raw: #b4ccd1; +$dataset-processed: #f9e69e; +$sample: #4d3d5f; + +:root { + --primary: #{$primary}; + --secondary: #{$secondary}; + --success: #{$success}; + --info: #{$info}; + --warning: #{$warning}; + --danger: #{$danger}; + --light: #{$light}; + --dark: #{$dark}; + + --dataset-raw: #{$dataset-raw}; + --dataset-processed: #{$dataset-processed}; + --sample: #{$sample}; +} diff --git a/apps/mx/src/standalone/scss/images/esrf-white.png b/apps/mx/src/standalone/scss/images/esrf-white.png new file mode 100644 index 0000000000000000000000000000000000000000..a3c925198bc7246316308878f9ffda7079adc867 Binary files /dev/null and b/apps/mx/src/standalone/scss/images/esrf-white.png differ diff --git a/apps/mx/src/standalone/scss/main.scss b/apps/mx/src/standalone/scss/main.scss new file mode 100644 index 0000000000000000000000000000000000000000..7c94a23909253e29305d0ecf0f920abde601c1bf --- /dev/null +++ b/apps/mx/src/standalone/scss/main.scss @@ -0,0 +1,60 @@ +@import 'bootswatch/dist/flatly/variables'; +@import './colors.scss'; +@import 'bootstrap/scss/bootstrap'; + +$web-font-path: false; //disable loading of web fonts +@import 'bootswatch/dist/flatly/bootswatch'; + +body { + font-size: 0.9em; +} + +.navbar-brand { + background: url('images/esrf-white.png') no-repeat; + background-size: contain; + padding-left: 70px; +} + +.breadcrumb { + margin: 0px; + padding: 0px; +} + +.table { + --bs-table-bg: 'none'; +} + +.monospace { + font-family: monospace; +} + +.bg-dataset-raw { + background-color: $dataset-raw; + color: color-contrast($dataset-raw); + a { + color: color-contrast($dataset-raw); + } +} + +.bg-dataset-processed { + background-color: $dataset-processed; + color: color-contrast($dataset-processed); + a { + color: color-contrast($dataset-processed); + } +} + +.bg-sample { + background-color: $sample; + color: color-contrast($sample); + a { + color: color-contrast($sample); + } +} + +.bg-secondary { + color: color-contrast($secondary); + a { + color: color-contrast($secondary); + } +} diff --git a/apps/mx/src/standalone/viewers/DatasetViewer.tsx b/apps/mx/src/standalone/viewers/DatasetViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b1437f5772b46ee5af3df60ef12d553c234a20c --- /dev/null +++ b/apps/mx/src/standalone/viewers/DatasetViewer.tsx @@ -0,0 +1,33 @@ +import { DatasetViewerType } from '@edata-portal/core'; +import type { Dataset } from '@edata-portal/icat-plus-api'; +import { Alert } from 'react-bootstrap'; +import MXDatasetDetailsViewer from 'viewers/MXDatasetDetailsViewer'; +import MXDatasetSnapshotViewer from 'viewers/MXDatasetSnapshotViewer'; + +/** + * Renders a dataset viewer based on the specified type. + * + * @param {Object} props - Component props. + * @param {Dataset} props.dataset - The dataset object to display. + * @param {any} [props.props] - Additional props to be passed to the viewer component. + * @param {DatasetViewerType} props.type - The type of viewer to display. + * + * @returns {JSX.Element} The appropriate dataset viewer component or an error alert. + */ +export function DatasetViewer({ + dataset, + props, + type, +}: { + dataset: Dataset; + props?: any; + type: DatasetViewerType; +}) { + if (type === 'details') { + return <MXDatasetDetailsViewer dataset={dataset} {...props} />; + } + if (type === 'snapshot') { + return <MXDatasetSnapshotViewer {...props} dataset={dataset} />; + } + return <Alert variant="danger">No viewer found</Alert>; +} diff --git a/apps/mx/src/standalone/viewers/InvestigationViewer.tsx b/apps/mx/src/standalone/viewers/InvestigationViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee860d3757128cce51a31ad2547ee0536e5fc66d --- /dev/null +++ b/apps/mx/src/standalone/viewers/InvestigationViewer.tsx @@ -0,0 +1,18 @@ +import { Investigation } from '@edata-portal/icat-plus-api'; +import MXInvestigationViewer from 'viewers/MXInvestigationViewer'; + +export function InvestigationViewer({ + investigation, + props, +}: { + investigation: Investigation; + props?: any; +}) { + return ( + <MXInvestigationViewer + investigation={investigation} + nested={true} + {...props} + /> + ); +} diff --git a/apps/mx/src/standalone/viewers/SampleViewer.tsx b/apps/mx/src/standalone/viewers/SampleViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..adb11bb9a7338d43ca0bab50cabd53cf3fd919a5 --- /dev/null +++ b/apps/mx/src/standalone/viewers/SampleViewer.tsx @@ -0,0 +1,12 @@ +import type { Sample } from '@edata-portal/icat-plus-api'; +import MXSampleViewer from 'viewers/MXSampleViewer'; + +export function SampleViewer({ + sample, + props, +}: { + sample: Sample; + props?: any; +}) { + return <MXSampleViewer sample={sample} {...props} />; +} diff --git a/apps/mx/tsconfig.json b/apps/mx/tsconfig.json index 54f4b84b0d1a9f04e7e7dc24a0f78ad19c34a379..bc6526fe14ebb2d7a79443e1c541210de3dd9f5b 100644 --- a/apps/mx/tsconfig.json +++ b/apps/mx/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.json", - "include": ["src"], + "include": ["src", "public/config"], "compilerOptions": { "baseUrl": "src", "outDir": "dist", diff --git a/apps/portal/package.json b/apps/portal/package.json index 99e025f9e0dca3611e5146cd7c0376860a61e4ab..4989c91080965ad649ea1f15b81fcf7634e6d3e9 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -47,7 +47,6 @@ "react-datepicker": "^7.3.0", "react-dom": "^18.3.1", "react-intersection-observer": "^9.13.0", - "react-oidc-context": "^3.1.0", "react-router-dom": "^6.26.1", "react-select": "^5.8.0" }, diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 8e92250894a6720da1b2587167bafa455385577c..9f49560e4cea040fcee30e741aeaae93d082ae37 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -1,21 +1,21 @@ -import { Loading } from '@edata-portal/core'; +import { + AuthenticatedAPIProvider, + AuthenticatorProvider, + Loading, + OpenIDProvider, + SideNavProvider, + UnauthenticatedAPIProvider, +} from '@edata-portal/core'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthenticatorProvider } from 'authentication/authenticator'; -import { OpenIDProvider } from 'authentication/openid'; import { AppRouter } from 'components/routing/AppRouter'; import { Suspense } from 'react'; import { Navigation } from 'components/navigation/Navigation'; import { ViewersProvider } from 'components/viewers/ViewersProvider'; -import { SideNavProvider } from 'components/navigation/SideNav'; import { NotifyProvider, NotifyRenderer, } from 'components/notify/NotifyProvider'; import { ReprocessContextProvider } from 'components/reprocessing/ReprocessContext'; -import { - AuthenticatedAPIProvider, - UnauthenticatedAPIProvider, -} from 'components/api/APIProvider'; import { TrackRoutes } from 'components/routing/TrackRoutes'; import { ConfigProvider } from 'ConfigProvider'; import { RemoteProvider } from 'remotes'; diff --git a/apps/portal/src/components/navigation/Navigation.tsx b/apps/portal/src/components/navigation/Navigation.tsx index de286936adb2c7fa0632a70ce557a0716d0822fa..c579aab7e9261af0832726d1a5021338d3dc5660 100644 --- a/apps/portal/src/components/navigation/Navigation.tsx +++ b/apps/portal/src/components/navigation/Navigation.tsx @@ -3,11 +3,10 @@ import { Footer } from 'components/navigation/Footer'; import { Breadcrumbs } from 'components/navigation/Breadcrumbs'; import { Outlet } from 'react-router-dom'; import { ShowLoginPage } from 'components/usermanagement/Login'; -import { SideNavRenderer } from 'components/navigation/SideNav'; import { Header } from 'components/navigation/header/Header'; import InformationMessage from 'components/messages/InformationMessages'; import { Suspense } from 'react'; -import { Loading } from '@edata-portal/core'; +import { Loading, SideNavRenderer } from '@edata-portal/core'; export function Navigation() { return ( diff --git a/apps/portal/src/components/navigation/header/Header.tsx b/apps/portal/src/components/navigation/header/Header.tsx index 750c31668bd55822a891c2449740dcc06e20a487..fe5b915f39eff661743cb59d005d7444d8ff38c8 100644 --- a/apps/portal/src/components/navigation/header/Header.tsx +++ b/apps/portal/src/components/navigation/header/Header.tsx @@ -1,7 +1,6 @@ import { Nav, Navbar } from 'react-bootstrap'; import { NavLink } from 'react-router-dom'; import { useState } from 'react'; -import { useAuthenticator } from 'authentication/authenticator'; import { HeaderItem } from 'components/navigation/header/HeaderItem'; import { HeaderDropdownItem } from 'components/navigation/header/HeaderDropdownItem'; import { HeaderSearchMenu } from 'components/navigation/header/HeaderSearchMenu'; @@ -10,7 +9,7 @@ import { HeaderSelectionMenu } from 'components/navigation/header/HeaderSelectio import { HeaderAdminMenu } from 'components/navigation/header/HeaderAdminMenu'; import { HeaderHelpButton } from 'components/navigation/header/HeaderHelpButton'; import { HeaderReprocessMenu } from 'components/navigation/header/HeaderReprocessMenu'; -import { useConfig } from '@edata-portal/core'; +import { useAuthenticator, useConfig } from '@edata-portal/core'; export function Header() { const authenticator = useAuthenticator(); diff --git a/apps/portal/src/components/navigation/header/HeaderUserMenu.tsx b/apps/portal/src/components/navigation/header/HeaderUserMenu.tsx index 0d20a5429bf519b4d5ba91dc3cefdd74f206a11b..99e3bca0bd43a207e8d7922739ab5a6828c007cc 100644 --- a/apps/portal/src/components/navigation/header/HeaderUserMenu.tsx +++ b/apps/portal/src/components/navigation/header/HeaderUserMenu.tsx @@ -1,7 +1,10 @@ -import { Button, useLocalStorageValue } from '@edata-portal/core'; +import { + Button, + useAuthenticator, + useLocalStorageValue, +} from '@edata-portal/core'; import { faUser } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useAuthenticator } from 'authentication/authenticator'; import { AffiliationSwitch } from 'components/usermanagement/AffiliationSwitch'; import { Logout } from 'components/usermanagement/Logout'; import { useEffect, useState } from 'react'; diff --git a/apps/portal/src/components/routing/AppRouter.tsx b/apps/portal/src/components/routing/AppRouter.tsx index eae4a2805ab1ce2212ab466e927af3b7aa940311..2cee80e6aaf91730d420bd19cf6905cd1b9747f7 100644 --- a/apps/portal/src/components/routing/AppRouter.tsx +++ b/apps/portal/src/components/routing/AppRouter.tsx @@ -14,6 +14,5 @@ export function AppRouter({ children }: { children: JSX.Element }) { }, ]); }, [children]); - return <RouterProvider router={router} />; } diff --git a/apps/portal/src/components/usermanagement/AffiliationSwitch.tsx b/apps/portal/src/components/usermanagement/AffiliationSwitch.tsx index cb68df33d8a56992ba841f338d329ff56446d505..e7d5dd6eb1395b1b8a6b991518756444f890978f 100644 --- a/apps/portal/src/components/usermanagement/AffiliationSwitch.tsx +++ b/apps/portal/src/components/usermanagement/AffiliationSwitch.tsx @@ -1,5 +1,4 @@ -import { useUser } from '@edata-portal/core'; -import { useAppOpenID } from 'authentication/openid'; +import { useAppOpenID, useUser } from '@edata-portal/core'; import { UserAffiliationLabel } from 'components/usermanagement/UserAffiliationLabel'; import { Dropdown } from 'react-bootstrap'; diff --git a/apps/portal/src/components/usermanagement/Login.tsx b/apps/portal/src/components/usermanagement/Login.tsx index 453f75a7b0c11259e15cef35df6f0609fa8f8c0f..716492396108418baa67b0f19e38b8d64f8478dd 100644 --- a/apps/portal/src/components/usermanagement/Login.tsx +++ b/apps/portal/src/components/usermanagement/Login.tsx @@ -1,9 +1,9 @@ import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useAuthenticator } from 'authentication/authenticator'; import { type GenericAuthenticator, OpenIDAuthenticator, + useAuthenticator, useConfig, } from '@edata-portal/core'; import { Alert, Modal, Stack } from 'react-bootstrap'; diff --git a/apps/portal/src/components/usermanagement/LoginForm.tsx b/apps/portal/src/components/usermanagement/LoginForm.tsx index 2b7e12f8211ddebec03be0168e3d002fce9b3d1f..9db6be856748f9da2b083acb77214d7b4492a9e8 100644 --- a/apps/portal/src/components/usermanagement/LoginForm.tsx +++ b/apps/portal/src/components/usermanagement/LoginForm.tsx @@ -1,5 +1,7 @@ -import { useAuthenticator } from 'authentication/authenticator'; -import type { GenericAuthenticator } from '@edata-portal/core'; +import { + useAuthenticator, + type GenericAuthenticator, +} from '@edata-portal/core'; import type { FormEvent } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { diff --git a/apps/portal/src/components/usermanagement/LoginOpenID.tsx b/apps/portal/src/components/usermanagement/LoginOpenID.tsx index 6bdb5a9918167465fbc83efab934feee451f14b1..aba0dfec4a83c4b22a96b5e5d11d2b8beb4dc529 100644 --- a/apps/portal/src/components/usermanagement/LoginOpenID.tsx +++ b/apps/portal/src/components/usermanagement/LoginOpenID.tsx @@ -1,7 +1,6 @@ -import { OpenIDAuthenticator } from '@edata-portal/core'; +import { OpenIDAuthenticator, useAppOpenID } from '@edata-portal/core'; import { faLock, faWarning } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useAppOpenID } from 'authentication/openid'; import { Alert, Button } from 'react-bootstrap'; export function LoginOpenID({ diff --git a/apps/portal/src/components/usermanagement/Logout.tsx b/apps/portal/src/components/usermanagement/Logout.tsx index a7a9c1d8bd5451e1836cfd1a443b9609ce569452..959337c94de34b6aea3e7ec352a4936574506e03 100644 --- a/apps/portal/src/components/usermanagement/Logout.tsx +++ b/apps/portal/src/components/usermanagement/Logout.tsx @@ -1,5 +1,4 @@ -import { useAuthenticator } from 'authentication/authenticator'; -import { useAppOpenID } from 'authentication/openid'; +import { useAppOpenID, useAuthenticator } from '@edata-portal/core'; import { NavDropdown } from 'react-bootstrap'; export function Logout({ onClick }: { onClick: () => void }) { diff --git a/packages/core/package.json b/packages/core/package.json index 64e7c619829a166469eb354a8199db8633b42e74..3a927685a42223310417f0472c7aab587c5f1894 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,6 +38,7 @@ "react-final-form": "^6.5.9", "react-final-form-arrays": "^3.1.4", "react-intersection-observer": "^9.13.0", + "react-oidc-context": "^3.2.0", "react-plotly.js": "^2.6.0", "react-resizable-panels": "^2.1.1", "react-rnd": "^10.4.12", diff --git a/apps/portal/src/components/LoadingIndicator.tsx b/packages/core/src/components/LoadingIndicator.tsx similarity index 100% rename from apps/portal/src/components/LoadingIndicator.tsx rename to packages/core/src/components/LoadingIndicator.tsx diff --git a/apps/portal/src/authentication/anonymous.ts b/packages/core/src/components/authentication/anonymous.ts similarity index 93% rename from apps/portal/src/authentication/anonymous.ts rename to packages/core/src/components/authentication/anonymous.ts index cc39e10592bdac005d54004175c8d0b24ceac730..965bae2755ad7f7ae3bec12796cb2291e836922c 100644 --- a/apps/portal/src/authentication/anonymous.ts +++ b/packages/core/src/components/authentication/anonymous.ts @@ -1,11 +1,11 @@ -import { useConfig } from '@edata-portal/core'; import { SESSION_CREATE_ENDPOINT, useMutateEndpoint, IcatUser, } from '@edata-portal/icat-plus-api'; import { CancelledError } from '@tanstack/react-query'; -import { usePersistedUserState } from 'authentication/persist'; +import { usePersistedUserState } from 'components/authentication/persist'; +import { useConfig } from 'context'; import { useCallback, useEffect } from 'react'; const PERSISTED_ANONYMOUS_KEY = 'anonymous-user'; diff --git a/apps/portal/src/authentication/authenticator.tsx b/packages/core/src/components/authentication/authenticator.tsx similarity index 92% rename from apps/portal/src/authentication/authenticator.tsx rename to packages/core/src/components/authentication/authenticator.tsx index b3c679320f359715712d8258eb1cc88bbe282739..95c4c6b82afe2a84b1f8c109425e37f50ea5be5c 100644 --- a/apps/portal/src/authentication/authenticator.tsx +++ b/packages/core/src/components/authentication/authenticator.tsx @@ -1,13 +1,14 @@ -import { GenericAuthenticator, Loading, UserContext } from '@edata-portal/core'; import React, { useCallback, useMemo, useState } from 'react'; import { SESSION_CREATE_ENDPOINT, IcatUser, useMutateEndpoint, } from '@edata-portal/icat-plus-api'; -import { useAnonymous } from 'authentication/anonymous'; -import { usePersistedUserState } from 'authentication/persist'; -import { useUserState } from 'authentication/userState'; +import { GenericAuthenticator, UserContext } from 'context'; +import { usePersistedUserState } from 'components/authentication/persist'; +import { useUserState } from 'components/authentication/userState'; +import { useAnonymous } from 'components/authentication/anonymous'; +import { Loading } from 'components/utils'; export interface AuthenticationState { login: ( diff --git a/packages/core/src/components/authentication/index.ts b/packages/core/src/components/authentication/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb3eb566619feb20ef066e73022bd45d5be090be --- /dev/null +++ b/packages/core/src/components/authentication/index.ts @@ -0,0 +1,2 @@ +export * from './authenticator'; +export * from './openid'; diff --git a/apps/portal/src/authentication/openid.tsx b/packages/core/src/components/authentication/openid.tsx similarity index 93% rename from apps/portal/src/authentication/openid.tsx rename to packages/core/src/components/authentication/openid.tsx index 803c65603869e273632653df90d8de90aa17cfdf..9602825baa7c55ce43c0a3dd65d6730f6f290c87 100644 --- a/apps/portal/src/authentication/openid.tsx +++ b/packages/core/src/components/authentication/openid.tsx @@ -1,4 +1,3 @@ -import { OpenIDAuthenticator, useConfig } from '@edata-portal/core'; import React, { createContext, useCallback, @@ -7,10 +6,10 @@ import React, { useMemo, useState, } from 'react'; -import { useAuthenticator } from 'authentication/authenticator'; import { AuthProvider, useAuth, hasAuthParams } from 'react-oidc-context'; import { Spinner } from 'react-bootstrap'; -import { useNavigate } from 'react-router-dom'; +import { OpenIDAuthenticator, useConfig } from 'context'; +import { useAuthenticator } from 'components/authentication/authenticator'; export function useOpenIDConfig() { const config = useConfig(); @@ -235,17 +234,18 @@ export function useAppOpenID() { const PARAMS = ['state', 'code', 'session_state', 'error'] as const; function useClearParams() { - // We need to manually clear the OpenID query params after login, the openID lib does not do it. - const navigate = useNavigate(); - return useCallback(() => { if (!hasAuthParams()) return; + const params = new URLSearchParams(window.location.search); if (!PARAMS.some((p) => params.has(p))) return; + PARAMS.forEach((p) => params.delete(p)); - navigate({ - pathname: window.location.pathname, - search: params.toString(), - }); - }, [navigate]); + + window.history.replaceState( + null, + '', + `${window.location.pathname}?${params.toString()}`, + ); + }, []); } diff --git a/apps/portal/src/authentication/persist.ts b/packages/core/src/components/authentication/persist.ts similarity index 94% rename from apps/portal/src/authentication/persist.ts rename to packages/core/src/components/authentication/persist.ts index 3fc7a40e007e81531d8c13228baed7108f9e5319..fd8e1a9b77fb22e3070abc1f9316cf54cb596e05 100644 --- a/apps/portal/src/authentication/persist.ts +++ b/packages/core/src/components/authentication/persist.ts @@ -1,5 +1,5 @@ import type { IcatUser } from '@edata-portal/icat-plus-api'; -import { useUserRefresher } from 'authentication/refresh'; +import { useUserRefresher } from 'components/authentication/refresh'; import { useCallback, useMemo, useState } from 'react'; /** diff --git a/apps/portal/src/authentication/refresh.ts b/packages/core/src/components/authentication/refresh.ts similarity index 97% rename from apps/portal/src/authentication/refresh.ts rename to packages/core/src/components/authentication/refresh.ts index fd046013725eaef441e1ce1eaa2caf839a104061..f55fe88f8c87d194b2073e5c70610cc485be2e9d 100644 --- a/apps/portal/src/authentication/refresh.ts +++ b/packages/core/src/components/authentication/refresh.ts @@ -1,4 +1,3 @@ -import { addToDate, parseDate, useConfig } from '@edata-portal/core'; import { SESSION_BY_ID_ENDPOINT, SESSION_REFRESH_ENDPOINT, @@ -6,6 +5,8 @@ import { useMutateEndpoint, useAsyncFetchEndpoint, } from '@edata-portal/icat-plus-api'; +import { useConfig } from 'context'; +import { addToDate, parseDate } from 'helpers'; import { useCallback, useEffect } from 'react'; export function useUserRefresher( diff --git a/apps/portal/src/authentication/userState.tsx b/packages/core/src/components/authentication/userState.tsx similarity index 80% rename from apps/portal/src/authentication/userState.tsx rename to packages/core/src/components/authentication/userState.tsx index 1bb2a38374c7e5b193fabf21f8cc7ec6c8ea610b..9a2ce9021912c1b7399f844fcecb01049d004f2b 100644 --- a/apps/portal/src/authentication/userState.tsx +++ b/packages/core/src/components/authentication/userState.tsx @@ -1,5 +1,5 @@ import type { IcatUser } from '@edata-portal/icat-plus-api'; -import { useUserRefresher } from 'authentication/refresh'; +import { useUserRefresher } from 'components/authentication/refresh'; import { useState } from 'react'; export function useUserState() { diff --git a/packages/core/src/components/dataset/generic/DatasetList.tsx b/packages/core/src/components/dataset/generic/DatasetList.tsx index 63184b98d2f119fdf8f7a617af07406d96710778..1ed3c96ac1b714b627d257eff4f4ad3ac4df1ef8 100644 --- a/packages/core/src/components/dataset/generic/DatasetList.tsx +++ b/packages/core/src/components/dataset/generic/DatasetList.tsx @@ -50,7 +50,6 @@ export function DatasetList( ) { // State to handle scrolling to a specific sample const [scrollToSample, setScrollToSample] = useParam('scrollToSample', ''); - // Callback to reset scroll state after scrolling is done const onScrollDone = useCallback(() => { setScrollToSample(undefined); diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 47b7fd03f11a6bc89fc01bf8eed6340ac40b1382..489d744c0ca3e788a8c1c786f2b313d6dad03223 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -15,3 +15,5 @@ export * from './browse'; export * from './filters'; export * from './gridgraph'; export * from './tabs'; +export * from './authentication'; +export * from './navigation'; diff --git a/apps/portal/src/components/navigation/SideNav.tsx b/packages/core/src/components/navigation/SideNav.tsx similarity index 96% rename from apps/portal/src/components/navigation/SideNav.tsx rename to packages/core/src/components/navigation/SideNav.tsx index 39a738fa0506e43763851326e822f825046d587a..5ee93a01fa7752680263e189335a706a832da9e2 100644 --- a/apps/portal/src/components/navigation/SideNav.tsx +++ b/packages/core/src/components/navigation/SideNav.tsx @@ -1,13 +1,10 @@ -import { - SideNavContext, - SideNavNode, - UpdateSideNavContext, - immutableArray, - useBreakpointValue, -} from '@edata-portal/core'; import { faAnglesLeft, faAnglesRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { LoadingIndicator } from 'components/LoadingIndicator'; +import { SideNavContext, SideNavNode, UpdateSideNavContext } from 'context'; +import { immutableArray } from 'helpers'; +import { useBreakpointValue } from 'hooks'; + import React from 'react'; import { Button, Container } from 'react-bootstrap'; diff --git a/packages/core/src/components/navigation/index.ts b/packages/core/src/components/navigation/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..caaad63a9150c18434f08cb70fe29b94e4dbc8b4 --- /dev/null +++ b/packages/core/src/components/navigation/index.ts @@ -0,0 +1 @@ +export * from './SideNav'; diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 8eccab12f5cf3e6cff10707e80ab5a0884487fce..94cfafce2e5b6ef406ee39db3a7816e93fe7eac4 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -5,3 +5,5 @@ export * from './hooks'; export * from './helpers'; export * from './context'; + +export * from './provider'; diff --git a/apps/portal/src/components/api/APIProvider.tsx b/packages/core/src/provider/APIProvider.tsx similarity index 95% rename from apps/portal/src/components/api/APIProvider.tsx rename to packages/core/src/provider/APIProvider.tsx index 570372f7a10c02e9250c27fdd5b5b0ed43a8dd1c..3cbc87ee8e85ea46c741660c1428b27bc04bfe0f 100644 --- a/apps/portal/src/components/api/APIProvider.tsx +++ b/packages/core/src/provider/APIProvider.tsx @@ -1,5 +1,5 @@ -import { useConfig, useNotify, useUser } from '@edata-portal/core'; import { IcatPlusAPIContext } from '@edata-portal/icat-plus-api'; +import { useConfig, useNotify, useUser } from 'context'; import { useMemo, type PropsWithChildren } from 'react'; export function UnauthenticatedAPIProvider({ diff --git a/packages/core/src/provider/index.tsx b/packages/core/src/provider/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09dc067729a86e00750df338b1b63f021e88982f --- /dev/null +++ b/packages/core/src/provider/index.tsx @@ -0,0 +1 @@ +export * from './APIProvider'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5b31d25a30656a7f071cd99fdb2114954308cb6..037e18938371fec673c5acf08f625d01eb7c7b7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,15 @@ importers: '@tanstack/react-query': specifier: ^5.52.1 version: 5.52.1(react@18.3.1) + ajv: + specifier: ^8.17.1 + version: 8.17.1 + bootstrap: + specifier: ^5.3.3 + version: 5.3.3(@popperjs/core@2.11.8) + bootswatch: + specifier: ^5.3.3 + version: 5.3.3 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -683,9 +692,6 @@ importers: react-intersection-observer: specifier: ^9.13.0 version: 9.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-oidc-context: - specifier: ^3.1.0 - version: 3.1.0(oidc-client-ts@3.0.1)(react@18.3.1) react-router-dom: specifier: ^6.26.1 version: 6.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -892,6 +898,9 @@ importers: react-intersection-observer: specifier: ^9.13.0 version: 9.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-oidc-context: + specifier: ^3.2.0 + version: 3.2.0(oidc-client-ts@3.0.1)(react@18.3.1) react-plotly.js: specifier: ^2.6.0 version: 2.6.0(plotly.js-dist-min@2.34.0)(react@18.3.1) @@ -4676,11 +4685,11 @@ packages: react: '>0.13.0' react-dom: '>0.13.0' - react-oidc-context@3.1.0: - resolution: {integrity: sha512-ceQztvDfdl28mbr0So31XF/tCJamyF1+nm4AQNIE/nub+Xs9PLtDqLy/+75Yx1ahI0/n3nsq0R2qcP0R2Laa3Q==} + react-oidc-context@3.2.0: + resolution: {integrity: sha512-ZLaCRLWV84Cn9pFdsatmblqxLMv0np69GWVXq9RWGqAjppdOGXNIbIxWMByIio0oSCVUwdeqwYRnJme0tjqd8A==} engines: {node: '>=18'} peerDependencies: - oidc-client-ts: ^3.0.0 + oidc-client-ts: ^3.1.0 react: '>=16.8.0' react-onclickoutside@6.13.1: @@ -9829,7 +9838,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) resize-observer-polyfill: 1.5.1 - react-oidc-context@3.1.0(oidc-client-ts@3.0.1)(react@18.3.1): + react-oidc-context@3.2.0(oidc-client-ts@3.0.1)(react@18.3.1): dependencies: oidc-client-ts: 3.0.1 react: 18.3.1