diff --git a/.env b/.env index bdf7fe1..384242f 100644 --- a/.env +++ b/.env @@ -3,4 +3,7 @@ APP_DESCRIPTION=GFTS point cloud #MAPBOX_TOKEN= -#DATA_API='' \ No newline at end of file +#DATA_API='' +REACT_APP_KEYCLOAK_URL +REACT_APP_KEYCLOAK_REALM +REACT_APP_KEYCLOAK_CLIENT_ID \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d54927..6fd80fb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ lib-cov *.pid npm-debug.log yarn-error.log +.yarn .parcel-cache .env.* diff --git a/.nvmrc b/.nvmrc index 2edeafb..cabf43b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +24 \ No newline at end of file diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz deleted file mode 100644 index 59f58e7..0000000 Binary files a/.yarn/install-state.gz and /dev/null differ diff --git a/README.md b/README.md index 31b11ba..e43f20a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The steps below will walk you through setting up your own instance of the projec ### Install Project Dependencies To set up the development environment for this website, you'll need to install the following on your system: -- [Node](http://nodejs.org/) v20 (To manage multiple node versions we recommend [nvm](https://github.com/creationix/nvm)) +- [Node](http://nodejs.org/) v24 (To manage multiple node versions we recommend [nvm](https://github.com/creationix/nvm)) - [Yarn](https://yarnpkg.com/) Package manager ### Install Application Dependencies diff --git a/app/components/auth/context.tsx b/app/components/auth/context.tsx new file mode 100644 index 0000000..bb05565 --- /dev/null +++ b/app/components/auth/context.tsx @@ -0,0 +1,124 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import Keycloak from 'keycloak-js'; + +const url = process.env.REACT_APP_KEYCLOAK_URL; +const realm = process.env.REACT_APP_KEYCLOAK_REALM; +const clientId = process.env.REACT_APP_KEYCLOAK_CLIENT_ID; + +const isAuthEnabled = !!(url && realm && clientId); + +const keycloak: Keycloak | undefined = isAuthEnabled + ? new (Keycloak as any)({ + url, + realm, + clientId + }) + : undefined; + +interface UserProfile { + groups: string[]; + username: string; +} + +export type KeycloakContextProps = { + initStatus: 'loading' | 'success' | 'error'; + isLoading: boolean; + profile?: UserProfile; +} & ( + | { + keycloak: Keycloak; + isEnabled: true; + } + | { + keycloak: undefined; + isEnabled: false; + } +); + +const KeycloakContext = createContext({ + initStatus: 'loading', + isEnabled: isAuthEnabled +} as KeycloakContextProps); + +export const KeycloakProvider = (props: { children: React.ReactNode }) => { + const [initStatus, setInitStatus] = + useState('loading'); + const [profile, setProfile] = useState(); + + const wasInit = useRef(false); + + useEffect(() => { + async function initialize() { + if (!keycloak) return; + // Keycloak can only be initialized once. This is a workaround to avoid + // multiple initialization attempts, specially by React double rendering. + if (wasInit.current) return; + wasInit.current = true; + + try { + await keycloak.init({ + // onLoad: 'login-required', + onLoad: 'check-sso', + checkLoginIframe: false + }); + if (keycloak.authenticated) { + // const profile = + // await (keycloak.loadUserProfile() as unknown as Promise); + setProfile({ + groups: keycloak.idTokenParsed?.access_group || [], + username: keycloak.idTokenParsed?.preferred_username || '' + }); + } + + setInitStatus('success'); + } catch (err) { + setInitStatus('error'); + // eslint-disable-next-line no-console + console.error('Failed to initialize keycloak adapter:', err); + } + } + initialize(); + }, []); + + const base = { + initStatus, + isLoading: isAuthEnabled && initStatus === 'loading', + profile + }; + + return ( + + {props.children} + + ); +}; + +export const useKeycloak = () => { + const ctx = useContext(KeycloakContext); + + if (!ctx) { + throw new Error('useKeycloak must be used within a KeycloakProvider'); + } + + return ctx; +}; diff --git a/app/components/auth/userInfo.tsx b/app/components/auth/userInfo.tsx new file mode 100644 index 0000000..5ec6aab --- /dev/null +++ b/app/components/auth/userInfo.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Avatar, IconButton, Tooltip } from '@chakra-ui/react'; +import { CollecticonLogin } from '@devseed-ui/collecticons-chakra'; + +import { useKeycloak } from './context'; +import SmartLink from '$components/common/smart-link'; + +export function UserInfo() { + const { profile, isLoading, isEnabled, keycloak } = useKeycloak(); + + if (!isEnabled) { + return null; + } + + const isAuthenticated = keycloak.authenticated; + + if (!isAuthenticated || !profile || isLoading) { + return ( + + } + onClick={() => { + if (!isLoading) { + keycloak.login({ + redirectUri: window.location.href + }); + } + }} + /> + + ); + } + + const username = profile.username; + + return ( + + { + e.preventDefault(); + keycloak.clearToken(); + keycloak.logout({ + redirectUri: window.location.href + }); + }} + > + + + + ); +} diff --git a/app/components/common/md-content.tsx b/app/components/common/md-content.tsx index 874d6df..7023860 100644 --- a/app/components/common/md-content.tsx +++ b/app/components/common/md-content.tsx @@ -12,6 +12,7 @@ import { } from '@chakra-ui/react'; import React, { Suspense, useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { NotFound } from './error/not-found'; import { getMdFn } from '$utils/api'; const Markdown = React.lazy(() => import('react-markdown')); @@ -53,9 +54,17 @@ export function MdContent(props: MdContentProps) { const { data, error, isLoading } = useQuery({ enabled: !!url, queryKey: ['markdown', url || 'n/a'], - queryFn: getMdFn(url || '') + queryFn: getMdFn(url || ''), + retry(failureCount, error) { + // Retry only if the error is a NotFound error + return error instanceof NotFound ? false : failureCount < 3; + } }); + if (error instanceof NotFound) { + return 'This was not a well-known individual. Nothing to learn about this fish.'; + } + if (isLoading || !remarkGfmPlugin) { return ; } diff --git a/app/components/common/page-footer.tsx b/app/components/common/page-footer.tsx new file mode 100644 index 0000000..05ff29d --- /dev/null +++ b/app/components/common/page-footer.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { + IconButton, + Flex, + useDisclosure, + Divider, + Image +} from '@chakra-ui/react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + CollecticonCircleInformation, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons-chakra'; + +import destineLogo from '../../media/layout/destine.svg'; +import ecmwfLogo from '../../media/layout/ecmwf.svg'; +import esaLogo from '../../media/layout/esa.svg'; +import euFlag from '../../media/layout/eu.svg'; +import eumetsatLogo from '../../media/layout/eumetsat.svg'; + +import SmartLink from './smart-link'; +const FooterSection = (props) => ( + +); + +export function PageFooter() { + const { getButtonProps, getDisclosureProps, isOpen } = useDisclosure(); + + return ( + + + {isOpen ? ( + + + + Part of + + Destination earth + + + + Funded by The European Union + EU Flag + + + Implemented by + + ECMWF + + + ESA + + + EUMETSAT + + + + + + ) : null} + + + {isOpen ? : } + + + ); +} diff --git a/app/components/common/page-layout.tsx b/app/components/common/page-layout.tsx index 2a63f12..7722081 100644 --- a/app/components/common/page-layout.tsx +++ b/app/components/common/page-layout.tsx @@ -8,6 +8,8 @@ import { List, ListItem, Show, + Skeleton, + SkeletonText, Text } from '@chakra-ui/react'; import { @@ -18,7 +20,9 @@ import { import { Link, LinkProps, Route, Switch, useRoute } from 'wouter'; import SmartLink from './smart-link'; import Logo from './logo'; +import { PageFooter } from './page-footer'; import { AppContextProvider } from '$components/common/app-context'; +import { UserInfo } from '$components/auth/userInfo'; const Home = React.lazy(() => import('../home/')); const Search = React.lazy(() => import('../search/')); @@ -83,6 +87,9 @@ export default function PageLayout() { + + + + Loading...; + return ( + + + + + + ); } function MapLoading() { diff --git a/app/components/home/index.tsx b/app/components/home/index.tsx index aa717fd..ebecd56 100644 --- a/app/components/home/index.tsx +++ b/app/components/home/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Badge, Box, + Flex, Heading, Image, List, @@ -26,7 +27,7 @@ export default function Component() { }); return ( - + - + {isSuccess ? data?.map((species) => ( @@ -84,6 +91,6 @@ export default function Component() { ))} - + ); } diff --git a/app/components/individual/index.tsx b/app/components/individual/index.tsx index d2dcb7d..01c0f74 100644 --- a/app/components/individual/index.tsx +++ b/app/components/individual/index.tsx @@ -135,27 +135,21 @@ export default function Component(props: SpeciesComponentProps) { ) } /> - - {/* */} - + > Visualize Learn - + {isArrowFetching && ( diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index c1b8760..5502095 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useLocation } from 'wouter'; -import { Badge, Box, Heading } from '@chakra-ui/react'; +import { Badge, Box, Flex, Heading } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import { Select } from 'chakra-react-select'; @@ -74,7 +74,7 @@ export default function Component() { } return ( - + )} - + ); } diff --git a/app/main.tsx b/app/main.tsx index 4e76729..ac58b4e 100644 --- a/app/main.tsx +++ b/app/main.tsx @@ -7,6 +7,7 @@ import { Router } from 'wouter'; import theme from '$styles/theme'; import PageLayout from '$components/common/page-layout'; +import { KeycloakProvider } from '$components/auth/context'; const publicUrl = process.env.PUBLIC_URL || ''; @@ -33,21 +34,23 @@ function Root() { }, []); return ( - - - - - - - + + + + + + + + + ); } diff --git a/app/media/layout/destine.svg b/app/media/layout/destine.svg new file mode 100644 index 0000000..73b1030 --- /dev/null +++ b/app/media/layout/destine.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/media/layout/ecmwf.svg b/app/media/layout/ecmwf.svg new file mode 100644 index 0000000..a69c040 --- /dev/null +++ b/app/media/layout/ecmwf.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/media/layout/esa.svg b/app/media/layout/esa.svg new file mode 100644 index 0000000..fa62387 --- /dev/null +++ b/app/media/layout/esa.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/media/layout/eu.svg b/app/media/layout/eu.svg new file mode 100644 index 0000000..afe6a05 --- /dev/null +++ b/app/media/layout/eu.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/media/layout/eumetsat.svg b/app/media/layout/eumetsat.svg new file mode 100644 index 0000000..faa7c77 --- /dev/null +++ b/app/media/layout/eumetsat.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/types.d.ts b/app/types.d.ts index 1c59232..66e8eec 100644 --- a/app/types.d.ts +++ b/app/types.d.ts @@ -2,3 +2,8 @@ declare module '*.png' { const value: string; export default value; } + +declare module '*.svg' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/app/utils/api.ts b/app/utils/api.ts index cbf6d14..9567333 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -57,6 +57,10 @@ export function getMdFn(url: string) { return async () => { const response = await fetch(`${process.env.DATA_API || ''}${url}`); + if (response.status === 404) { + throw new NotFound('Resource not found', { url }); + } + try { if (!response.ok) { throw new Error(); diff --git a/package.json b/package.json index 05c6d94..4d25011 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "echo \"Error: no test specified\" && exit 0" }, "engines": { - "node": "20.x" + "node": "24.x" }, "browserslist": "> 0.5%, last 2 versions, not dead", "devDependencies": { @@ -30,6 +30,7 @@ "@tanstack/eslint-plugin-query": "^5.62.1", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", + "@types/keycloak-js": "^3.4.1", "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -92,6 +93,7 @@ "date-fns": "^3.3.1", "deck.gl": "^9.0.0", "framer-motion": "^11.11.0", + "keycloak-js": "26.1.5", "mapbox-gl": "^3.1.2", "polished": "^4.3.1", "react": "^18.2.0", diff --git a/tsconfig.json b/tsconfig.json index 79fdc6c..78ec1f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "strictNullChecks": true, "baseUrl": "./", "jsx": "react", + /* Modules */ + "module": "ESNext", /* Specify what module code is generated. */ + "moduleResolution": "bundler", "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ "$components/*": ["./app/components/*"], diff --git a/yarn.lock b/yarn.lock index 2ca76cd..aa9b7dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6343,6 +6343,15 @@ __metadata: languageName: node linkType: hard +"@types/keycloak-js@npm:^3.4.1": + version: 3.4.1 + resolution: "@types/keycloak-js@npm:3.4.1" + dependencies: + keycloak-js: "npm:*" + checksum: 10c0/acc6384153dbc809ffc803c6245dce44a0cc1235bfa138c216c8eb0c7cedd16558954d192a6fced4f397afadebf09bdb80acec71ab373c71d12a6f70bcf503f9 + languageName: node + linkType: hard + "@types/lodash.mergewith@npm:4.6.9": version: 4.6.9 resolution: "@types/lodash.mergewith@npm:4.6.9" @@ -10438,6 +10447,7 @@ __metadata: "@turf/bbox": "npm:^7.2.0" "@types/d3": "npm:^7.4.3" "@types/geojson": "npm:^7946.0.14" + "@types/keycloak-js": "npm:^3.4.1" "@types/mapbox-gl": "npm:^2.7.20" "@types/node": "npm:^22.10.1" "@types/react": "npm:^18.3.12" @@ -10470,6 +10480,7 @@ __metadata: jest: "npm:^29.7.0" jest-css-modules-transform: "npm:^4.4.2" jest-environment-jsdom: "npm:^29.7.0" + keycloak-js: "npm:26.1.5" mapbox-gl: "npm:^3.1.2" parcel: "npm:^2.11.0" parcel-resolver-ignore: "npm:^2.2.0" @@ -12161,6 +12172,20 @@ __metadata: languageName: node linkType: hard +"keycloak-js@npm:*": + version: 26.2.0 + resolution: "keycloak-js@npm:26.2.0" + checksum: 10c0/a0c97c29ce90ac689f27dce9c6fed92a353b4d06b4339e03ed1adc663edbbcffac59ac9a55abf18f7df95fa57f08958683c7e8a36bc536791407ef6a02eb0de0 + languageName: node + linkType: hard + +"keycloak-js@npm:26.1.5": + version: 26.1.5 + resolution: "keycloak-js@npm:26.1.5" + checksum: 10c0/d0050d4de981b2dd0ec33fc906ea30638bf06c76f5087979acff992e443b86d2bacd427f1bc0cbe4c8fc40c3d7756d148caa6af14d2c1a88fb0993cae2762f5e + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4"