diff --git a/configure/src/core/ConfigureStore.js b/configure/src/core/ConfigureStore.js index c0c28fc73..2fbc1789b 100644 --- a/configure/src/core/ConfigureStore.js +++ b/configure/src/core/ConfigureStore.js @@ -33,6 +33,8 @@ export const ConfigureStore = createSlice({ updateDataset: false, deleteDataset: false, newStacCollection: false, + stacCollectionItems: false, + stacCollectionJson: false, layersUsedByStacCollection: false, deleteStacCollection: false, uploadConfig: false, diff --git a/configure/src/core/calls.js b/configure/src/core/calls.js index 4f97bb6b6..3bb01626c 100644 --- a/configure/src/core/calls.js +++ b/configure/src/core/calls.js @@ -106,6 +106,14 @@ const c = { type: "DELETE", url: "stac/collections/:collection", }, + stac_collection_items: { + type: "GET", + url: "stac/collections/:collection/items", + }, + stac_delete_item: { + type: "DELETE", + url: "stac/collections/:collection/items/:item", + }, account_entries: { type: "GET", url: "api/accounts/entries", diff --git a/configure/src/pages/STAC/Modals/AppendGeoDatasetModal/AppendGeoDatasetModal.js b/configure/src/pages/STAC/Modals/AppendGeoDatasetModal/AppendGeoDatasetModal.js deleted file mode 100644 index cb96b8377..000000000 --- a/configure/src/pages/STAC/Modals/AppendGeoDatasetModal/AppendGeoDatasetModal.js +++ /dev/null @@ -1,360 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useSelector, useDispatch } from "react-redux"; - -import { calls } from "../../../../core/calls"; - -import { setModal, setSnackBarText } from "../../../../core/ConfigureStore"; - -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import IconButton from "@mui/material/IconButton"; - -import CloseSharpIcon from "@mui/icons-material/CloseSharp"; -import ControlPointDuplicateIcon from "@mui/icons-material/ControlPointDuplicate"; -import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; - -import { useDropzone } from "react-dropzone"; - -import TextField from "@mui/material/TextField"; - -import { makeStyles, useTheme } from "@mui/styles"; -import useMediaQuery from "@mui/material/useMediaQuery"; - -const useStyles = makeStyles((theme) => ({ - Modal: { - margin: theme.headHeights[1], - [theme.breakpoints.down("xs")]: { - margin: "6px", - }, - "& .MuiDialog-container": { - height: "unset !important", - transform: "translateX(-50%) translateY(-50%)", - left: "50%", - top: "50%", - position: "absolute", - }, - }, - contents: { - background: theme.palette.primary.main, - height: "100%", - width: "600px", - }, - heading: { - height: theme.headHeights[2], - boxSizing: "border-box", - background: theme.palette.swatches.p[0], - borderBottom: `1px solid ${theme.palette.swatches.grey[800]}`, - padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, - }, - title: { - padding: `8px 0px`, - fontSize: theme.typography.pxToRem(16), - fontWeight: "bold", - textTransform: "uppercase", - }, - content: { - padding: "8px 16px 16px 16px !important", - height: `calc(100% - ${theme.headHeights[2]}px)`, - }, - closeIcon: { - padding: theme.spacing(1.5), - height: "100%", - margin: "4px 0px", - }, - flexBetween: { - display: "flex", - justifyContent: "space-between", - }, - subtitle: { - fontSize: "14px !important", - width: "100%", - marginBottom: "8px !important", - color: theme.palette.swatches.grey[300], - letterSpacing: "0.2px", - }, - subtitle2: { - fontSize: "12px !important", - fontStyle: "italic", - width: "100%", - marginBottom: "8px !important", - color: theme.palette.swatches.grey[400], - }, - missionNameInput: { - width: "100%", - margin: "8px 0px 4px 0px !important", - }, - backgroundIcon: { - margin: "7px 8px 0px 0px", - }, - - fileName: { - textAlign: "center", - fontWeight: "bold", - letterSpacing: "1px", - marginBottom: "10px", - paddingBottom: "10px", - borderBottom: `1px solid ${theme.palette.swatches.grey[500]}`, - }, - dropzone: { - width: "100%", - minHeight: "100px", - margin: "16px 0px", - "& > div": { - flex: "1 1 0%", - display: "flex", - flexDirection: "column", - alignItems: "center", - padding: "20px", - borderWidth: "2px", - borderRadius: "2px", - borderColor: theme.palette.swatches.grey[300], - borderStyle: "dashed", - backgroundColor: theme.palette.swatches.grey[900], - color: theme.palette.swatches.grey[200], - outline: "none", - transition: "border 0.24s ease-in-out 0s", - "&:hover": { - borderColor: theme.palette.swatches.p[11], - }, - }, - }, - dropzoneMessage: { - textAlign: "center", - color: theme.palette.swatches.p[11], - "& > p:first-child": { fontWeight: "bold", letterSpacing: "1px" }, - "& > p:last-child": { fontSize: "14px", fontStyle: "italic" }, - }, - timeFields: { - display: "flex", - "& > div:first-child": { - marginRight: "5px", - }, - "& > div:last-child": { - marginLeft: "5px", - }, - }, -})); - -const MODAL_NAME = "appendGeoDataset"; -const AppendGeoDatasetModal = (props) => { - const { queryGeoDatasets } = props; - const c = useStyles(); - - const modal = useSelector((state) => state.core.modal[MODAL_NAME]); - - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("md")); - - const dispatch = useDispatch(); - - const [geojson, setGeojson] = useState(null); - const [fileName, setFileName] = useState(null); - const [startTimeField, setStartTimeField] = useState(null); - const [endTimeField, setEndTimeField] = useState(null); - - useEffect(() => { - setStartTimeField(modal?.geoDataset?.start_time_field); - setEndTimeField(modal?.geoDataset?.end_time_field); - }, [JSON.stringify(modal)]); - - const handleClose = () => { - // close modal - dispatch(setModal({ name: MODAL_NAME, on: false })); - }; - const handleSubmit = () => { - const geoDatasetName = modal?.geoDataset?.name; - - if (geojson == null || fileName === null) { - dispatch( - setSnackBarText({ - text: "Please upload a file.", - severity: "error", - }) - ); - return; - } - - if (geoDatasetName === null) { - dispatch( - setSnackBarText({ - text: "No GeoDataset found to append to.", - severity: "error", - }) - ); - return; - } - const forceParams = { - filename: fileName, - }; - if (startTimeField) forceParams.start_prop = startTimeField; - if (endTimeField) forceParams.end_prop = endTimeField; - - calls.api( - "geodatasets_append", - { - urlReplacements: { - name: geoDatasetName, - }, - forceParams, - type: geojson.type, - features: geojson.features, - }, - (res) => { - if (res.status === "success") { - dispatch( - setSnackBarText({ - text: "Successfully appended to GeoDataset.", - severity: "success", - }) - ); - queryGeoDatasets(); - handleClose(); - } else { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to append to GeoDataset.", - severity: "error", - }) - ); - } - }, - (res) => { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to append to GeoDataset.", - severity: "error", - }) - ); - } - ); - }; - - // Dropzone initialization - const { - getRootProps, - getInputProps, - isDragActive, - isDragAccept, - isDragReject, - } = useDropzone({ - maxFiles: 1, - accept: { - "application/json": [".json", ".geojson"], - }, - onDropAccepted: (files) => { - const file = files[0]; - setFileName(file.name); - - const reader = new FileReader(); - reader.onload = (e) => { - setGeojson(JSON.parse(e.target.result)); - }; - reader.readAsText(file); - }, - onDropRejected: () => { - setFileName(null); - setGeojson(null); - }, - }); - - return ( - - -
-
- -
{`Append features to this GeoDataset: ${modal?.geoDataset?.name}`}
-
- - - -
-
- - - {`Appends the features of the uploaded file to the GeoDataset`} - -
-
- - {isDragAccept &&

All files will be accepted

} - {isDragReject &&

Some files will be rejected

} - {!isDragActive && ( -
-

Drag 'n' drop or click to select files...

-

Only *.json and *.geojson files are accepted.

-
- )} -
-
- -
- -
{fileName || "No File Selected"}
-
- -
-
- { - setStartTimeField(e.target.value); - }} - /> - - {`If this GeoDataset already has a Start Time Field attached, the name of that start time field inside each feature's "properties" object for which to create a temporal index for the geodataset. Take care in using time field names for the appended GeoJSON features that are different from that of the existing features.`} - -
-
- { - setEndTimeField(e.target.value); - }} - /> - - {`If this GeoDataset already has a End Time Field attached, the name of that end time field inside each feature's "properties" object for which to create a temporal index for the geodataset. Take care in using time field names for the appended GeoJSON features that are different from that of the existing features.`} - -
-
-
- - - -
- ); -}; - -export default AppendGeoDatasetModal; diff --git a/configure/src/pages/STAC/Modals/PreviewGeoDatasetModal/PreviewGeoDatasetModal.js b/configure/src/pages/STAC/Modals/PreviewGeoDatasetModal/PreviewGeoDatasetModal.js deleted file mode 100644 index 3c0918e41..000000000 --- a/configure/src/pages/STAC/Modals/PreviewGeoDatasetModal/PreviewGeoDatasetModal.js +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useSelector, useDispatch } from "react-redux"; - -import { calls } from "../../../../core/calls"; - -import { setModal, setSnackBarText } from "../../../../core/ConfigureStore"; - -import Dialog from "@mui/material/Dialog"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import IconButton from "@mui/material/IconButton"; - -import CloseSharpIcon from "@mui/icons-material/CloseSharp"; -import PreviewIcon from "@mui/icons-material/Preview"; - -import { makeStyles, useTheme } from "@mui/styles"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import Map from "../../../../components/Map/Map"; - -const useStyles = makeStyles((theme) => ({ - Modal: { - [theme.breakpoints.down("xs")]: { - margin: "6px", - }, - "& .MuiDialog-container": { - width: "100%", - transform: "translateX(-50%) translateY(-50%)", - left: "50%", - top: "50%", - position: "absolute", - }, - "& .MuiPaper-root": { - margin: "0px", - }, - }, - contents: { - height: "100%", - width: "100%", - maxWidth: "calc(100vw - 32px) !important", - maxHeight: "calc(100vh - 32px) !important", - }, - heading: { - height: theme.headHeights[2], - boxSizing: "border-box", - background: theme.palette.swatches.p[0], - padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, - }, - title: { - padding: `8px 0px`, - fontSize: theme.typography.pxToRem(16), - fontWeight: "bold", - textTransform: "uppercase", - }, - content: { - padding: "0px !important", - height: `calc(100vh - 32px)`, - background: theme.palette.swatches.grey[100], - }, - closeIcon: { - padding: theme.spacing(1.5), - height: "100%", - margin: "4px 0px", - }, - flexBetween: { - display: "flex", - justifyContent: "space-between", - }, - backgroundIcon: { - margin: "7px 8px 0px 0px", - }, -})); - -const MODAL_NAME = "previewGeoDataset"; -const PreviewGeoDatasetModal = (props) => { - const {} = props; - const c = useStyles(); - - const modal = useSelector((state) => state.core.modal[MODAL_NAME]); - const [geoDataset, setGeoDataset] = useState(null); - - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("md")); - - const dispatch = useDispatch(); - - const handleClose = () => { - // close modal - dispatch(setModal({ name: MODAL_NAME, on: false })); - }; - - const queryGeoDataset = () => { - if (modal?.geoDataset?.name) - calls.api( - "geodatasets_get", - { - layer: modal.geoDataset.name, - }, - (res) => { - setGeoDataset(res); - }, - (res) => { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to get geodataset.", - severity: "error", - }) - ); - } - ); - }; - useEffect(() => { - queryGeoDataset(); - }, [modal?.geoDataset?.name]); - - return ( - - -
-
- -
{`Previewing GeoDataset${ - modal?.geoDataset?.name ? `: ${modal.geoDataset.name}` : "" - }`}
-
- - - -
-
- - - -
- ); -}; - -export default PreviewGeoDatasetModal; diff --git a/configure/src/pages/STAC/Modals/StacCollectionItemsModal/StacCollectionItemsModal.js b/configure/src/pages/STAC/Modals/StacCollectionItemsModal/StacCollectionItemsModal.js new file mode 100644 index 000000000..47e64b153 --- /dev/null +++ b/configure/src/pages/STAC/Modals/StacCollectionItemsModal/StacCollectionItemsModal.js @@ -0,0 +1,973 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { calls } from "../../../../core/calls"; + +import { setModal, setSnackBarText } from "../../../../core/ConfigureStore"; + +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; +import TableRow from "@mui/material/TableRow"; +import CircularProgress from "@mui/material/CircularProgress"; +import Box from "@mui/material/Box"; +import Tooltip from "@mui/material/Tooltip"; +import InputAdornment from "@mui/material/InputAdornment"; +import Divider from "@mui/material/Divider"; + +import CloseSharpIcon from "@mui/icons-material/CloseSharp"; +import WidgetsIcon from "@mui/icons-material/Widgets"; +import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; +import SearchIcon from "@mui/icons-material/Search"; +import InfoIcon from "@mui/icons-material/Info"; + +import { makeStyles, useTheme } from "@mui/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import ReactJson from "react-json-view"; +import * as L from "leaflet"; + +import "leaflet/dist/leaflet.css"; + +const useStyles = makeStyles((theme) => ({ + Modal: { + margin: theme.headHeights[1], + [theme.breakpoints.down("xs")]: { + margin: "6px", + }, + "& .MuiDialog-container": { + height: "unset !important", + transform: "translateX(-50%) translateY(-50%)", + left: "50%", + top: "50%", + position: "absolute", + }, + }, + contents: { + background: theme.palette.primary.main, + height: "80vh", + width: "90vw", + maxWidth: "1600px", + minWidth: "1000px", + }, + heading: { + height: theme.headHeights[2], + boxSizing: "border-box", + background: theme.palette.swatches.p[0], + padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, + }, + title: { + padding: `8px 0px`, + fontSize: theme.typography.pxToRem(16), + fontWeight: "bold", + color: theme.palette.swatches.grey[0], + textTransform: "uppercase", + }, + content: { + padding: "16px !important", + height: `calc(100% - ${theme.headHeights[2]}px)`, + display: "flex", + flexDirection: "column", + }, + closeIcon: { + padding: theme.spacing(1.5), + height: "100%", + margin: "4px 0px", + }, + flexBetween: { + display: "flex", + justifyContent: "space-between", + }, + backgroundIcon: { + margin: "7px 8px 0px 0px", + }, + searchContainer: { + marginBottom: "16px", + }, + tableContainer: { + flex: 1, + overflowY: "auto", + boxShadow: "0px 1px 7px 0px rgba(0, 0, 0, 0.2)", + }, + table: { + "& .MuiTableHead-root": { + "& .MuiTableCell-root": { + backgroundColor: `${theme.palette.swatches.grey[1000]} !important`, + color: `${theme.palette.swatches.grey[0]} !important`, + fontWeight: "bold !important", + textTransform: "uppercase", + letterSpacing: "1px !important", + borderRight: `1px solid ${theme.palette.swatches.grey[900]}`, + }, + }, + "& .MuiTableBody-root": { + "& .MuiTableRow-root": { + background: theme.palette.swatches.grey[850], + "&:hover": { + background: theme.palette.swatches.grey[900], + }, + }, + "& .MuiTableCell-root": { + borderRight: `1px solid ${theme.palette.swatches.grey[800]}`, + borderBottom: `1px solid ${theme.palette.swatches.grey[700]} !important`, + color: theme.palette.swatches.grey[100], + }, + }, + "& td:first-child": { + fontWeight: "bold", + letterSpacing: "1px", + fontSize: "16px", + color: `${theme.palette.swatches.p[13]}`, + }, + }, + loadingContainer: { + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "200px", + }, + noItems: { + textAlign: "center", + padding: "40px", + color: theme.palette.swatches.grey[400], + fontStyle: "italic", + }, + deleteIcon: { + width: "40px !important", + height: "40px !important", + marginLeft: "4px !important", + "&:hover": { + background: "#c43541 !important", + color: `${theme.palette.swatches.grey[900]} !important`, + }, + }, + infoIcon: { + width: "40px !important", + height: "40px !important", + marginRight: "4px !important", + }, + collectionTitle: { + fontSize: "18px !important", + color: theme.palette.swatches.grey[100], + fontWeight: "bold !important", + marginBottom: "8px !important", + }, + limitMessage: { + fontSize: "14px !important", + color: theme.palette.swatches.grey[400], + fontStyle: "italic", + marginBottom: "16px !important", + }, + itemId: { + fontFamily: "monospace", + fontSize: "12px", + }, + pagination: { + borderTop: `1px solid ${theme.palette.swatches.grey[700]}`, + background: theme.palette.swatches.grey[850], + }, + jsonDialog: { + "& .MuiDialog-paper": { + maxWidth: "90vw", + maxHeight: "85vh", + width: "1800px", + height: "1000px", + background: theme.palette.primary.main, + }, + }, + jsonContent: { + padding: "0px !important", + height: "100%", + overflow: "hidden", + background: theme.palette.swatches.grey[150], + display: "flex", + }, + mapPanel: { + width: "50%", + height: "100%", + background: theme.palette.swatches.grey[900], + borderRight: `1px solid ${theme.palette.swatches.grey[700]}`, + }, + mapContainer: { + width: "100%", + height: "100%", + position: "relative", + "& .leaflet-container": { + background: theme.palette.swatches.grey[900], + }, + }, + coordinateDisplay: { + left: "0px", + color: "white", + bottom: "62px", + display: "none", + padding: "6px 10px", + zIndex: "1000", + position: "absolute", + fontSize: "12px", + background: theme.palette.swatches.grey[300], + fontFamily: "monospace", + pointerEvents: "none", + }, + jsonPanel: { + width: "50%", + height: "100%", + }, + jsonContainer: { + background: theme.palette.swatches.grey[150], + padding: "16px", + boxSizing: "border-box", + height: "100%", + overflow: "auto", + }, + jsonDialogActions: { + background: theme.palette.swatches.grey[200], + padding: "16px 24px !important", + }, + jsonDialogActionsClose: { + color: `${theme.palette.swatches.grey[800]} !important`, + border: `1px solid ${theme.palette.swatches.grey[800]} !important`, + '&:hover': { + background: `${theme.palette.swatches.grey[900]} !important`, + color: `${theme.palette.swatches.grey[0]} !important`, + }, + }, + deleteDialog: { + "& .MuiDialog-paper": { + background: theme.palette.swatches.grey[900], + width: "500px", + }, + }, + deleteDialogTitle: { + background: theme.palette.swatches.p[4], + padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, + }, + deleteDialogContent: { + padding: "16px !important", + }, + deleteItemName: { + textAlign: "center", + fontSize: "20px !important", + letterSpacing: "1px !important", + color: theme.palette.swatches.p[4], + fontWeight: "bold !important", + borderBottom: `1px solid ${theme.palette.swatches.grey[100]}`, + paddingBottom: "10px", + fontFamily: "monospace", + }, + deleteConfirmInput: { + width: "100%", + margin: "16px 0px 8px 0px !important", + borderTop: `1px solid ${theme.palette.swatches.grey[500]}`, + }, + deleteConfirmMessage: { + fontStyle: "italic", + fontSize: "15px !important", + color: theme.palette.swatches.grey[300], + }, + deleteDialogActions: { + display: "flex !important", + justifyContent: "space-between !important", + padding: "16px 24px !important", + }, + deleteButton: { + background: `${theme.palette.swatches.p[4]} !important`, + color: `${theme.palette.swatches.grey[1000]} !important`, + "&:hover": { + background: `${theme.palette.swatches.grey[0]} !important`, + }, + }, +})); + +const MODAL_NAME = "stacCollectionItems"; + +const StacCollectionItemsModal = () => { + const c = useStyles(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const dispatch = useDispatch(); + + const modal = useSelector((state) => state.core.modal[MODAL_NAME]); + + const [allItems, setAllItems] = useState([]); + const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [jsonModalOpen, setJsonModalOpen] = useState(false); + const [selectedItemJson, setSelectedItemJson] = useState(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + + // Map related state + const mapRef = useRef(null); + const coordinateDisplayRef = useRef(null); + const [map, setMap] = useState(null); + const [mapLayers, setMapLayers] = useState({ bbox: null, raster: null }); + + const fetchAllItems = (searchQuery = "") => { + if (!modal?.stacCollection?.id) return; + + const body = { + urlReplacements: { + collection: modal.stacCollection.id, + }, + limit: 500, + offset: 0 + } + const filter = searchQuery && searchQuery.trim() ? `id LIKE '%${searchQuery.trim()}%'` : null + if (filter !== null) { + body.filter = filter + } + + setLoading(true); + calls.api( + "stac_collection_items", + body, + (res) => { + if (res?.features) { + setAllItems(res.features); + } else { + setAllItems([]); + } + setLoading(false); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || "Failed to fetch STAC items.", + severity: "error", + }) + ); + setAllItems([]); + setLoading(false); + } + ); + }; + + // Local pagination: get items for current page + const getCurrentPageItems = () => { + const startIndex = page * rowsPerPage; + const endIndex = startIndex + rowsPerPage; + return allItems.slice(startIndex, endIndex); + }; + + const items = getCurrentPageItems(); + const totalItems = allItems.length; + + useEffect(() => { + if (modal && modal.stacCollection) { + setSearchTerm(""); + setPage(0); + fetchAllItems(""); + } + }, [modal]); + + const handleClose = () => { + dispatch(setModal({ name: MODAL_NAME, on: false })); + setAllItems([]); + setSearchTerm(""); + setPage(0); + // Also close JSON modal if it's open + setJsonModalOpen(false); + setSelectedItemJson(null); + // Also close delete modal if it's open + setDeleteModalOpen(false); + setItemToDelete(null); + setDeleteConfirmation(""); + }; + + const handleSearchChange = (event) => { + const value = event.target.value; + setSearchTerm(value); + setPage(0); + fetchAllItems(value); + }; + + const handlePageChange = (event, newPage) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = (event) => { + const newRowsPerPage = parseInt(event.target.value, 10); + setRowsPerPage(newRowsPerPage); + setPage(0); + }; + + const handleDeleteItem = (itemId) => { + if (!modal?.stacCollection?.id || !itemId) return; + + setItemToDelete(itemId); + setDeleteModalOpen(true); + setDeleteConfirmation(""); + }; + + const handleCloseDeleteModal = () => { + setDeleteModalOpen(false); + setItemToDelete(null); + setDeleteConfirmation(""); + }; + + const handleConfirmDelete = () => { + if (!modal?.stacCollection?.id || !itemToDelete) { + dispatch( + setSnackBarText({ + text: "Cannot delete undefined STAC Item.", + severity: "error", + }) + ); + return; + } + + if (deleteConfirmation !== itemToDelete) { + dispatch( + setSnackBarText({ + text: "Confirmation item ID does not match.", + severity: "error", + }) + ); + return; + } + + calls.api( + "stac_delete_item", + { + urlReplacements: { + collection: modal.stacCollection.id, + item: itemToDelete, + }, + }, + (res) => { + dispatch( + setSnackBarText({ + text: `Successfully deleted item '${itemToDelete}'.`, + severity: "success", + }) + ); + handleCloseDeleteModal(); + // Refresh all items after deletion + fetchAllItems(searchTerm); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || `Failed to delete item '${itemToDelete}'.`, + severity: "error", + }) + ); + } + ); + }; + + const handleViewJson = (item) => { + setSelectedItemJson(item); + setJsonModalOpen(true); + + // Initialize map when modal opens + setTimeout(() => { + initializeMap(item); + }, 100); + }; + + const handleCloseJsonModal = () => { + setJsonModalOpen(false); + setSelectedItemJson(null); + + // Clean up map + if (map) { + map.remove(); + setMap(null); + setMapLayers({ bbox: null, raster: null }); + } + }; + + const initializeMap = (item) => { + if (!mapRef.current || map) return; + + try { + // Ensure Leaflet is available + const leaflet = window.L || L; + if (!leaflet) { + console.error('Leaflet not available'); + return; + } + + // Create map with OSM base layer + const newMap = leaflet.map(mapRef.current, { + center: [0, 0], + zoom: 2, + zoomControl: true, + attributionControl: true, + }); + + // Add OSM tile layer + leaflet.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18, + }).addTo(newMap); + + // Add coordinate tracking on mouse move - direct DOM update for performance + newMap.on('mousemove', (e) => { + const { lat, lng } = e.latlng; + if (coordinateDisplayRef.current) { + coordinateDisplayRef.current.textContent = `Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}`; + coordinateDisplayRef.current.style.display = 'block'; + } + }); + + // Hide coordinates when mouse leaves the map + newMap.on('mouseout', () => { + if (coordinateDisplayRef.current) { + coordinateDisplayRef.current.style.display = 'none'; + } + }); + + setMap(newMap); + + // Add item data to map + if (item) { + addItemToMap(newMap, item); + } + } catch (error) { + console.error('Error initializing map:', error); + } + }; + + const addItemToMap = (mapInstance, item) => { + try { + const leaflet = window.L || L; + if (!leaflet) return; + + // Clear existing layers + if (mapLayers.bbox) { + mapInstance.removeLayer(mapLayers.bbox); + } + if (mapLayers.raster) { + mapInstance.removeLayer(mapLayers.raster); + } + + let bounds = null; + + // Add bounding box if available + if (item.bbox && item.bbox.length >= 4) { + const [minLon, minLat, maxLon, maxLat] = item.bbox; + bounds = [[minLat, minLon], [maxLat, maxLon]]; + + const bboxLayer = leaflet.rectangle(bounds, { + color: '#ff7800', + weight: 2, + opacity: 1, + fillColor: '#ff7800', + fillOpacity: 0.1, + }).addTo(mapInstance); + + setMapLayers(prev => ({ ...prev, bbox: bboxLayer })); + } + + // Add raster layer from assets if available + if (item.assets) { + const cogAsset = findCogAsset(item.assets); + if (cogAsset && cogAsset.href) { + addRasterLayer(mapInstance, cogAsset.href, cogAsset); + } + } + + // Fit map to bounds + if (bounds) { + mapInstance.fitBounds(bounds, { padding: [20, 20] }); + } else if (item.geometry && item.geometry.coordinates) { + // Fallback to geometry if no bbox + const coords = item.geometry.coordinates; + if (coords.length > 0) { + const flatCoords = coords.flat().flat(); + const lons = flatCoords.filter((_, i) => i % 2 === 0); + const lats = flatCoords.filter((_, i) => i % 2 === 1); + const minLon = Math.min(...lons); + const maxLon = Math.max(...lons); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + bounds = [[minLat, minLon], [maxLat, maxLon]]; + mapInstance.fitBounds(bounds, { padding: [20, 20] }); + } + } + } catch (error) { + console.error('Error adding item to map:', error); + } + }; + + const findCogAsset = (assets) => { + // Look for COG/TIF assets + const assetKeys = Object.keys(assets); + + // Common COG asset names + const cogKeys = ['data', 'cog', 'image', 'tif', 'tiff']; + + for (const key of cogKeys) { + if (assets[key] && assets[key].href) { + return assets[key]; + } + } + + // Fallback to first asset with href + for (const key of assetKeys) { + if (assets[key] && assets[key].href && + (assets[key].href.includes('.tif') || assets[key].href.includes('.cog'))) { + return assets[key]; + } + } + + return null; + }; + + const addRasterLayer = (mapInstance, cogUrl, cogAsset) => { + try { + const leaflet = window.L || L; + if (!leaflet) return; + + let domain = + window.mmgisglobal.NODE_ENV === "development" + ? "http://localhost:8888/" + : window.mmgisglobal.ROOT_PATH || ""; + if (domain.length > 0 && !domain.endsWith("/")) domain += "/"; + + // Start building the titiler URL + let titilerUrl = `${domain}titiler/cog/tiles/WebMercatorQuad/{z}/{x}/{y}?url=${encodeURIComponent(cogUrl)}`; + + // Check if this is 32-bit float data that needs rescaling + console.log('COG Asset:', cogAsset); + if (cogAsset && cogAsset['raster:bands'] && cogAsset['raster:bands'][0]) { + const firstBand = cogAsset['raster:bands'][0]; + console.log('Raster band info:', firstBand); + + if (firstBand.data_type === 'float32' && firstBand.statistics) { + const stats = firstBand.statistics; + if (stats.minimum !== undefined && stats.maximum !== undefined) { + // Add rescaling and colormap for 32-bit float data + console.log(`Adding rescale for float32 data: ${stats.minimum}-${stats.maximum}`); + titilerUrl += `&rescale=${stats.minimum},${stats.maximum}&colormap_name=viridis`; + } + } + } + + const rasterLayer = leaflet.tileLayer(titilerUrl, { + attribution: 'COG via TiTiler', + opacity: 0.8, + maxZoom: 18, + }).addTo(mapInstance); + + setMapLayers(prev => ({ ...prev, raster: rasterLayer })); + } catch (error) { + console.error('Error adding raster layer:', error); + } + }; + + const formatDateTime = (dateTimeStr) => { + if (!dateTimeStr) return "N/A"; + try { + return new Date(dateTimeStr).toLocaleString(); + } catch { + return dateTimeStr; + } + }; + + const getAssetsHref = (item) => { + if (!item?.assets) return "N/A"; + + // Look for the first asset with an href + const assetKeys = Object.keys(item.assets); + for (const key of assetKeys) { + if (item.assets[key]?.href) { + return item.assets[key].href; + } + } + + return "N/A"; + }; + + return ( + + +
+
+ +
STAC Collection Items
+
+ + + +
+
+ + + Collection: {modal?.stacCollection?.id} + + + Showing at most 500 items. Use search to narrow results if needed. + + +
+ + + + ), + }} + /> +
+ + + + + + Item ID + DateTime + Assets Href + + + + + {loading ? ( + + +
+ +
+
+
+ ) : items.length === 0 ? ( + + +
+ {searchTerm + ? `No items found matching "${searchTerm}"` + : "No items found in this collection"} +
+
+
+ ) : ( + items.map((item) => ( + + {item.id} + + {formatDateTime(item.properties?.datetime)} + + +
+ {getAssetsHref(item)} +
+
+ +
+ + handleViewJson(item)} + > + + + + + + + + handleDeleteItem(item.id)} + > + + + +
+
+
+ )) + )} +
+
+
+ + +
+ + {/* JSON View Modal */} + + +
+
+ +
STAC Item: {selectedItemJson?.id}
+
+ + + +
+
+ + {/* Map Panel */} +
+
+ {/* Coordinate Display */} +
+ Lat: 0.000000, Lng: 0.000000 +
+
+ + {/* JSON Panel */} +
+
+ {selectedItemJson && ( + + )} +
+
+ + + + +
+ + {/* Delete Item Confirmation Modal */} + + +
+
+ +
Delete STAC Item
+
+ + + +
+
+ + + Collection: {modal?.stacCollection?.id} + + + {`Deleting: ${itemToDelete || ""}`} + + setDeleteConfirmation(e.target.value)} + placeholder={`Type "${itemToDelete}" to confirm`} + /> + + {`Enter '${itemToDelete || ""}' above and click 'Delete' to confirm the permanent deletion of this STAC item.`} + + + + + + +
+
+ ); +}; + +export default StacCollectionItemsModal; \ No newline at end of file diff --git a/configure/src/pages/STAC/Modals/StacCollectionJsonModal/StacCollectionJsonModal.js b/configure/src/pages/STAC/Modals/StacCollectionJsonModal/StacCollectionJsonModal.js new file mode 100644 index 000000000..b78967b08 --- /dev/null +++ b/configure/src/pages/STAC/Modals/StacCollectionJsonModal/StacCollectionJsonModal.js @@ -0,0 +1,159 @@ +import React from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { setModal } from "../../../../core/ConfigureStore"; + +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; + +import CloseSharpIcon from "@mui/icons-material/CloseSharp"; +import InfoIcon from "@mui/icons-material/Info"; + +import { makeStyles, useTheme } from "@mui/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import ReactJson from "react-json-view"; + +const useStyles = makeStyles((theme) => ({ + Modal: { + + }, + contents: { + maxWidth: "80vw", + maxHeight: "80vh", + width: "1100px", + background: theme.palette.primary.main, + }, + heading: { + height: theme.headHeights[2], + boxSizing: "border-box", + background: theme.palette.swatches.p[0], + borderBottom: `1px solid ${theme.palette.swatches.grey[800]}`, + padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, + }, + title: { + padding: `8px 0px`, + fontSize: theme.typography.pxToRem(16), + fontWeight: "bold", + color: theme.palette.swatches.grey[0], + textTransform: "uppercase", + }, + content: { + padding: "0px !important", + height: "100%", + overflow: "none", + background: theme.palette.swatches.grey[150], + }, + closeIcon: { + padding: theme.spacing(1.5), + height: "100%", + margin: "4px 0px", + }, + flexBetween: { + display: "flex", + justifyContent: "space-between", + }, + backgroundIcon: { + margin: "7px 8px 0px 0px", + }, + jsonContainer: { + background: theme.palette.swatches.grey[150], + padding: "16px", + boxSizing: "border-box", + height: "100%", + overflow: "auto", + }, + dialogActions: { + background: theme.palette.swatches.grey[200], + padding: "16px 24px !important", + }, + dialogActionsClose: { + color: `${theme.palette.swatches.grey[800]} !important`, + border: `1px solid ${theme.palette.swatches.grey[800]} !important`, + '&:hover': { + background: `${theme.palette.swatches.grey[900]} !important`, + color: `${theme.palette.swatches.grey[0]} !important`, + }, + }, +})); + +const MODAL_NAME = "stacCollectionJson"; + +const StacCollectionJsonModal = () => { + const c = useStyles(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const dispatch = useDispatch(); + + const modal = useSelector((state) => state.core.modal[MODAL_NAME]); + + const handleClose = () => { + dispatch(setModal({ name: MODAL_NAME, on: false })); + }; + + return ( + + +
+
+ +
Collection JSON: {modal?.collection?.id}
+
+ + + +
+
+ +
+ {modal?.collection && ( + + )} +
+
+ + + +
+ ); +}; + +export default StacCollectionJsonModal; \ No newline at end of file diff --git a/configure/src/pages/STAC/Modals/UpdateGeoDatasetModal/UpdateGeoDatasetModal.js b/configure/src/pages/STAC/Modals/UpdateGeoDatasetModal/UpdateGeoDatasetModal.js deleted file mode 100644 index 62e0c68bf..000000000 --- a/configure/src/pages/STAC/Modals/UpdateGeoDatasetModal/UpdateGeoDatasetModal.js +++ /dev/null @@ -1,354 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useSelector, useDispatch } from "react-redux"; - -import { calls } from "../../../../core/calls"; - -import { setModal, setSnackBarText } from "../../../../core/ConfigureStore"; - -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import IconButton from "@mui/material/IconButton"; - -import CloseSharpIcon from "@mui/icons-material/CloseSharp"; -import UploadIcon from "@mui/icons-material/Upload"; -import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; - -import { useDropzone } from "react-dropzone"; - -import TextField from "@mui/material/TextField"; - -import { makeStyles, useTheme } from "@mui/styles"; -import useMediaQuery from "@mui/material/useMediaQuery"; - -const useStyles = makeStyles((theme) => ({ - Modal: { - margin: theme.headHeights[1], - [theme.breakpoints.down("xs")]: { - margin: "6px", - }, - "& .MuiDialog-container": { - height: "unset !important", - transform: "translateX(-50%) translateY(-50%)", - left: "50%", - top: "50%", - position: "absolute", - }, - }, - contents: { - background: theme.palette.primary.main, - height: "100%", - width: "600px", - }, - heading: { - height: theme.headHeights[2], - boxSizing: "border-box", - background: theme.palette.swatches.p[0], - borderBottom: `1px solid ${theme.palette.swatches.grey[800]}`, - padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, - }, - title: { - padding: `8px 0px`, - fontSize: theme.typography.pxToRem(16), - fontWeight: "bold", - textTransform: "uppercase", - }, - content: { - padding: "8px 16px 16px 16px !important", - height: `calc(100% - ${theme.headHeights[2]}px)`, - }, - closeIcon: { - padding: theme.spacing(1.5), - height: "100%", - margin: "4px 0px", - }, - flexBetween: { - display: "flex", - justifyContent: "space-between", - }, - subtitle: { - fontSize: "14px !important", - width: "100%", - marginBottom: "8px !important", - color: theme.palette.swatches.grey[300], - letterSpacing: "0.2px", - }, - subtitle2: { - fontSize: "12px !important", - fontStyle: "italic", - width: "100%", - marginBottom: "8px !important", - color: theme.palette.swatches.grey[400], - }, - missionNameInput: { - width: "100%", - margin: "8px 0px 4px 0px !important", - }, - backgroundIcon: { - margin: "7px 8px 0px 0px", - }, - - fileName: { - textAlign: "center", - fontWeight: "bold", - letterSpacing: "1px", - marginBottom: "10px", - paddingBottom: "10px", - borderBottom: `1px solid ${theme.palette.swatches.grey[500]}`, - }, - dropzone: { - width: "100%", - minHeight: "100px", - margin: "16px 0px", - "& > div": { - flex: "1 1 0%", - display: "flex", - flexDirection: "column", - alignItems: "center", - padding: "20px", - borderWidth: "2px", - borderRadius: "2px", - borderColor: theme.palette.swatches.grey[300], - borderStyle: "dashed", - backgroundColor: theme.palette.swatches.grey[900], - color: theme.palette.swatches.grey[200], - outline: "none", - transition: "border 0.24s ease-in-out 0s", - "&:hover": { - borderColor: theme.palette.swatches.p[11], - }, - }, - }, - dropzoneMessage: { - textAlign: "center", - color: theme.palette.swatches.p[11], - "& > p:first-child": { fontWeight: "bold", letterSpacing: "1px" }, - "& > p:last-child": { fontSize: "14px", fontStyle: "italic" }, - }, - timeFields: { - display: "flex", - "& > div:first-child": { - marginRight: "5px", - }, - "& > div:last-child": { - marginLeft: "5px", - }, - }, -})); - -const MODAL_NAME = "updateGeoDataset"; -const UpdateGeoDatasetModal = (props) => { - const { queryGeoDatasets } = props; - const c = useStyles(); - - const modal = useSelector((state) => state.core.modal[MODAL_NAME]); - - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("md")); - - const dispatch = useDispatch(); - - const [geojson, setGeojson] = useState(null); - const [fileName, setFileName] = useState(null); - const [startTimeField, setStartTimeField] = useState(null); - const [endTimeField, setEndTimeField] = useState(null); - - useEffect(() => { - setStartTimeField(modal?.geoDataset?.start_time_field); - setEndTimeField(modal?.geoDataset?.end_time_field); - }, [JSON.stringify(modal)]); - - const handleClose = () => { - // close modal - dispatch(setModal({ name: MODAL_NAME, on: false })); - }; - const handleSubmit = () => { - const geoDatasetName = modal?.geoDataset?.name; - - if (geojson == null || fileName === null) { - dispatch( - setSnackBarText({ - text: "Please upload a file.", - severity: "error", - }) - ); - return; - } - - if (geoDatasetName === null) { - dispatch( - setSnackBarText({ - text: "No GeoDataset found to update.", - severity: "error", - }) - ); - return; - } - - calls.api( - "geodatasets_recreate", - { - name: geoDatasetName, - startProp: startTimeField, - endProp: endTimeField, - geojson: geojson, - filename: fileName, - }, - (res) => { - if (res.status === "success") { - dispatch( - setSnackBarText({ - text: "Successfully updated GeoDataset.", - severity: "success", - }) - ); - queryGeoDatasets(); - handleClose(); - } else { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to update GeoDataset.", - severity: "error", - }) - ); - } - }, - (res) => { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to update GeoDataset.", - severity: "error", - }) - ); - } - ); - }; - - // Dropzone initialization - const { - getRootProps, - getInputProps, - isDragActive, - isDragAccept, - isDragReject, - } = useDropzone({ - maxFiles: 1, - accept: { - "application/json": [".json", ".geojson"], - }, - onDropAccepted: (files) => { - const file = files[0]; - setFileName(file.name); - - const reader = new FileReader(); - reader.onload = (e) => { - setGeojson(JSON.parse(e.target.result)); - }; - reader.readAsText(file); - }, - onDropRejected: () => { - setFileName(null); - setGeojson(null); - }, - }); - - return ( - - -
-
- -
{`Update/Replace this GeoDataset: ${modal?.geoDataset?.name}`}
-
- - - -
-
- - - {`Overwrites the contents of the existing GeoDataset with that of the uploaded file.`} - -
-
- - {isDragAccept &&

All files will be accepted

} - {isDragReject &&

Some files will be rejected

} - {!isDragActive && ( -
-

Drag 'n' drop or click to select files...

-

Only *.json and *.geojson files are accepted.

-
- )} -
-
- -
- -
{fileName || "No File Selected"}
-
- -
-
- { - setStartTimeField(e.target.value); - }} - /> - - {`If this GeoDataset already has a Start Time Field attached, the name of that start time field inside each feature's "properties" object for which to create a temporal index for the geodataset. `} - -
-
- { - setEndTimeField(e.target.value); - }} - /> - - {`If this GeoDataset already has a End Time Field attached, the name of that end time field inside each feature's "properties" object for which to create a temporal index for the geodataset. This enables time queries on GeoDatasets. `} - -
-
-
- - - -
- ); -}; - -export default UpdateGeoDatasetModal; diff --git a/configure/src/pages/STAC/STAC.js b/configure/src/pages/STAC/STAC.js index 3a2912dbe..1a41f5b4c 100644 --- a/configure/src/pages/STAC/STAC.js +++ b/configure/src/pages/STAC/STAC.js @@ -29,10 +29,12 @@ import Button from "@mui/material/Button"; import Tooltip from "@mui/material/Tooltip"; import Divider from "@mui/material/Divider"; import Badge from "@mui/material/Badge"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; import { visuallyHidden } from "@mui/utils"; import InventoryIcon from "@mui/icons-material/Inventory"; -import PreviewIcon from "@mui/icons-material/Preview"; +import InfoIcon from "@mui/icons-material/Info"; import WidgetsIcon from "@mui/icons-material/Widgets"; import DownloadIcon from "@mui/icons-material/Download"; import UploadIcon from "@mui/icons-material/Upload"; @@ -40,13 +42,13 @@ import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; import AddIcon from "@mui/icons-material/Add"; import HorizontalSplitIcon from "@mui/icons-material/HorizontalSplit"; import SettingsIcon from "@mui/icons-material/Settings"; +import SearchIcon from "@mui/icons-material/Search"; import NewStacCollectionModal from "./Modals/NewStacCollectionModal/NewStacCollectionModal"; import DeleteStacCollectionModal from "./Modals/DeleteStacCollectionModal/DeleteStacCollectionModal"; import LayersUsedByModal from "./Modals/LayersUsedByModal/LayersUsedByModal"; -import PreviewGeoDatasetModal from "./Modals/PreviewGeoDatasetModal/PreviewGeoDatasetModal"; -import AppendGeoDatasetModal from "./Modals/AppendGeoDatasetModal/AppendGeoDatasetModal"; -import UpdateGeoDatasetModal from "./Modals/UpdateGeoDatasetModal/UpdateGeoDatasetModal"; +import StacCollectionItemsModal from "./Modals/StacCollectionItemsModal/StacCollectionItemsModal"; +import StacCollectionJsonModal from "./Modals/StacCollectionJsonModal/StacCollectionJsonModal"; function descendingComparator(a, b, orderBy) { if (b[orderBy] < a[orderBy]) { @@ -107,7 +109,7 @@ const useStyles = makeStyles((theme) => ({ }, }, tableInner: { - margin: "32px", + margin: "8px 32px 32px 32px", width: "calc(100% - 64px) !important", boxShadow: "0px 1px 7px 0px rgba(0, 0, 0, 0.2)", }, @@ -182,6 +184,9 @@ const useStyles = makeStyles((theme) => ({ background: theme.palette.swatches.grey[850], boxShadow: "inset 10px 0px 10px -5px rgba(0,0,0,0.3)", }, + searchContainer: { + padding: "16px 32px 8px 32px", + }, th: { fontWeight: "bold !important", textTransform: "uppercase", @@ -305,6 +310,7 @@ export default function STAC() { const [orderBy, setOrderBy] = React.useState("name"); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(25); + const [searchTerm, setSearchTerm] = React.useState(""); const c = useStyles(); @@ -357,26 +363,61 @@ export default function STAC() { setPage(0); }; + // Filter collections based on search term + const filteredCollections = React.useMemo(() => { + if (!searchTerm.trim()) { + return stacCollections; + } + return stacCollections.filter(collection => + collection.id.toLowerCase().includes(searchTerm.toLowerCase()) || + (collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }, [stacCollections, searchTerm]); + // Avoid a layout jump when reaching the last page with empty rows. const emptyRows = page > 0 - ? Math.max(0, (1 + page) * rowsPerPage - stacCollections.length) + ? Math.max(0, (1 + page) * rowsPerPage - filteredCollections.length) : 0; const visibleRows = React.useMemo( () => - stableSort(stacCollections, getComparator(order, orderBy)).slice( + stableSort(filteredCollections, getComparator(order, orderBy)).slice( page * rowsPerPage, page * rowsPerPage + rowsPerPage ), - [order, orderBy, page, rowsPerPage, stacCollections] + [order, orderBy, page, rowsPerPage, filteredCollections] ); + const handleSearchChange = (event) => { + setSearchTerm(event.target.value); + setPage(0); // Reset to first page when searching + }; + return ( <> - + + +
+ + + + ), + }} + /> +
+ {visibleRows.map((row, index) => { @@ -443,19 +484,15 @@ export default function STAC() { title="Info" aria-label="info" onClick={() => { - window - .open( - `${window.location.pathname - .replace(`/configure`, "") - .replace(/^\//g, "")}/stac/collections/${ - row.id - }`, - "_blank" - ) - .focus(); + dispatch( + setModal({ + name: "stacCollectionJson", + collection: row, + }) + ); }} > - + @@ -465,16 +502,12 @@ export default function STAC() { title="Items" aria-label="items" onClick={() => { - window - .open( - `${window.location.pathname - .replace(`/configure`, "") - .replace(/^\//g, "")}/stac/collections/${ - row.id - }/items`, - "_blank" - ) - .focus(); + dispatch( + setModal({ + name: "stacCollectionItems", + stacCollection: row, + }) + ); }} > @@ -521,7 +554,7 @@ export default function STAC() { className={c.bottomBar} rowsPerPageOptions={[25, 50, 100]} component="div" - count={stacCollections.length} + count={filteredCollections.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} @@ -532,9 +565,8 @@ export default function STAC() { - - - + + ); }