From 70bcd518d0d2bab98b838d39305d47bb1341053f Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 11 Dec 2024 16:31:26 +0000 Subject: [PATCH] Implement global page structure --- package-lock.json | 18 +- packages/client/package.json | 4 +- packages/client/src/App.tsx | 64 +++++- .../client/src/components/InnerPageHeader.tsx | 79 +++++++ .../client/src/components/MainNavigation.tsx | 50 +++-- packages/client/src/components/SmartLink.tsx | 22 ++ packages/client/src/components/index.tsx | 12 +- .../src/pages/CollectionDetail/index.tsx | 177 ++++++++++----- .../src/pages/CollectionForm/EditForm.tsx | 61 +++--- .../src/pages/CollectionForm_/index.tsx | 176 --------------- .../client/src/pages/CollectionForm_/types.ts | 15 -- .../CollectionForm_/useUpdateCollection.ts | 38 ---- packages/client/src/pages/CollectionList.tsx | 35 +-- packages/client/src/pages/Home.tsx | 16 +- .../client/src/pages/ItemDetail/index.tsx | 206 ++++++++++++------ packages/client/src/pages/ItemList/index.tsx | 11 +- packages/client/src/pages/NotFound.tsx | 25 ++- 17 files changed, 543 insertions(+), 466 deletions(-) create mode 100644 packages/client/src/components/InnerPageHeader.tsx create mode 100644 packages/client/src/components/SmartLink.tsx delete mode 100644 packages/client/src/pages/CollectionForm_/index.tsx delete mode 100644 packages/client/src/pages/CollectionForm_/types.ts delete mode 100644 packages/client/src/pages/CollectionForm_/useUpdateCollection.ts diff --git a/package-lock.json b/package-lock.json index 4d46b72..7e9fb9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5509,12 +5509,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.9.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", - "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.8" + "undici-types": "~6.20.0" } }, "node_modules/@types/normalize-package-data": { @@ -17040,9 +17040,9 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, "node_modules/union-value": { @@ -17756,7 +17756,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "formik": "^2.4.6", - "framer-motion": "^11.0.3", + "framer-motion": "^10.16.5", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "maplibre-gl": "^3.6.2", "polished": "^4.3.1", @@ -17774,7 +17774,7 @@ "@parcel/reporter-bundle-analyzer": "^2.12.0", "@parcel/reporter-bundle-buddy": "^2.12.0", "@types/babel__core": "^7", - "@types/node": "^22.9.0", + "@types/node": "^22.10.2", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", "buffer": "^6.0.3", diff --git a/packages/client/package.json b/packages/client/package.json index ad20756..eabb48d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -35,7 +35,7 @@ "@parcel/reporter-bundle-analyzer": "^2.12.0", "@parcel/reporter-bundle-buddy": "^2.12.0", "@types/babel__core": "^7", - "@types/node": "^22.9.0", + "@types/node": "^22.10.2", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", "buffer": "^6.0.3", @@ -74,7 +74,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "formik": "^2.4.6", - "framer-motion": "^11.0.3", + "framer-motion": "^10.16.5", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "maplibre-gl": "^3.6.2", "polished": "^4.3.1", diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 0d90d61..68f6e3d 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,6 +1,15 @@ import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; -import { ChakraProvider, Box, Container } from '@chakra-ui/react'; +import { + ChakraProvider, + Box, + Container, + Flex, + Heading, + Text, + Badge, + Divider +} from '@chakra-ui/react'; import { StacApiProvider } from '@developmentseed/stac-react'; import { PluginConfigProvider } from '@stac-manager/data-core'; @@ -21,20 +30,25 @@ export const App = () => ( - - + - - STAC Admin - + + STAC Manager + + - + } /> @@ -60,6 +74,32 @@ export const App = () => ( } /> + + + + Powered by{' '} + + STAC Manager{' '} + + {process.env.APP_VERSION} + + {' '} + + + {new Date().getFullYear()} + + diff --git a/packages/client/src/components/InnerPageHeader.tsx b/packages/client/src/components/InnerPageHeader.tsx new file mode 100644 index 0000000..c3f5fa2 --- /dev/null +++ b/packages/client/src/components/InnerPageHeader.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Flex, Heading, Text, forwardRef, FlexProps } from '@chakra-ui/react'; + +interface InnerPageHeaderProps extends FlexProps { + title: string; + overline?: string; + actions?: React.ReactNode; +} + +export const InnerPageHeader = forwardRef( + ({ title, overline, actions, ...rest }, ref) => { + return ( + + {overline && ( + + {overline} + + )} + + {title} + {actions && {actions}} + + + ); + } +); + +export const InnerPageHeaderSticky = forwardRef( + (props, ref) => { + const [isAtTop, setIsAtTop] = useState(false); + + const localRef = useRef(null); + + useEffect(() => { + const el = localRef.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + setIsAtTop(entry.intersectionRatio < 1); + }, + { threshold: [1] } + ); + + observer.observe(el); + + return () => { + observer.unobserve(el); + }; + }, []); + + const headerRef: React.RefCallback = (v) => { + localRef.current = v; + if (typeof ref === 'function') { + ref(v); + } else if (ref != null) { + (ref as React.MutableRefObject).current = v; + } + }; + + return ( + + ); + } +); diff --git a/packages/client/src/components/MainNavigation.tsx b/packages/client/src/components/MainNavigation.tsx index 66e1eac..642d3ae 100644 --- a/packages/client/src/components/MainNavigation.tsx +++ b/packages/client/src/components/MainNavigation.tsx @@ -1,34 +1,42 @@ -import { Link as RouterLink } from "react-router-dom"; -import { Box, List, ListItem, Link } from "@chakra-ui/react"; +import React from 'react'; +import { Box, List, ListItem, Button, ButtonProps } from '@chakra-ui/react'; +import { + CollecticonFolder, + CollecticonPlusSmall +} from '@devseed-ui/collecticons-chakra'; -type NavItemProps = React.PropsWithChildren<{ - to: string -}> +import SmartLink, { SmartLinkProps } from './SmartLink'; -function NavItem({ to, children }: NavItemProps) { +function NavItem(props: ButtonProps & SmartLinkProps) { return ( - - {children} - + + + } + variant='outline' + size='sm' + /> + + } + as={SmartLink} + to={`${process.env.REACT_APP_STAC_BROWSER}/stac/collections/${id}`} + > + View in STAC Browser + + } + color='danger.500' + _hover={{ bg: 'danger.200' }} + _focus={{ bg: 'danger.200' }} + onClick={() => alert('Soon!')} + > + Delete + + + + + } + /> + + - - - About - + + + + About + + + + - { (title || description) && ( - - { title && { title } } - { description } + {(title || description) && ( + + {title && {title} } + {description} )} - - - - { dateLabel } + + + + {dateLabel} - - - { license } + + + {license} - { (keywords && keywords.length > 0) && ( - + {keywords && keywords.length > 0 && ( + {keywords.map((keyword) => ( - {keyword} + + {keyword} + ))} )} - Items in this collection + Items in this collection - + ); } diff --git a/packages/client/src/pages/CollectionForm/EditForm.tsx b/packages/client/src/pages/CollectionForm/EditForm.tsx index 788646e..2159ef5 100644 --- a/packages/client/src/pages/CollectionForm/EditForm.tsx +++ b/packages/client/src/pages/CollectionForm/EditForm.tsx @@ -13,11 +13,13 @@ import { TabList, TabPanel, TabPanels, - Tabs, - Text + Tabs } from '@chakra-ui/react'; import { Formik, FormikHelpers } from 'formik'; import { WidgetJSON } from '@stac-manager/data-widgets'; +import { CollecticonTickSmall } from '@devseed-ui/collecticons-chakra'; + +import { InnerPageHeaderSticky } from '$components/InnerPageHeader'; type FormView = 'fields' | 'json'; @@ -63,38 +65,29 @@ export function EditForm(props: { // @ts-expect-error Can't detect the as=form and throws error onSubmit={handleSubmit} > - - - - {initialData && 'Edit '}Collection - - - {initialData - ? initialData.title || 'Untitled' - : 'New Collection'} - - - - + + + + } + /> ({ values: collection }); - const { fields, append, remove } = useFieldArray({ - control, - name: 'providers' - }); - const onSubmit = (data: StacCollection) => { - update(data).then(reload); - }; - - if (!collection || state === 'LOADING') { - return Loading collection...; - } - - return ( - <> - - Edit Collection {collection.id} - -
- - - - - ( - - )} - control={control} - /> - -
- Providers - - - - - - - - - - - - {fields.map(({ id }, idx: number) => ( - - - - - - - - ))} - -
NameDescriptionRolesURL -
- - - - - ( - - )} - control={control} - /> - - - - } - onClick={() => remove(idx)} - aria-label='Remove provider' - /> -
- - - -
- - - - - - - ); -} - -export default CollectionForm; diff --git a/packages/client/src/pages/CollectionForm_/types.ts b/packages/client/src/pages/CollectionForm_/types.ts deleted file mode 100644 index 3065943..0000000 --- a/packages/client/src/pages/CollectionForm_/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { StacProvider, Extents, StacAsset, StacLink } from "stac-ts"; - -export type FormValues = { - id: string; - stac_version: "1.0.0"; - type: "Collection"; - title: string; - description: string; - license: string; - keywords: string[]; - providers: StacProvider[]; - assets: { [k: string]: StacAsset; }; - extent: Extents; - links: StacLink[]; -} diff --git a/packages/client/src/pages/CollectionForm_/useUpdateCollection.ts b/packages/client/src/pages/CollectionForm_/useUpdateCollection.ts deleted file mode 100644 index bfd5670..0000000 --- a/packages/client/src/pages/CollectionForm_/useUpdateCollection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useState } from "react"; -import { StacCollection } from "stac-ts"; -import Api from "../../api"; -import { LoadingState, ApiError } from "../../types"; - -type UseUpdateCollectionType = { - update: (data: StacCollection) => Promise; - error?: ApiError; - state: LoadingState; -} - -function useUpdateCollection(): UseUpdateCollectionType { - const [ error, setError ] = useState(); - const [ state, setState ] = useState("IDLE"); - - const update = useCallback((data: StacCollection) => { - setState("LOADING"); - - return Api.fetch( - `${process.env.REACT_APP_STAC_API}/collections/`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data) - }, - ) - .catch((e) => setError(e)) - .finally(() => setState("IDLE")); - }, []); - - return { - update, - error, - state - }; -} - -export default useUpdateCollection; diff --git a/packages/client/src/pages/CollectionList.tsx b/packages/client/src/pages/CollectionList.tsx index 2e56ce7..e345594 100644 --- a/packages/client/src/pages/CollectionList.tsx +++ b/packages/client/src/pages/CollectionList.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { TableContainer, Table, @@ -9,13 +9,16 @@ import { Th, Td, Tbody, - Heading, Flex } from '@chakra-ui/react'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons-chakra'; import { useCollections } from '@developmentseed/stac-react'; import type { StacCollection } from 'stac-ts'; + import { Loading } from '../components'; import { usePageTitle } from '../hooks'; +import { InnerPageHeader } from '$components/InnerPageHeader'; +import SmartLink from '$components/SmartLink'; function CollectionList() { usePageTitle('Collections'); @@ -23,18 +26,22 @@ function CollectionList() { const { collections, state } = useCollections(); return ( - - - Collections - - + + } + > + Create + + } + /> diff --git a/packages/client/src/pages/Home.tsx b/packages/client/src/pages/Home.tsx index 71e8aca..ea1e092 100644 --- a/packages/client/src/pages/Home.tsx +++ b/packages/client/src/pages/Home.tsx @@ -1,14 +1,12 @@ -import { Text } from "@chakra-ui/react"; -import { usePageTitle } from "../hooks"; +import React from 'react'; -function Home () { - usePageTitle("STAC Admin"); +import { usePageTitle } from '../hooks'; +import { Navigate } from 'react-router-dom'; - return ( - <> - STAC Admin - - ); +function Home() { + usePageTitle('STAC Admin'); + + return ; } export default Home; diff --git a/packages/client/src/pages/ItemDetail/index.tsx b/packages/client/src/pages/ItemDetail/index.tsx index 63f50fd..dff5391 100644 --- a/packages/client/src/pages/ItemDetail/index.tsx +++ b/packages/client/src/pages/ItemDetail/index.tsx @@ -1,33 +1,52 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { Box, Heading, Icon, Text } from "@chakra-ui/react"; -import { MdEdit } from "react-icons/md"; -import Map, { Source, Layer, MapRef } from "react-map-gl/maplibre"; -import StacFields from "@radiantearth/stac-fields"; -import { useItem } from "@developmentseed/stac-react"; -import getBbox from "@turf/bbox"; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { + Box, + Text, + Icon, + Button, + Flex, + IconButton, + Menu, + MenuButton, + MenuList, + MenuItem +} from '@chakra-ui/react'; +import { MdEdit } from 'react-icons/md'; +import Map, { Source, Layer, MapRef } from 'react-map-gl/maplibre'; +import StacFields from '@radiantearth/stac-fields'; +import { StacAsset } from 'stac-ts'; +import { useItem } from '@developmentseed/stac-react'; +import { + CollecticonEllipsisVertical, + CollecticonGlobe, + CollecticonPencil, + CollecticonTrashBin +} from '@devseed-ui/collecticons-chakra'; +import getBbox from '@turf/bbox'; -import { usePageTitle } from "../../hooks"; -import { HeadingLead, Loading } from "../../components"; -import PropertyList from "./PropertyList"; -import { PropertyGroup } from "../../types"; -import { BackgroundTiles } from "../../components/Map"; -import AssetList from "./AssetList"; -import { StacAsset } from "stac-ts"; +import { usePageTitle } from '../../hooks'; +import { Loading } from '../../components'; +import PropertyList from './PropertyList'; +import { PropertyGroup } from '../../types'; +import { BackgroundTiles } from '../../components/Map'; +import AssetList from './AssetList'; +import { InnerPageHeader } from '$components/InnerPageHeader'; +import SmartLink from '$components/SmartLink'; const resultsOutline = { - "line-color": "#C53030", - "line-width": 2, + 'line-color': '#C53030', + 'line-width': 2 }; const resultsFill = { - "fill-color": "#C53030", - "fill-opacity": 0.1 + 'fill-color': '#C53030', + 'fill-opacity': 0.1 }; const cogMediaTypes = [ - "image/tiff; application=geotiff; profile=cloud-optimized", - "image/vnd.stac.geotiff", + 'image/tiff; application=geotiff; profile=cloud-optimized', + 'image/vnd.stac.geotiff' ]; function ItemDetail() { @@ -36,7 +55,7 @@ function ItemDetail() { const itemResource = `${process.env.REACT_APP_STAC_API}/collections/${collectionId}/items/${itemId}`; const { item, state } = useItem(itemResource); - const [ map, setMap ] = useState(); + const [map, setMap] = useState(); const setMapRef = (m: MapRef) => setMap(m); // Fit the map view around the current results bbox @@ -52,25 +71,22 @@ function ItemDetail() { const previewAsset = useMemo(() => { if (!item) return; - return Object.values(item.assets).reduce( - (preview, asset) => { - const { type, href, roles } = asset as StacAsset; - if (cogMediaTypes.includes(type || "")) { - if (!preview) { + return Object.values(item.assets).reduce((preview, asset) => { + const { type, href, roles } = asset as StacAsset; + if (cogMediaTypes.includes(type || '')) { + if (!preview) { + return href; + } else { + if (roles && roles.includes('visual')) { return href; - } else { - if (roles && roles.includes("visual")) { - return href; - } } } - return preview; - }, - undefined - ); + } + return preview; + }, undefined); }, [item]); - if (!item || state === "LOADING") { + if (!item || state === 'LOADING') { return Loading item...; } @@ -78,53 +94,113 @@ function ItemDetail() { const formattedProperties = StacFields.formatItemProperties({ properties }); return ( - <> - - Item {item.id} - - + + + + + } + variant='outline' + size='sm' + /> + + } + as={SmartLink} + to={`${process.env.REACT_APP_STAC_BROWSER}/stac/collections/${properties.collection}/items/${properties.id}/edit`} + > + View in STAC Browser + + } + color='danger.500' + _hover={{ bg: 'danger.200' }} + _focus={{ bg: 'danger.200' }} + onClick={() => alert('Soon!')} + > + Delete + + + + + } + /> + - - + + - { previewAsset && ( + {previewAsset && ( - + )} - - - { !previewAsset && } + + + {!previewAsset && ( + + )} - - - About - + + + + About + + + + - { (title || description) && ( - - { title && { title } } - { description } + {(title || description) && ( + + {title && {title} } + {description} )} - { formattedProperties.map((property: PropertyGroup) => )} + {formattedProperties.map((property: PropertyGroup) => ( + + ))} - + ); } diff --git a/packages/client/src/pages/ItemList/index.tsx b/packages/client/src/pages/ItemList/index.tsx index b566c21..f7ff921 100644 --- a/packages/client/src/pages/ItemList/index.tsx +++ b/packages/client/src/pages/ItemList/index.tsx @@ -1,10 +1,11 @@ import React, { useEffect } from 'react'; -import { Heading, Flex } from '@chakra-ui/react'; +import { Flex } from '@chakra-ui/react'; import { useStacSearch } from '@developmentseed/stac-react'; import { usePageTitle } from '../../hooks'; import ItemListFilter from './ItemListFilter'; import ItemResults from '../../components/ItemResults'; +import { InnerPageHeader } from '$components/InnerPageHeader'; function ItemList() { usePageTitle('Items'); @@ -23,14 +24,14 @@ function ItemList() { // Submit handlers and effects useEffect(() => { - // Automatically submit to receive intial results + // Automatically submit to receive initial results if (results) return; submit(); - }, [submit]); // eslint-disable-line react-hooks/exhaustive-deps + }, [submit]); return ( - - Items + + - Not found - Try browsing collections or items instead. - + + + + The resource you're looking for could not be found. + + Perhaps in the mean time you can check out the{' '} + collections page. + + + ); }