diff --git a/.env.example b/.env.example index f15d8604..01d50380 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,7 @@ VITE_MACROSTRAT_TILESERVER_V2='https://dev.macrostrat.org/tiles' VITE_MACROSTRAT_TILESERVER_V1='https://tiles.macrostrat.org' VITE_MACROSTRAT_TILESERVER_DOMAIN='https://dev.macrostrat.org/tiles' VITE_MACROSTRAT_API_DOMAIN='https://macrostrat.org' +VITE_MACROSTRAT_INGEST_API=https://dev.macrostrat.org/api/ingest VITE_CORELLE_API_DOMAIN='https://rotate.macrostrat.org' -PUBLIC_URL='/' \ No newline at end of file +PUBLIC_URL='/' +SECRET_KEY='Replace with api signing key' \ No newline at end of file diff --git a/.github/workflows/build-dev.yaml b/.github/workflows/build-dev.yaml index 4d10ee17..7d6521f2 100644 --- a/.github/workflows/build-dev.yaml +++ b/.github/workflows/build-dev.yaml @@ -2,6 +2,7 @@ name: Build Development on: push: + branches: [ '**' ] tags: - v[0-9]+.[0-9]+.[0-9]+-** # Semver Pre-Release pull_request: @@ -25,7 +26,10 @@ jobs: tags: | type=semver,pattern={{version}} type=raw,value=latest-itb - type=ref,enable=true,prefix=pr-,suffix=-{{date 'YYYYMMDDHHmmss'}},event=pr + type=ref,event=pr,suffix=-{{date 'YYYYMMDDHHmmss'}} + type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}} + type=ref,event=tag,suffix=-{{date 'YYYYMMDDHHmmss'}} + type=raw,value=latest-itb-{{date 'YYYYMMDDHHmmss'}} type=raw,value=sha-{{sha}} - name: Set up Docker Buildx @@ -39,7 +43,7 @@ jobs: password: ${{ secrets.HARBOR_CLI_SECRET }} - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/.github/workflows/build-prod.yaml b/.github/workflows/build-prod.yaml index bd32d304..72880a37 100644 --- a/.github/workflows/build-prod.yaml +++ b/.github/workflows/build-prod.yaml @@ -21,6 +21,8 @@ jobs: with: images: hub.opensciencegrid.org/macrostrat/web tags: | + type=ref,event=pr,suffix=-{{date 'YYYYMMDDHHmmss'}} + type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}} type=semver,pattern={{version}} type=raw,value=latest,enable={{is_default_branch}} type=raw,value=sha-{{sha}} @@ -36,7 +38,7 @@ jobs: password: ${{ secrets.HARBOR_CLI_SECRET }} - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . push: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..956ec398 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20 AS build + +ENV NODE_ENV=production + +WORKDIR /usr/src/app +COPY . ./ + +RUN yarn cache clean +RUN yarn add + +CMD ["sh", "server/server.sh"] diff --git a/deps/web-components b/deps/web-components index 7ff75c9f..848169e0 160000 --- a/deps/web-components +++ b/deps/web-components @@ -1 +1 @@ -Subproject commit 7ff75c9f3ca54fd4d4ea4b69f11774facabe489b +Subproject commit 848169e012f9366dff72aeb349b4a88d244570f7 diff --git a/package.json b/package.json index f7d99e93..99331b47 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "bootstrap": "yarn", "dev": "yarn run server:dev", "build": "vite build", - "server": "node ./server/index.js", + "server": "node --env-file=.env ./server/index.js", "server:dev": "yarn run server", "server:prod": "cross-env NODE_ENV=production npm run server" }, @@ -47,10 +47,12 @@ "dependencies": { "@blueprintjs/core": "^4.14.1", "@blueprintjs/select": "4", + "@blueprintjs/table": "^4", "@lagunovsky/redux-react-router": "^3.2.0", "@loadable/component": "^5.14.1", "@macrostrat-web/data-sheet-test": "workspace:*", "@macrostrat-web/globe": "workspace:*", + "@macrostrat-web/security": "workspace:*", "@macrostrat/api-utils": "workspace:*", "@macrostrat/api-views": "workspace:*", "@macrostrat/column-components": "workspace:*", @@ -85,6 +87,7 @@ "chroma-js": "^2.4.2", "classnames": "^2.2.6", "compression": "^1.7.4", + "cookie-parser": "^1.4.6", "cross-env": "^7.0.3", "d3-array": "^3.1.1", "d3-axis": "^3.0.0", @@ -97,6 +100,7 @@ "express": "^4.18.2", "history": "^5.3.0", "immutability-helper": "^3.1.1", + "jose": "^5.1.2", "mapbox-gl": "^2.15.0", "new-github-issue-url": "^1.0.0", "pbf": "^3.2.1", @@ -119,9 +123,10 @@ "topojson-client": "^3.0.0", "transition-hook": "^1.5.2", "ts-node": "^10.9.1", + "use-debounce": "^9.0.4", "use-react-router-breadcrumbs": "^3.2.1", "use-resize-observer": "^9.1.0", - "vike": "^0.4.150", + "vike": "0.4.150-commit-63b1c32", "vite": "^4.4.9", "vite-plugin-cesium": "^1.2.22" }, diff --git a/packages/globe-dev/src/index.ts b/packages/globe-dev/src/index.ts index 98b2ea54..ad7f4e33 100644 --- a/packages/globe-dev/src/index.ts +++ b/packages/globe-dev/src/index.ts @@ -25,7 +25,7 @@ import Map from "./map-comparison"; import { getMapPositionForHash, applyMapPositionToHash, -} from "/Users/Daven/Projects/Macrostrat/Software/web/src/pages/map/map-interface/app-state/reducers/hash-string"; +} from "../../../src/pages/map/map-interface/app-state/reducers/hash-string"; function VisControl({ show, setShown, name }) { const className = show ? "active" : ""; diff --git a/packages/security/package.json b/packages/security/package.json new file mode 100644 index 00000000..e72d4dd5 --- /dev/null +++ b/packages/security/package.json @@ -0,0 +1,6 @@ +{ + "name": "@macrostrat-web/security", + "private": true, + "main": "src/index.ts", + "license": "ISC" +} diff --git a/packages/security/src/index.ts b/packages/security/src/index.ts new file mode 100644 index 00000000..dd23226a --- /dev/null +++ b/packages/security/src/index.ts @@ -0,0 +1,22 @@ + +// Handles fetch requests that require authentication +export const secureFetch = async (url, options) => { + + options = { + credentials: "include", + ...options, + } + + const response = await fetch(url, options); + + if (response.status === 401 || response.status === 403) { + + const url = new URL(`${import.meta.env.VITE_MACROSTRAT_INGEST_API}/security/login`); + url.searchParams.append("return_url", `${window.location.origin}/dev/security/endpoint`); + + window.open(url, '_blank').focus(); + throw {name: "UnauthorizedError", message: "User is not logged in"} + } + + return response +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index 3726dd35..c5f8e1c5 100644 --- a/server/index.js +++ b/server/index.js @@ -4,11 +4,16 @@ // - To use your environment variables defined in your .env files, you need to install dotenv, see https://vike.dev/env // - To use your path aliases defined in your vite.config.js, you need to tell Node.js about them, see https://vike.dev/path-aliases -import express from "express"; -import compression from "compression"; +import express from "express" +import compression from "compression" import { renderPage } from "vike/server"; -import { root } from "./root.js"; -const isProduction = process.env.NODE_ENV === "production"; +import { root } from "./root.js" + +// Auth imports +import cookieParser from "cookie-parser" +import * as jose from "jose" + +const isProduction = process.env.NODE_ENV === "production" startServer(); @@ -16,6 +21,7 @@ async function startServer() { const app = express(); app.use(compression()); + app.use(cookieParser()); // Vite integration if (isProduction) { @@ -43,9 +49,27 @@ async function startServer() { // vike middleware. It should always be our last middleware (because it's a // catch-all middleware superseding any middleware placed after it). - app.get("*", async (req, res, next) => { + app.get('*', async (req, res, next) => { + + // Pull out the authorization cookie and decrypt it + let user = undefined + + try { + const authHeader = req.cookies?.Authorization + const secret = new TextEncoder().encode( + process.env.SECRET_KEY + ); + const jwt = authHeader.substring(7, authHeader.length) + user = (await jose.jwtVerify(jwt, secret)).payload + + + } catch (e) { + // I don't care if it fails, it just means the user isn't logged in + } + const pageContextInit = { urlOriginal: req.originalUrl, + user: user }; const pageContext = await renderPage(pageContextInit); diff --git a/src/components/map-navbar/index.ts b/src/components/map-navbar/index.ts index 2acf2160..2c64b4bf 100644 --- a/src/components/map-navbar/index.ts +++ b/src/components/map-navbar/index.ts @@ -20,7 +20,16 @@ export function ParentRouteButton({ return h(LinkButton, { to: "..", icon, minimal: true, ...rest }); } -export function MapNavbar({ title, isOpen, setOpen, parentRoute }) { +export function MapNavbar({ + title, + isOpen, + setOpen, + parentRoute, + minimal = false, +}) { + if (minimal) { + return MapMinimalNavbar({ isOpen, setOpen }); + } const { isLoading } = useMapStatus(); return h(FloatingNavbar, { className: "searchbar map-navbar" }, [ h([h(ParentRouteButton, { parentRoute }), h("h2.map-title", title)]), @@ -32,3 +41,17 @@ export function MapNavbar({ title, isOpen, setOpen, parentRoute }) { }), ]); } + +function MapMinimalNavbar({ isOpen, setOpen }) { + const { isLoading } = useMapStatus(); + return h("div.map-minimal-navbar map-navbar", [ + h(FloatingNavbar, { className: "searchbar" }, [ + h(MapLoadingButton, { + active: isOpen, + onClick: () => setOpen(!isOpen), + isLoading, + }), + ]), + h("div.spacer"), + ]); +} diff --git a/src/components/map-navbar/main.module.sass b/src/components/map-navbar/main.module.sass index 3ad0a2a9..2be9f8e1 100644 --- a/src/components/map-navbar/main.module.sass +++ b/src/components/map-navbar/main.module.sass @@ -12,3 +12,11 @@ .map-title // Allow the title to wrap on hover overflow-x: visible + +.map-minimal-navbar + display: flex + flex-direction: row + &>.spacer + flex-grow: 1 + .map-navbar + min-width: 0 \ No newline at end of file diff --git a/src/pages/dev/security/endpoint/index.page.ts b/src/pages/dev/security/endpoint/index.page.ts new file mode 100644 index 00000000..d56f3f43 --- /dev/null +++ b/src/pages/dev/security/endpoint/index.page.ts @@ -0,0 +1,9 @@ +import {default as h} from "@macrostrat/hyper"; + +export function Page() { + + return h("div", [ + h("span", "You are logged in, you can now close this tab.") + ]); +} + diff --git a/src/pages/dev/security/index.page.route.ts b/src/pages/dev/security/index.page.route.ts new file mode 100644 index 00000000..842275a7 --- /dev/null +++ b/src/pages/dev/security/index.page.route.ts @@ -0,0 +1,20 @@ +import { render, redirect } from 'vike/abort' + +export const guard = (pageContext) => { + const { user } = pageContext + + console.log("User: ", user) + + if (user === undefined) { + // Render the login page while preserving the URL. (This is novel technique + // which we explain down below.) + throw redirect(`${import.meta.env.VITE_MACROSTRAT_INGEST_API}/security/login?return_url=${pageContext.url}`) + /* The more traditional way, redirect the user: + throw redirect('/login') + */ + } + if (!user.groups.includes(1)) { + // Render the error page and show message to the user + return render(403, 'Only admins are allowed to access this page.') + } +} \ No newline at end of file diff --git a/src/pages/dev/security/index.page.ts b/src/pages/dev/security/index.page.ts new file mode 100644 index 00000000..7dd66255 --- /dev/null +++ b/src/pages/dev/security/index.page.ts @@ -0,0 +1,9 @@ +import {default as h} from "@macrostrat/hyper"; + +export function Page() { + + return h("div", [ + h("h1", "Secure Page") + ]); +} + diff --git a/src/pages/map/map-interface/app-state/reducers/index.ts b/src/pages/map/map-interface/app-state/reducers/index.ts index ccd859da..bd6d1d36 100644 --- a/src/pages/map/map-interface/app-state/reducers/index.ts +++ b/src/pages/map/map-interface/app-state/reducers/index.ts @@ -145,6 +145,7 @@ export default appReducer; export * from "./core"; export * from "./map"; export * from "./types"; +export * from "./hash-string"; /* function overallReducer(state: AppState, action: Action): AppState { diff --git a/src/pages/maps/@id/edit/components/cell/editable-cell.ts b/src/pages/maps/@id/edit/components/cell/editable-cell.ts new file mode 100644 index 00000000..71fca0e8 --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/editable-cell.ts @@ -0,0 +1,20 @@ +import React from 'react'; + +import {EditableCell2, EditableCell2Props} from "@blueprintjs/table"; + +import hyper from "@macrostrat/hyper"; +import styles from "./main.module.sass"; +import { getTableUpdate } from "~/pages/maps/@id/edit/table-util"; + +export const h = hyper.styled(styles); + + +interface EditableCell extends EditableCell2Props { + columnName: string, + rowIndex: number +} + +export const EditableCell = ({...props}: EditableCell2Props) => { + + return h(EditableCell2, {...props}); +} diff --git a/src/pages/maps/@id/edit/components/cell/editor-popover.ts b/src/pages/maps/@id/edit/components/cell/editor-popover.ts new file mode 100644 index 00000000..f91ef36b --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/editor-popover.ts @@ -0,0 +1,19 @@ +import {Button, MenuItem} from "@blueprintjs/core"; +import {Select2, ItemRenderer} from "@blueprintjs/select"; +import {EditableCell2Props} from "@blueprintjs/table"; +import React, {useEffect, useMemo} from "react"; + +// @ts-ignore +import hyper from "@macrostrat/hyper"; + +import "@blueprintjs/core/lib/css/blueprint.css" +import "@blueprintjs/select/lib/css/blueprint-select.css"; +import styles from "../../edit-table.module.sass"; + + +const h = hyper.styled(styles); + + +export const EditorPopover = () => { + +} diff --git a/src/pages/maps/@id/edit/components/cell/index.d.ts b/src/pages/maps/@id/edit/components/cell/index.d.ts new file mode 100644 index 00000000..802d0d2b --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/index.d.ts @@ -0,0 +1,4 @@ +interface CellProps extends React.HTMLProps { + value: string; + onChange: (value: string) => void; +} \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/cell/interval-selection.ts b/src/pages/maps/@id/edit/components/cell/interval-selection.ts new file mode 100644 index 00000000..accb061b --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/interval-selection.ts @@ -0,0 +1,122 @@ +import {Button, MenuItem} from "@blueprintjs/core"; +import { Select2, ItemRenderer, ItemPredicate } from "@blueprintjs/select"; +import {EditableCell2Props, EditableCell2, Cell} from "@blueprintjs/table"; +import React, {useEffect, useMemo} from "react"; + +// @ts-ignore +import hyper from "@macrostrat/hyper"; + +import "@blueprintjs/core/lib/css/blueprint.css" +import "@blueprintjs/select/lib/css/blueprint-select.css"; +import styles from "../../edit-table.module.sass"; + + +const h = hyper.styled(styles); + +interface Timescale { + timescale_id: number + name: string +} + +export interface Interval { + int_id: number + name: string + abbrev: string + t_age: number + b_age: number + int_type: string + timescales: Timescale[] + color: string +} + +const IntervalOption: ItemRenderer = (interval: Interval, { handleClick, handleFocus, modifiers }) => { + + if (interval == null) { + return h(MenuItem, { + shouldDismissPopover: true, + active: modifiers.active, + disabled: modifiers.disabled, + key: "", + label: "", + onClick: handleClick, + onFocus: handleFocus, + text: "", + roleStructure:"listoption" + }, []) + } + + return h(MenuItem, { + style: {backgroundColor: interval.color}, + shouldDismissPopover: true, + active: modifiers.active, + disabled: modifiers.disabled, + key: interval.int_id, + label: interval.int_id.toString(), + onClick: handleClick, + onFocus: handleFocus, + text: interval.name, + roleStructure:"listoption" + }, []) +} + + +const IntervalSelection = ({value, onConfirm, intent, intervals, ...props} : EditableCell2Props & {intervals: Interval[]}) => { + + const [localValue, setLocalValue] = React.useState(value); + + const filterInterval: ItemPredicate = (query, interval) => { + + if(interval?.name == undefined){ + return false + } + return interval.name.toLowerCase().indexOf(query.toLowerCase()) >= 0; + } + + const interval = useMemo(() => { + + let interval = null + if(intervals.length != 0){ + interval = intervals.filter((interval) => interval.int_id == parseInt(value))[0] + } + + return interval + }, [value, localValue, intervals]) + + return h(Cell, { + ...props, + style: {...props.style, padding: 0}, + }, [ + h(Select2, { + fill: true, + items: intervals, + className: "update-input-group", + popoverProps: { + position: "bottom", + minimal: true + }, + popoverContentProps:{ + onWheelCapture: (event) => event.stopPropagation() + }, + itemPredicate: filterInterval, + itemRenderer: IntervalOption, + onItemSelect: (interval: Interval, e) => { + onConfirm(interval.int_id.toString()) + setLocalValue(interval.int_id.toString()) + }, + noResults: h(MenuItem, {disabled: true, text: "No results.", roleStructure: "listoption"}), + }, [ + h(Button, { + style: {backgroundColor: interval?.color ?? "white", fontSize: "12px", minHeight: "0px", padding: "1.7px 10px", boxShadow: "none"}, + fill: true, + alignText: "left", + text: h("span", {style: {overflow: "hidden", textOverflow: "ellipses"}}, interval?.name ?? "Select an Interval"), + rightIcon: "double-caret-vertical", + className: "update-input-group", + placeholder: "Select A Filter" + }, []) + ]), + ]) +} + + +export default IntervalSelection; diff --git a/src/pages/maps/@id/edit/components/cell/main.module.sass b/src/pages/maps/@id/edit/components/cell/main.module.sass new file mode 100644 index 00000000..91250971 --- /dev/null +++ b/src/pages/maps/@id/edit/components/cell/main.module.sass @@ -0,0 +1,55 @@ +@import "@blueprintjs/core/lib/scss/variables.scss" + +.data-sheet-container, .data-sheet-holder + flex: 1 + position: relative + min-height: 0 + +.data-sheet-container + display: flex + flex-direction: column + +.data-sheet + height: 100% + +:global(.bp4-dark) .data-sheet :global(.bp4-table-quadrant) + background-color: $dark-gray1 + +.input-cell + padding: 0 2px !important + input + width: 100% + height: 100% + padding: 0 8px + z-index: 0 + position: relative + border: none + margin: 0 + font-size: 1em + pointer-events: all + background: transparent + &:focus + outline: none + +.hidden-input + opacity: 0 + position: absolute + width: 0 + +.corner-drag-handle + position: absolute + bottom: 0 + right: 0 + width: 8px + height: 8px + background-color: $dark-gray1 + cursor: ns-resize + background-color: dodgerblue + +.data-sheet-toolbar + display: flex + flex-direction: row + margin-bottom: 4px + +.spacer + flex-grow: 1 \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/index.ts b/src/pages/maps/@id/edit/components/index.ts new file mode 100644 index 00000000..58584ec0 --- /dev/null +++ b/src/pages/maps/@id/edit/components/index.ts @@ -0,0 +1 @@ +export * from "./panels"; diff --git a/src/pages/maps/@id/edit/components/main.module.sass b/src/pages/maps/@id/edit/components/main.module.sass new file mode 100644 index 00000000..812ded8d --- /dev/null +++ b/src/pages/maps/@id/edit/components/main.module.sass @@ -0,0 +1,24 @@ +.width-adjustable-panel + transition: max-width 0.1s ease-in-out + height: 100% + display: flex + flex-direction: row + position: relative + +.width-adjustable-panel-content + display: flex + flex-direction: column + overflow: scroll + height: 100% + flex-grow: 1 + padding: 1em + +.width-adjuster + cursor: col-resize + width: 6px + height: 100% + // Not sure why this defaults to shrinking + flex-shrink: 0 + background-color: #efefef + &:hover + background-color: #ddd \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/panels.ts b/src/pages/maps/@id/edit/components/panels.ts new file mode 100644 index 00000000..7e59e452 --- /dev/null +++ b/src/pages/maps/@id/edit/components/panels.ts @@ -0,0 +1,80 @@ +import { ReactNode, useEffect } from "react"; +import { useRef } from "react"; +import { useStoredState } from "@macrostrat/ui-components"; +import hyper from "@macrostrat/hyper"; +import styles from "./main.module.sass"; +import { on } from "events"; +export const h = hyper.styled(styles); + +export enum AdjustSide { + LEFT = "left", + RIGHT = "right", +} + +export function WidthAdjustablePanel({ + children, + adjustSide = AdjustSide.RIGHT, + expand, + className, + storageID = null, +}: { + children: ReactNode; + adjustSide?: AdjustSide; + expand?: boolean; + className?: string; + storageID?: string; +}) { + const [maxWidth, setMaxWidth] = useStoredState( + storageID, + 0, + (v) => typeof v === "number" + ); + + useEffect(() => { + if (typeof window === "undefined") return; + setMaxWidth(window.innerWidth / 2); + }, []); + + if (expand) { + return h("div.width-adjustable-panel", { className }, [ + h("div.width-adjustable-panel-content", {}, children), + ]); + } + return h( + "div.width-adjustable-panel", + { style: { maxWidth: maxWidth + "px" }, className }, + [ + h.if(adjustSide == AdjustSide.LEFT)(WidthAdjuster, { + onAdjust: (dx) => { + const newMaxWidth = maxWidth - dx; + setMaxWidth(newMaxWidth); + }, + }), + h("div.width-adjustable-panel-content", {}, children), + h.if(adjustSide == AdjustSide.RIGHT)(WidthAdjuster, { + onAdjust: (dx) => { + const newMaxWidth = maxWidth + dx; + setMaxWidth(newMaxWidth); + }, + }), + ] + ); +} + +function WidthAdjuster({ onAdjust }: { onAdjust: (dx: number) => void }) { + const startPosition = useRef(0); + return h( + "div.width-adjuster", + { + onDragStart: (e) => { + startPosition.current = e.clientX; + }, + onDragEnd: (e) => { + const dx = e.clientX - startPosition.current; + onAdjust(dx); + }, + draggable: true, + }, + [] + ); +} diff --git a/src/pages/maps/@id/edit/components/progress-popover/main.module.sass b/src/pages/maps/@id/edit/components/progress-popover/main.module.sass new file mode 100644 index 00000000..4b4f7a21 --- /dev/null +++ b/src/pages/maps/@id/edit/components/progress-popover/main.module.sass @@ -0,0 +1,15 @@ +.progress-popover + z-index: 9999 + position: absolute + bottom: 0px + background: white + padding: 10px + border-radius: 5px + box-shadow: #ececec 5px 5px 5px + width: 200px + left: 50% + transform: translate(-50%, -50%) + + .progress-popover-text + padding-top: 10px + text-align: center \ No newline at end of file diff --git a/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts b/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts new file mode 100644 index 00000000..30f74f76 --- /dev/null +++ b/src/pages/maps/@id/edit/components/progress-popover/progress-popover.ts @@ -0,0 +1,26 @@ +import { ProgressBar, ProgressBarProps } from "@blueprintjs/core"; + +import hyper from "@macrostrat/hyper"; + +import styles from "./main.module.sass"; +const h = hyper.styled(styles); + +export interface ProgressPopoverProps extends React.HTMLProps { + text: string; + value: number; + progressBarProps?: ProgressBarProps; +} + +export default function ProgressPopover({text, value, progressBarProps}: ProgressPopoverProps) { + return h("div", { + className: "progress-popover" + }, [ + h(ProgressBar, { + value: value, + ...progressBarProps + }), + h("div", { + className: "progress-popover-text" + }, text) + ]); +} diff --git a/src/pages/maps/@id/edit/components/table-interface.ts b/src/pages/maps/@id/edit/components/table-interface.ts new file mode 100644 index 00000000..643a4eaf --- /dev/null +++ b/src/pages/maps/@id/edit/components/table-interface.ts @@ -0,0 +1,260 @@ +import hyper from "@macrostrat/hyper"; + + + +import { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo, FunctionComponent } from "react"; +import { HotkeysProvider, InputGroup, Button } from "@blueprintjs/core"; +import { Spinner, ButtonGroup } from "@blueprintjs/core"; +import { + Column, + Table2, + EditableCell2, + RowHeaderCell2, + ColumnHeaderCell2, + SelectionModes, + RegionCardinality +} from "@blueprintjs/table"; +import update from "immutability-helper"; + +import { Filters, OperatorQueryParameter, TableUpdate, TableSelection, Selection, DataParameters } from "~/pages/maps/@id/edit/table"; +import { + buildURL, + Filter, + isEmptyArray, + submitChange, + getTableUpdate, + range, + applyTableUpdates +} from "~/pages/maps/@id/edit/table-util"; +import TableMenu from "~/pages/maps/@id/edit/table-menu"; +import IntervalSelection from "../components/cell/interval-selection"; +import ProgressPopover from "~/pages/maps/@id/edit/components/progress-popover/progress-popover"; + +import "./override.sass" +import "@blueprintjs/table/lib/css/table.css"; +import styles from "./edit-table.module.sass"; +import { EditableCell } from "~/pages/maps/@id/edit/components/cell/editable-cell"; +import EditTable from "~/pages/maps/@id/edit/edit-table"; + +const h = hyper.styled(styles); + +const FINAL_COLUMNS = [ + "source_id", + "orig_id", + "descrip", + "ready", + "name", + "strat_name", + "age", + "lith", + "comments", + "t_interval", + "b_interval" +] + +interface EditTableProps { + url: string; + data: { + [key: string]: any + }; +} + +interface TableState { + error: string | undefined; + filters: Filters; + group: string | undefined; + tableSelection: TableSelection; +} + +export default function TableInterface({ url }: EditTableProps) { + + // Data State + const [dataParameters, setDataParameters] = useState({select: {page: "0", pageSize: "999999"}}) + const [data, setData] = useState([]) + const [dataToggle, setDataToggle] = useState(false); + + // Error State + const [error, setError] = useState(undefined) + + // Table Update State + const [tableUpdates, setTableUpdates] = useState([]) + const [updateProgress, setUpdateProgress] = useState(undefined) + + // Memoize non-id columns + const nonIdColumnNames = useMemo(() => { + return data.length ? Object.keys(data[0]).filter(x => x != "_pkid") : [] + }, [data]) + + + let getData = async () => { + + const dataURL = buildURL(url, dataParameters) + + const response = await fetch(dataURL) + const data = await response.json() + + if(data.length == 0){ + setError("Warning: No results matched query") + } else { + + setError(undefined) + setData(data) + } + + // Remove the progress bar on data reload + setUpdateProgress(undefined) + + return data + } + + // On mount get data + useEffect(() => { + getData() + }, [dataParameters]) + + if(data.length == 0 && error == undefined){ + return h(Spinner) + } + + const submitTableUpdates = async () => { + + setUpdateProgress(0) + + let index = 0 + for(const tableUpdate of tableUpdates){ + + try { + await tableUpdate.execute() + } catch (e) { + + setUpdateProgress(undefined) + return // If there is an error, stop submitting + } + + index += 1 + setUpdateProgress(index / tableUpdates.length) + } + + setTableUpdates([]) + setDataToggle(!dataToggle) + } + + const columnHeaderCellRenderer = (columnIndex: number) => { + + const columnName: string = nonIdColumnNames[columnIndex] + + const onFilterChange = (param: OperatorQueryParameter) => { + const columnFilter = new Filter(columnName, param.operator, param.value) + setDataParameters({...dataParameters, filter: {...dataParameters.filter, [columnName]: columnFilter}}) + } + + const filter = dataParameters.filter[columnName] + + const setGroup = (group: string | undefined) => { + setDataParameters({...dataParameters, group: group}) + } + + return h(ColumnHeaderCell2, { + menuRenderer: () => h(TableMenu, {"columnName": columnName, "onFilterChange": onFilterChange, "filter": filter, "onGroupChange": setGroup, "group": dataParameters?.group}), + name: columnName, + style: { + backgroundColor: filter.is_valid() || dataParameters?.group == columnName ? "rgba(27,187,255,0.12)" : "#ffffff00" + } + }, []) + } + + + const defaultColumnConfig = Object.entries(nonIdColumnNames).map(([columnName, value], index) => { + return h(Column, { + name: columnName, + className: FINAL_COLUMNS.includes(columnName) ? "final-column" : "", + columnHeaderCellRenderer: columnHeaderCellRenderer, + cellRenderer: (rowIndex) => h(EditableCell, { + onConfirm: (value) => { + const tableUpdate = getTableUpdate(url, value, columnName, rowIndex, data, dataParameters) + setTableUpdates([...tableUpdates, tableUpdate]) + }, + value: applyTableUpdates(data[rowIndex], columnName, tableUpdates) + }), + "key": columnName + }) + }) + + const columnConfig = { + ...defaultColumnConfig, + "t_interval": h(Column, { + ...defaultColumnConfig["t_interval"], + cellRenderer: (rowIndex) => h(IntervalSelection, { + onConfirm: (value) => { + const tableUpdate = getTableUpdate(url, value, "t_interval", rowIndex, data, dataParameters) + setTableUpdates([...tableUpdates, tableUpdate]) + }, + value: data[rowIndex]["t_interval"] + }) + }) + } + + const rowHeaderCellRenderer = (rowIndex: number) => { + const headerKey = dataParameters?.group ? dataParameters?.group : "_pkid" + let name = data[rowIndex][headerKey] + + if (name == null) { + name = "NULL"; + } else if(name.length > 47){ + name = name.slice(0, 47) + "..." + } + + return h(RowHeaderCell2, { "name": name }, []); + }; + + return h(HotkeysProvider, {}, [ + h("div.table-container", {}, [ + h.if(error != undefined)("div.warning", {}, [error]), + h("div.input-form", {}, [ + h(ButtonGroup, [ + h( + Button, + { + onClick: () => { + setTableUpdates([]); + }, + disabled: isEmptyArray(tableUpdates), + }, + ["Clear changes"] + ), + h( + Button, + { + type: "submit", + onClick: submitTableUpdates, + disabled: isEmptyArray(tableUpdates), + intent: "success", + }, + ["Submit"] + ), + ]), + ]), + h( + Table2, + { + selectionModes: dataParameters?.group ? RegionCardinality.CELLS : SelectionModes.COLUMNS_AND_CELLS, + rowHeaderCellRenderer: rowHeaderCellRenderer, + onSelection: (selections: Selection[]) => + getSelectionValues(selections), + numRows: data.length, + // Dumb hacks to try to get the table to rerender on changes + cellRendererDependencies: [data], + }, + columnConfig + ), + h.if(updateProgress != undefined)( + ProgressPopover, + { + text: "Submitting Changes", + value: updateProgress, + progressBarProps: { intent: "success" }, + } + ) + ]), + ]); +} diff --git a/src/pages/maps/@id/edit/edit-page.module.sass b/src/pages/maps/@id/edit/edit-page.module.sass new file mode 100644 index 00000000..8c329d4d --- /dev/null +++ b/src/pages/maps/@id/edit/edit-page.module.sass @@ -0,0 +1,45 @@ +.edit-page + display: flex + flex-direction: row + height: 100vh + width: 100vw + overflow: hidden + +.edit-page-header + display: flex + flex-direction: row + +.spacer + flex-grow: 1 + +.map-panel-container, .edit-page-content + flex: 1 + min-width: 0 + +.edit-menu + flex-grow: 1 + +div.interface + height: 100% + background-color: #efefef + border-radius: 10px + margin-right: auto + +button.icon-button + aspect-ratio: 1 / 1 + margin: 5px + display: flex + flex-direction: column + border-radius: 10px + .icon-container + display: flex + margin: auto + padding: 6px + .icon-label + padding-bottom: 10px + + + +div.edit-table-wrapper + overflow: scroll + width: 100% diff --git a/src/pages/maps/@id/edit/edit-page.ts b/src/pages/maps/@id/edit/edit-page.ts new file mode 100644 index 00000000..ee7dac6a --- /dev/null +++ b/src/pages/maps/@id/edit/edit-page.ts @@ -0,0 +1,96 @@ +import hyper from "@macrostrat/hyper"; +import styles from "./edit-page.module.sass"; +import { useState } from "react"; +import EditTable from "./edit-table"; +import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +import { LinkButton } from "~/pages/map/map-interface/components/buttons"; +import { WidthAdjustablePanel } from "./components"; +import MapInterface from "./map-interface"; +import { useStoredState } from "@macrostrat/ui-components"; +import { ParentRouteButton } from "~/components/map-navbar"; +import { Button, HotkeysProvider } from "@blueprintjs/core"; + +export const h = hyper.styled(styles); + +function EditMenu() { + return h("div.edit-menu", {}, [ + h(LinkButton, { + icon: "polygon-filter", + text: "Polygons", + large: true, + to: "polygons", + }), + ]); +} + +interface EditInterfaceProps { + title?: string; + parentRoute?: string; + source_id?: number; + mapBounds?: any; +} + +export default function EditInterface({ + source_id, + mapBounds, +}: EditInterfaceProps) { + const [showMap, setShowMap] = useStoredState( + "edit:showMap", + true, + // Check if is valid boolean + (v) => typeof v === "boolean" + ); + + const title = mapBounds.properties.name; + + return h(HotkeysProvider, [ + h("div.edit-page", [ + h( + WidthAdjustablePanel, + { + expand: !showMap, + className: "edit-page-content", + storageID: "edit-panel-width", + }, + // TODO: make this basename dynamic + h([ + h("div.edit-page-header", [ + h(ParentRouteButton, { parentRoute: "/maps/" }), + h("h2", title), + h("div.spacer"), + h("div.edit-page-buttons", [ + h(ShowMapButton, { showMap, setShowMap }), + ]), + ]), + h(Router, { basename: `/maps/${source_id}/edit` }, [ + h(Routes, [ + h(Route, { + path: "", + element: h(EditMenu), + }), + h(Route, { + path: "polygons", + element: h(EditTable, { + url: `${import.meta.env.VITE_MACROSTRAT_INGEST_API}/sources/${source_id}/polygons`, + }), + }), + ]), + ]) + ]) + ), + h.if(showMap)(MapInterface, { id: source_id, map: mapBounds }), + ]), + ]); +} + +function ShowMapButton({ showMap, setShowMap }) { + return h(Button, { + minimal: true, + icon: "map", + large: true, + intent: showMap ? "primary" : "none", + onClick: () => setShowMap(!showMap), + }); +} + + diff --git a/src/pages/maps/@id/edit/edit-table.module.sass b/src/pages/maps/@id/edit/edit-table.module.sass new file mode 100644 index 00000000..2665ec0a --- /dev/null +++ b/src/pages/maps/@id/edit/edit-table.module.sass @@ -0,0 +1,37 @@ +.table-container + height: 100% + display: flex + flex-direction: column + +td + text-wrap: nowrap + background-color: #ffffff78 + +tr:nth-child(odd) td + background-color: #ffffff + +div.input-form + display: flex + flex-direction: row + +.update-input-group + flex-grow: 1 + +div.filter-header + padding-bottom: .4rem + font-size: 1rem + +div.filter-select + padding-bottom: .2rem + +div.filter-container + box-shadow: #00000038 1px 1px 8px + padding: 10px + border-radius: 10px + +div.warning + background-color: rgb(255 216 152 / 73%) + color: black + border: #ffe26c solid 1px + font-size: 1rem + padding-bottom: 0.2rem diff --git a/src/pages/maps/@id/edit/edit-table.ts b/src/pages/maps/@id/edit/edit-table.ts new file mode 100644 index 00000000..c2a5a273 --- /dev/null +++ b/src/pages/maps/@id/edit/edit-table.ts @@ -0,0 +1,410 @@ +import hyper from "@macrostrat/hyper"; + +import { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo, FunctionComponent } from "react"; +import { HotkeysProvider, InputGroup, Button, useHotkeys } from "@blueprintjs/core"; +import { Spinner, ButtonGroup } from "@blueprintjs/core"; +import { + Column, + Table2, + EditableCell2, + RowHeaderCell2, + ColumnHeaderCell2, + SelectionModes, + RegionCardinality +} from "@blueprintjs/table"; +import update from "immutability-helper"; + +import { Filters, OperatorQueryParameter, TableUpdate, TableSelection, Selection, DataParameters } from "~/pages/maps/@id/edit/table"; +import { + buildURL, + Filter, + isEmptyArray, + submitChange, + getTableUpdate, + range, + applyTableUpdate, + applyTableUpdates, + submitColumnCopy +} from "~/pages/maps/@id/edit/table-util"; +import TableMenu from "~/pages/maps/@id/edit/table-menu"; +import IntervalSelection, {Interval} from "./components/cell/interval-selection"; +import ProgressPopover, { + ProgressPopoverProps +} from "~/pages/maps/@id/edit/components/progress-popover/progress-popover"; + +import "./override.sass" +import "@blueprintjs/table/lib/css/table.css"; +import styles from "./edit-table.module.sass"; +import { EditableCell } from "~/pages/maps/@id/edit/components/cell/editable-cell"; + +const h = hyper.styled(styles); + +const FINAL_COLUMNS = [ + "source_id", + "orig_id", + "descrip", + "ready", + "name", + "strat_name", + "age", + "lith", + "comments", + "t_interval", + "b_interval" +] + +interface EditTableProps { + url: string; +} + +interface TableState { + error: string | undefined; + filters: Filters; + group: string | undefined; + tableSelection: TableSelection; +} + +export default function TableInterface({ url }: EditTableProps) { + + // Selection State + const [selectedColumn, setSelectedColumn] = useState(undefined) + const [copiedColumn, setCopiedColumn] = useState(undefined) + + // Data State + const [dataParameters, setDataParameters] = useState({select: {page: "0", pageSize: "50"}, filter: {}}) + const [data, setData] = useState([]) + + // Error State + const [error, setError] = useState(undefined) + + // Table Update State + const [tableUpdates, _setTableUpdates] = useState([]) + const [updateProgress, setUpdateProgress] = useState(undefined) + + // Cell Values + const [intervals, setIntervals] = useState([]) + + useEffect(() => { + + async function getIntervals() { + let response = await fetch(`https://macrostrat.org/api/defs/intervals?tilescale_id=11`) + + if (response.ok) { + let response_data = await response.json(); + setIntervals(response_data.success.data); + } + } + + getIntervals() + }, []) + + const nonIdColumnNames = useMemo(() => { + return data.length ? Object.keys(data[0]).filter(x => x != "_pkid") : [] + }, [data]) + + const setTableUpdates = useCallback(async (newTableUpdates: TableUpdate[]) => { + + // If the table updates are empty, reset the data + if (newTableUpdates.length == 0) { + let newData = await getData(newTableUpdates, dataParameters) + setData(newData) + } + + // If a new update is available apply it to the data + if(newTableUpdates.length > tableUpdates.length){ + let newData = applyTableUpdate(data, newTableUpdates.slice(-1)[0]) + setData(newData) + } + + _setTableUpdates(newTableUpdates) + + }, [data, tableUpdates, dataParameters]) + + const getData = useCallback( async (tableUpdates: TableUpdate[], dataParameters: DataParameters) => { + + const dataURL = buildURL(url, dataParameters) + + const response = await fetch(dataURL) + let data = await response.json() + + // Apply tableupdates to the data + data = applyTableUpdates(data, tableUpdates) + + if(data.length == 0){ + setError("Warning: No results matched query") + } else { + + setError(undefined) + } + + // Remove the progress bar on data reload + setUpdateProgress(undefined) + + return data + }, []) + + // On mount get data + useEffect(() => { + (async () => { + setData(await getData(tableUpdates, dataParameters)) + })() + }, [dataParameters]) + + const handlePaste = useCallback(() => { + if(copiedColumn != undefined && selectedColumn != undefined){ + + const tableUpdate = { + description: "Copy column " + copiedColumn + " to column " + selectedColumn + " for all rows", + applyToCell: (value: string, row, cellColumnName) => { + + if(cellColumnName != selectedColumn){ + return value + } + + // If this row doesn't pass all the filters skip it + if(dataParameters?.filter != undefined) { + for (const filter of Object.values(dataParameters.filter)) { + if (!filter.passes(row)) { + return value + } + } + } + + if(cellColumnName == selectedColumn){ + return row[copiedColumn] + } + + return value + }, + execute: async () => { + await submitColumnCopy(url, copiedColumn, selectedColumn, dataParameters) + } + } + + setTableUpdates([...tableUpdates, tableUpdate]) + } + }, [selectedColumn, copiedColumn, dataParameters]) + + const handleCopy = useCallback(() => { + setCopiedColumn(selectedColumn) + }, [selectedColumn]) + + const hotkeys = useMemo(() => [ + { + combo: "cmd+c", + label: "Copy data", + onKeyDown: handleCopy, + }, + { + combo: "cmd+v", + label: "Paste Data", + onKeyDown: handlePaste, + } + ], [handlePaste, handleCopy]); + const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + + const submitTableUpdates = useCallback(async () => { + + setUpdateProgress({value: 0, text: "Submitting changes"}) + + let index = 0 + for(const tableUpdate of tableUpdates){ + + setUpdateProgress({...updateProgress, text: tableUpdate?.description ?? "Submitting changes"}) + + try { + await tableUpdate.execute() + } catch (e) { + + setUpdateProgress({ + progressBarProps: { intent: "danger" }, + value: 1, + text: "Error submitting changes" + }) + console.error(e) + + setTimeout(() => { + setUpdateProgress(undefined) + }, 5000) + + return // If there is an error, stop submitting + } + + index += 1 + setUpdateProgress({...updateProgress, value: index / tableUpdates.length}) + } + + setTableUpdates([]) + }, [tableUpdates]) + + const columnHeaderCellRenderer = useCallback((columnIndex: number) => { + + const columnName: string = nonIdColumnNames[columnIndex] + + const onFilterChange = (param: OperatorQueryParameter) => { + const columnFilter = new Filter(columnName, param.operator, param.value) + setDataParameters({...dataParameters, filter: {...dataParameters.filter, [columnName]: columnFilter}}) + } + + + let filter = undefined + if(dataParameters.filter != undefined && dataParameters.filter[columnName] != undefined){ + filter = dataParameters.filter[columnName] + } else { + filter = new Filter(columnName, undefined, "") + } + + const setGroup = (group: string | undefined) => { + setDataParameters({...dataParameters, group: group}) + } + + return h(ColumnHeaderCell2, { + menuRenderer: () => h(TableMenu, {"columnName": columnName, "onFilterChange": onFilterChange, "filter": filter, "onGroupChange": setGroup, "group": dataParameters?.group}), + name: columnName, + style: { + backgroundColor: filter.is_valid() || dataParameters?.group == columnName ? "rgba(27,187,255,0.12)" : "#ffffff00" + } + }, []) + }, [dataParameters, data]) + + const rowHeaderCellRenderer = useCallback((rowIndex: number) => { + + if (data.length == 0) { + return h(RowHeaderCell2, { "name": "NULL" }, []); + } + + const headerKey = dataParameters?.group ? dataParameters?.group : "_pkid" + let name = data[rowIndex][headerKey] + + if (name == null) { + name = "NULL"; + } else if(name.length > 47){ + name = name.slice(0, 47) + "..." + } + + return h(RowHeaderCell2, { "name": name }, []); + }, [dataParameters, data]) + + if(data.length == 0 && error == undefined){ + return h(Spinner) + } + + const defaultColumnConfig = nonIdColumnNames.reduce((prev, columnName, index) => { + return { + ...prev, + [columnName]: h(Column, { + name: columnName, + className: FINAL_COLUMNS.includes(columnName) ? "final-column" : "", + columnHeaderCellRenderer: columnHeaderCellRenderer, + cellRenderer: (rowIndex) => h(EditableCell, { + onConfirm: (value) => { + const tableUpdate = getTableUpdate(url, value, columnName, rowIndex, data, dataParameters) + setTableUpdates([...tableUpdates, tableUpdate]) + }, + value: data[rowIndex][columnName] + }), + "key": columnName + }) + } + }, {}) + + const columnConfig = { + ...defaultColumnConfig, + "t_interval": h(Column, { + ...defaultColumnConfig["t_interval"].props, + cellRenderer: (rowIndex) => h(IntervalSelection, { + "intervals": intervals, + onConfirm: (value) => { + const tableUpdate = getTableUpdate(url, value, "t_interval", rowIndex, data, dataParameters) + setTableUpdates([...tableUpdates, tableUpdate]) + }, + value: data[rowIndex]["t_interval"] + }) + }), + "b_interval": h(Column, { + ...defaultColumnConfig["b_interval"].props, + cellRenderer: (rowIndex) => h(IntervalSelection, { + "intervals": intervals, + onConfirm: (value) => { + const tableUpdate = getTableUpdate(url, value, "b_interval", rowIndex, data, dataParameters) + setTableUpdates([...tableUpdates, tableUpdate]) + }, + value: data[rowIndex]["b_interval"] + }) + }) + } + + return h("div", { + onKeyDown: handleKeyDown, + onKeyUp: handleKeyUp, + tabIndex: 0, + style: { + minHeight: "0" + } + }, [ + h("div.table-container", {}, [ + h.if(error != undefined)("div.warning", {}, [error]), + h("div.input-form", {}, [ + h(ButtonGroup, [ + h( + Button, + { + onClick: () => { + setTableUpdates([]); + }, + disabled: tableUpdates.length == 0, + }, + ["Clear changes"] + ), + h( + Button, + { + type: "submit", + onClick: submitTableUpdates, + disabled: tableUpdates.length == 0, + intent: "success", + }, + ["Submit"] + ), + ]), + ]), + h( + Table2, + { + selectionModes: dataParameters?.group ? RegionCardinality.CELLS : SelectionModes.COLUMNS_AND_CELLS, + rowHeaderCellRenderer: rowHeaderCellRenderer, + onSelection: (selections: Selection[]) => { + const selectedColumns = selections[0]?.cols + if(selectedColumns[0] == selectedColumns[1] && selections[0]?.rows == undefined){ + setSelectedColumn(nonIdColumnNames[selectedColumns[0]]) + } else { + setSelectedColumn(undefined) + } + }, + onVisibleCellsChange: (visibleCells) => { + + console.log(visibleCells) + if(visibleCells["rowIndexEnd"] > parseInt(dataParameters.select.pageSize) - 10){ + const newPageSize = (parseInt(dataParameters.select.pageSize) + 50).toString() + + setDataParameters({...dataParameters, select: {...dataParameters.select, pageSize: newPageSize}}) + } + }, + numRows: data.length, + // Dumb hacks to try to get the table to rerender on changes + cellRendererDependencies: [data, tableUpdates], + }, + Object.values(columnConfig) + ), + h.if(updateProgress != undefined)( + ProgressPopover, + { + progressBarProps: { intent: "success" }, + ...updateProgress + } + ) + ]), + ]); +} + + diff --git a/src/pages/maps/@id/edit/index.page.route.ts b/src/pages/maps/@id/edit/index.page.route.ts new file mode 100644 index 00000000..a5f8b371 --- /dev/null +++ b/src/pages/maps/@id/edit/index.page.route.ts @@ -0,0 +1 @@ +export default "/maps/@id/edit/*"; diff --git a/src/pages/maps/@id/edit/index.page.ts b/src/pages/maps/@id/edit/index.page.ts new file mode 100644 index 00000000..e2f5ab29 --- /dev/null +++ b/src/pages/maps/@id/edit/index.page.ts @@ -0,0 +1,40 @@ +import { PageContextBuiltInServer } from "vike/types"; +import { SETTINGS } from "~/settings"; +import h from "@macrostrat/hyper"; +import { ClientOnly } from "~/renderer/client-only"; + +const apiAddress = SETTINGS.apiDomain + "/api/v2/defs/sources"; + +export async function onBeforeRender(pageContext: PageContextBuiltInServer) { + const { id } = pageContext.routeParams; + + const params = new URLSearchParams({ + format: "geojson", + source_id: id, + }); + const response = await fetch(apiAddress + "?" + params); + const data: any = await response.json(); + const map = data?.success?.data?.features[0]; + + return { + pageContext: { + pageProps: { + id, + map, + }, + documentProps: { + // The page's + title: map.properties.name, + }, + }, + }; +} + +const EditInterface = () => import("./edit-page"); + +export function Page({ id, map }) { + return h( + "div.single-map", + h(ClientOnly, { component: EditInterface, source_id: id, mapBounds: map }) + ); +} diff --git a/src/pages/maps/@id/edit/main.module.sass b/src/pages/maps/@id/edit/main.module.sass new file mode 100644 index 00000000..6a9080ad --- /dev/null +++ b/src/pages/maps/@id/edit/main.module.sass @@ -0,0 +1,37 @@ +body + margin: 0 + padding: 0 + +:root + --map-detail-stack-width: fit-content + +.single-map + flex: 1 + height: 100% + margin: 0 + --map-context-stack-width: 16em + +.map-legend-container + overflow-y: scroll + +.map-legend + margin: 1em + +.legend-entry + margin: 0.5em 0 + +.legend-title + display: flex + flex-direction: row + &:hover + cursor: pointer + background: #eee + h4 + margin: 0 + margin-right: 0.5em + +.legend-swatch + width: 1em + height: 1em + display: inline-block + margin-right: 0.5em \ No newline at end of file diff --git a/src/pages/maps/@id/edit/map-interface.ts b/src/pages/maps/@id/edit/map-interface.ts new file mode 100644 index 00000000..d4ae0fc1 --- /dev/null +++ b/src/pages/maps/@id/edit/map-interface.ts @@ -0,0 +1,273 @@ +import { Radio, RadioGroup, Spinner } from "@blueprintjs/core"; +import hyper from "@macrostrat/hyper"; +import { + MapAreaContainer, + MapMarker, + MapView, + PanelCard, +} from "@macrostrat/map-interface"; +import { buildMacrostratStyle } from "@macrostrat/mapbox-styles"; +import { getMapboxStyle, mergeStyles } from "@macrostrat/mapbox-utils"; +import { NullableSlider, useDarkMode } from "@macrostrat/ui-components"; +import boundingBox from "@turf/bbox"; +import { LngLatBoundsLike } from "mapbox-gl"; +import { useEffect, useMemo, useState } from "react"; +import { MapNavbar } from "~/components/map-navbar"; +import { SETTINGS } from "~/settings"; +import "~/styles/global.styl"; +import { s3Address, tempImageIndex } from "../../raster-images"; +import styles from "./main.module.sass"; + +const h = hyper.styled(styles); + +function rasterURL(source_id) { + const image = tempImageIndex[source_id]; + if (image == null) return null; + return `${s3Address}/${image}`; +} + +interface StyleOpts { + style: string; + focusedMap: number; + layerOpacity: { + vector: number | null; + raster: number | null; + }; +} + +const emptyStyle: any = { + version: 8, + sprite: "mapbox://sprites/mapbox/bright-v9", + glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf", + sources: {}, + layers: [], +}; + +function buildOverlayStyle({ + style, + focusedMap, + layerOpacity, +}: StyleOpts): any { + let mapStyle = emptyStyle; + if (layerOpacity.vector != null) { + mapStyle = buildMacrostratStyle({ + tileserverDomain: SETTINGS.burwellTileDomain, + focusedMap, + fillOpacity: layerOpacity.vector - 0.1, + strokeOpacity: layerOpacity.vector + 0.2, + lineOpacity: layerOpacity.vector + 0.4, + }); + } + + if (style == null) { + return mapStyle; + } + + return mergeStyles(style, mapStyle); +} + +function ensureBoxInGeographicRange(bounds: LngLatBoundsLike) { + if (bounds[1] < -90) bounds[1] = -90; + if (bounds[3] > 90) bounds[3] = 90; + return bounds; +} + +enum Basemap { + Satellite = "satellite", + Basic = "basic", + None = "none", +} + +function basemapStyle(basemap, inDarkMode) { + switch (basemap) { + case Basemap.Satellite: + return SETTINGS.satelliteMapURL; + case Basemap.Basic: + return inDarkMode ? SETTINGS.darkMapURL : SETTINGS.baseMapURL; + case Basemap.None: + return null; + } +} + +export default function MapInterface({ id, map }) { + const [isOpen, setOpen] = useState(false); + const dark = useDarkMode()?.isEnabled ?? false; + const title = map.properties.name; + + const hasRaster = rasterURL(map.properties.source_id) != null; + + const bounds: LngLatBoundsLike = useMemo(() => { + return ensureBoxInGeographicRange(boundingBox(map.geometry)); + }, [map.geometry]); + + const [layer, setLayer] = useState(Basemap.None); + const [style, setStyle] = useState(null); + // Basemap style + useEffect(() => { + if (layer == null) setStyle(null); + const styleURL = basemapStyle(layer, dark); + getMapboxStyle(styleURL, { + access_token: SETTINGS.mapboxAccessToken, + }).then(setStyle); + }, [layer, dark]); + + const [selectedLocation, setSelectedLocation] = useState(null); + + const [layerOpacity, setLayerOpacity] = useState({ + vector: 0.5, + raster: 0.5, + }); + + // Overlay style + const [mapStyle, setMapStyle] = useState(null); + useEffect(() => { + setMapStyle( + buildOverlayStyle({ + style, + focusedMap: map.properties.source_id, + layerOpacity, + }) + ); + }, [ + map.properties.source_id, + style, + layerOpacity.raster == null, + layerOpacity.vector == null, + ]); + + // Layer opacity + useEffect(() => { + if (mapStyle == null) return; + const mergeLayers = buildOverlayStyle({ + style, + focusedMap: map.properties.source_id, + layerOpacity, + }).layers; + + for (const layer of mapStyle.layers) { + let mergeLayer = mergeLayers.find((l) => l.id == layer.id); + layer.layout ??= {}; + layer.paint ??= {}; + if (mergeLayer == null) { + layer.layout.visibility = "none"; + continue; + } else { + layer.layout.visibility = "visible"; + } + for (const prop in ["fill-opacity", "line-opacity", "raster-opacity"]) { + if (mergeLayer.paint[prop] != null) { + layer.paint[prop] = mergeLayer.paint[prop]; + } + } + setMapStyle({ ...mapStyle, layers: mergeLayers }); + } + }, [layerOpacity]); + + const maxBounds: LatLngBoundsLike = useMemo(() => { + const dx = bounds[2] - bounds[0]; + const dy = bounds[3] - bounds[1]; + const buf = 1 * Math.max(dx, dy); + + return ensureBoxInGeographicRange([ + bounds[0] - buf, + bounds[1] - buf, + bounds[2] + buf, + bounds[3] + buf, + ]); + }, [bounds]); + + if (bounds == null || mapStyle == null) return h(Spinner); + + const contextPanel = h(PanelCard, [ + h("div.vector-controls", [ + h("h3", "Vector map"), + h(OpacitySlider, { + opacity: layerOpacity.vector, + setOpacity(v) { + setLayerOpacity({ ...layerOpacity, vector: v }); + }, + }), + ]), + h.if(hasRaster)("div.raster-controls", [ + h("h3", "Raster map"), + h(OpacitySlider, { + opacity: layerOpacity.raster, + setOpacity(v) { + setLayerOpacity({ ...layerOpacity, raster: v }); + }, + }), + ]), + h(BaseLayerSelector, { layer, setLayer }), + ]); + + return h( + MapAreaContainer, + { + className: "single-map", + navbar: h(MapNavbar, { isOpen, setOpen, minimal: true }), + contextPanel, + contextPanelOpen: isOpen, + detailPanelOpen: false, + fitViewport: false, + }, + [ + h( + MapView, + { + style: mapStyle, //"mapbox://styles/mapbox/satellite-v9", + mapboxToken: SETTINGS.mapboxAccessToken, + //projection: { name: "globe" }, + bounds, + mapPosition: null, + maxBounds, + fitBoundsOptions: { padding: 50 }, + infoMarkerPosition: selectedLocation, + }, + [ + h(MapMarker, { + position: selectedLocation, + setPosition(lnglat) { + setSelectedLocation(lnglat); + }, + }), + ] + //[h(FitBoundsManager, { bounds })] + ), + ] + ); +} + +function BaseLayerSelector({ layer, setLayer }) { + return h("div.base-layer-selector", [ + h("h3", "Base layer"), + h( + RadioGroup, + { + selectedValue: layer, + onChange(e) { + setLayer(e.currentTarget.value); + }, + }, + [ + h(Radio, { label: "Satellite", value: Basemap.Satellite }), + h(Radio, { label: "Basic", value: Basemap.Basic }), + h(Radio, { label: "None", value: Basemap.None }), + ] + ), + ]); +} + +function OpacitySlider(props) { + return h("div.opacity-slider", [ + h(NullableSlider, { + value: props.opacity, + min: 0.1, + max: 1, + labelStepSize: 0.2, + stepSize: 0.1, + onChange(v) { + props.setOpacity(v); + }, + }), + ]); +} diff --git a/src/pages/maps/@id/edit/override.sass b/src/pages/maps/@id/edit/override.sass new file mode 100644 index 00000000..6c3c1ad9 --- /dev/null +++ b/src/pages/maps/@id/edit/override.sass @@ -0,0 +1,17 @@ + +// For the button text in cells +.bp4-button-text + overflow: hidden + text-overflow: ellipsis + + +// Need this to get the popups in front of the sidebar +.bp4-portal + z-index: 101 + +.bp4-menu + padding: 0px + +// For the table column highlighting +.final-column + box-shadow: inset 0 -1px 0 rgba(39, 201, 236, 0.55), inset -1px 0 0 rgba(39, 201, 236, 0.55) !important \ No newline at end of file diff --git a/src/pages/maps/@id/edit/server.ts b/src/pages/maps/@id/edit/server.ts new file mode 100644 index 00000000..ac8f02ba --- /dev/null +++ b/src/pages/maps/@id/edit/server.ts @@ -0,0 +1,48 @@ +interface Error { + code: number; + text: string; +} + +interface Patch { + id: number; + function: () => Response; + error: Error; + attempts: number; +} + +// Holds the current state of the patching process +class PatchManager { + constructor() { + this.active = []; + this.success = []; + this.failed = []; + this.abandoned = []; + } + + // Wraps the patch function and handles in the context of the manager + async runPatch(patch: Patch) { + patch.attempts += 1; + + try { + let response = await patch.function(); + + if (response.status == 400) { + patch.error.code = response.status; + patch.error.text = (await response.json())["details"]; + this.abandoned.push(patch); + } else if (response.status == 200) { + this.success.push(patch); + } else { + this.failed.push(patch); + } + } catch (e) { + patch.error.code = response.status; + patch.error.text = (await response.json())["details"]; + this.failed.push(patch); + } + } +} + +async function getTable() {} + +async function patchTable(value, db_id) {} diff --git a/src/pages/maps/@id/edit/table-menu.ts b/src/pages/maps/@id/edit/table-menu.ts new file mode 100644 index 00000000..aa06af27 --- /dev/null +++ b/src/pages/maps/@id/edit/table-menu.ts @@ -0,0 +1,129 @@ +import {Button, Menu, MenuItem, InputGroup} from "@blueprintjs/core"; +import {Select2, ItemRenderer} from "@blueprintjs/select"; +import React from "react"; +import {useDebouncedCallback} from "use-debounce"; + +// @ts-ignore +import hyper from "@macrostrat/hyper"; + +import {OperatorQueryParameter, ColumnOperatorOption} from "./table"; + +import "@blueprintjs/core/lib/css/blueprint.css" +import "@blueprintjs/select/lib/css/blueprint-select.css"; +import styles from "./edit-table.module.sass"; +import {Filter} from "./table-util"; + + +const h = hyper.styled(styles); + + +const validExpressions: ColumnOperatorOption[] = [ + {key: "eq", value: "=", verbose: "Equals"}, + {key: "lt", value: "<", verbose: "Is less than"}, + {key: "le", value: "<=", verbose: "Is less than or equal to"}, + {key: "gt", value: ">", verbose: "Is greater than"}, + {key: "ge", value: ">=", verbose: "Is greater than or equal to"}, + {key: "ne", value: "<>", verbose: "Is not equal to"}, + {key: "like", value: "LIKE", verbose: "Like"}, + {key: "is", value: "IS", verbose: "Is", placeholder: "true | false | null"}, + {key: "in", value: "IN", verbose: "In", placeholder: "1,2,3"} +] + + + +const OperatorFilterOption: ItemRenderer<ColumnOperatorOption> = (column, { handleClick, handleFocus, modifiers }) => { + + return h(MenuItem, { + shouldDismissPopover: false, + active: modifiers.active, + disabled: modifiers.disabled, + key: column.key, + label: column.verbose, + onClick: handleClick, + onFocus: handleFocus, + text: column.value, + roleStructure:"listoption" + }, []) +} + +interface TableMenuProps { + columnName: string; + onFilterChange: (query: OperatorQueryParameter) => void; + filter: Filter; + onGroupChange: (group: string | undefined) => void; + group: string | undefined; +} + +const TableMenu = ({columnName, onFilterChange, filter, onGroupChange, group} : TableMenuProps) => { + + const [menuOpen, setMenuOpen] = React.useState<boolean>(false); + const [inputPlaceholder, setInputPlaceholder] = React.useState<string>(""); + + // Create a debounced version of the text state + const [inputValue, setInputValue] = React.useState<string>(filter.value); + const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + setMenuOpen(false); + onFilterChange({operator: filter.operator, value: e.target.value}) + } + const debouncedInputChange = useDebouncedCallback(onInputChange, 1000); + + // Set the expression current value from the parent filter + const selectedExpression = validExpressions.find((expression) => expression.key === filter.operator); + + // Set if this group is active + const groupActive: boolean = group === columnName; + + return h(Menu, {}, [ + h("div.filter-container", {}, [ + h("div.filter-header", {}, ["Filter"]), + h("div.filter-select", {}, [ + h(Select2<ColumnOperatorOption>, { + fill: true, + items: validExpressions, + className: "update-input-group", + filterable: false, + popoverProps: {isOpen: menuOpen}, + itemRenderer: OperatorFilterOption, + onItemSelect: (operator: ColumnOperatorOption) => { + setMenuOpen(false); + setInputPlaceholder(operator.placeholder || ""); + onFilterChange({operator: operator.key, value: filter.value}) + }, + noResults: h(MenuItem, {disabled: true, text: "No results.", roleStructure: "listoption"}, []), + }, [ + h(Button, { + fill: true, + onClick: () => setMenuOpen(!menuOpen), + alignText: "left", + text: selectedExpression?.verbose, + rightIcon: "double-caret-vertical", + className: "update-input-group", + placeholder: "Select A Filter" + }, []) + ]), + ]), + h("div.filter-input", {}, [ + h(InputGroup, { + "value": inputValue, + className: "update-input-group", + placeholder: inputPlaceholder, + onChange: (e: React.ChangeEvent<HTMLInputElement>) => {setInputValue(e.target.value); debouncedInputChange(e)} + }, []) + ]), + h("div.filter-header", {}, ["Group"]), + h("div.filter-select", {}, [ + h(Button, + { + rightIcon: groupActive ? "tick" : "disable", + alignText: "left", + intent: groupActive ? "success" : "warning", + text: groupActive ? "Active" : "Inactive", + fill: true, + onClick: () => onGroupChange(group == filter.column_name ? undefined : filter.column_name) + }, []) + ]), + ]) + ]) +} + +export default TableMenu; \ No newline at end of file diff --git a/src/pages/maps/@id/edit/table-util.ts b/src/pages/maps/@id/edit/table-util.ts new file mode 100644 index 00000000..f8967a75 --- /dev/null +++ b/src/pages/maps/@id/edit/table-util.ts @@ -0,0 +1,250 @@ +import { ColumnOperators, Filters, TableSelection, TableUpdate, DataParameters } from "./table"; +import {secureFetch} from "@macrostrat-web/security"; + + +export class Filter { + readonly column_name: string; + readonly operator: ColumnOperators | undefined; + readonly value: string; + + constructor(column_name: string, operator: ColumnOperators | undefined, value: string){ + this.column_name = column_name + this.operator = operator + this.value = value + } + + get formattedValue(){ + switch (this.operator) { + case "in": + return `(${this.value})` + default: + return this.value + } + } + + get urlValue() { + return this.operator + "." + this.formattedValue + } + + passes = (data: {[key: string] : string}) => { + const filterValue = data[this.column_name] + switch (this.operator) { + case "eq": + return filterValue == this.value + case "lt": + return filterValue < this.value + case "le": + return filterValue <= this.value + case "gt": + return filterValue > this.value + case "ge": + return filterValue >= this.value + case "ne": + return filterValue != this.value + case "like": + return filterValue.includes(this.value) + case "in": + return this.value.includes(filterValue) + case "is": + return filterValue == this.value + default: + return false + } + } + + is_valid = () => { + if(this.operator == undefined || this.value == ""){ + return false + } + return true + } + + to_array = () => { + return [this.column_name, this.operator + "." + this.formattedValue] + } + +} + + +export function buildURL(baseURL: string, dataParameters: DataParameters){ + let url = new URL(baseURL) + + // Order by ID if no group is specified + if(dataParameters?.group == undefined){ + url.searchParams.append("_pkid", "order_by" ) + + // Otherwise order by group and group by group + } else { + url.searchParams.append(dataParameters.group, "order_by" ) + url.searchParams.append(dataParameters.group, "group_by") + } + + // Add the page and page size + url.searchParams.append("page", dataParameters.select.page); + url.searchParams.append("page_size", dataParameters.select.pageSize); + + // Add the rest of the filters + if(dataParameters?.filter != undefined){ + for(const filter of Object.values(dataParameters?.filter)){ + if(filter.is_valid()){ + const [columnName, filterValue] = filter.to_array() + url.searchParams.append(columnName, filterValue); + } + } + } + + return url +} + +export const applyTableUpdate = (data: any[], tableUpdate: TableUpdate) => { + + let appliedData = structuredClone(data) + for(const [rowIndex, row] of data.entries()){ + for(const columnName of Object.keys(row)){ + appliedData[rowIndex][columnName] = tableUpdate.applyToCell(appliedData[rowIndex][columnName], row, columnName) + } + } + + return appliedData +} + +export const applyTableUpdates = (data: any[], tableUpdates: TableUpdate[]) => { + + let appliedData = structuredClone(data) + for(const tableUpdate of tableUpdates){ + appliedData = applyTableUpdate(appliedData, tableUpdate) + } + + return appliedData +} + +/** + * Wraps around submitChange to filter based on the group + */ +export const getTableUpdate = ( + url: string, + value: string, + columnName: string, + rowIndex: number, + data: any[], + dataParameters: DataParameters +): TableUpdate => { + + dataParameters = structuredClone(dataParameters) + if( dataParameters?.group != undefined){ + dataParameters.filter[dataParameters?.group] = new Filter(dataParameters?.group, "eq", data[rowIndex][dataParameters?.group]) + } else { + dataParameters.filter["_pkid"] = new Filter("_pkid", "eq", data[rowIndex]["_pkid"]) + } + + const execute = async () => submitChange(url, value, [columnName], dataParameters.filter) + + const apply = (currentValue: string, row: {[key: string]: string}, cellColumnName: string) => { + + // If this function does not apply to this column skip it + if (cellColumnName != columnName) { + return currentValue + } + + // If this row doesn't pass all the filters skip it + if(dataParameters?.filter != undefined) { + for (const filter of Object.values(dataParameters.filter)) { + if (!filter.passes(row)) { + return currentValue + } + } + } + // Return the new value + return value + } + + return { + description: "Update " + columnName + " to " + value + " for " + JSON.stringify(dataParameters.filter), + "execute": execute, + "applyToCell": apply + } as TableUpdate +} + +export const submitChange = async (url: string, value: string, columns: string[], filters: {[key: string] : Filter}) => { + + // Query per column + for (const column of columns) { + + let updateURL = new URL(url); + + // Add the filters to the query parameters + for (const filter of Object.values(filters)) { + + // Check that the filter is valid + if(!filter.is_valid()){ + continue + } + + const [columnName, filterValue] = filter.to_array() + updateURL.searchParams.append(columnName, filterValue); + } + + // Create the request body + let patch = { [column]: value }; + + // Send the request + let response = await secureFetch(updateURL, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(patch), + }); + + if (response.status != 204) { + + // Stop execution if the request failed + throw Error("Failed to update"); + } + } +}; + +export const submitColumnCopy = async (url: string, sourceColumn: string, targetColumn: string, dataParameters: DataParameters) => { + + + let updateURL = new URL(url + "/" + targetColumn); + + // Add the filters to the query parameters + for (const filter of Object.values(dataParameters.filter)) { + + // Check that the filter is valid + if(!filter.is_valid()){ + continue + } + + const [columnName, filterValue] = filter.to_array() + updateURL.searchParams.append(columnName, filterValue); + } + + // Create the request body + let patch = { "source_column": sourceColumn }; + + // Send the request + let response = await secureFetch(updateURL, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(patch), + }); + + if (response.status != 204) { + + // Stop execution if the request failed + throw Error("Failed to update"); + } +} + +export function isEmptyArray(arr) { + return arr.length == 0 || arr.every((x) => x == null); +} + +export const range = (start, stop, step = 1) => + Array(Math.ceil((stop - start) / step)) + .fill(start) + .map((x, y) => x + y * step); diff --git a/src/pages/maps/@id/edit/table.d.ts b/src/pages/maps/@id/edit/table.d.ts new file mode 100644 index 00000000..3e394c0e --- /dev/null +++ b/src/pages/maps/@id/edit/table.d.ts @@ -0,0 +1,57 @@ +import {Filter} from "./table-util.ts"; + + +export type ColumnOperators = "eq" | "lt" | "le" | "gt" | "ge" | "ne" | "like" | "in" | "is"; + +export interface ColumnOperatorOption { + key: ColumnOperators; + value: string; + verbose: string; + placeholder?: string; +} + +export interface OperatorQueryParameter { + operator: ColumnOperators | undefined; + value: string; +} + +interface Filters { + [key: string]: Filter; +} + +interface Selection { + cols: number[]; + rows: number[]; +} + + +interface Filters { + [key: string]: Filter; +} + +// An object that represents a selection of rows and columns +interface TableSelection { + columns: string[]; + filters: Filters; +} + +// An object that represents a value update made on top of a specific TableSelection +interface TableUpdate { + // Helpful for debugging + description?: string + // Function to execute this update + execute: () => Promise<void>; + // Function to apply this update to a cell + applyToCell: (currentValue: string, row: {[key: string]: string}, cellColumnName: string) => string; +} + +export interface DataParameters { + group?: string; + select: { + page?: string + pageSize?: string; + }; + filter: { + [key: string]: Filter; // Used for filters + } +} \ No newline at end of file diff --git a/src/pages/maps/index.page.ts b/src/pages/maps/index.page.ts index a65ad0ef..e008dbb8 100644 --- a/src/pages/maps/index.page.ts +++ b/src/pages/maps/index.page.ts @@ -2,6 +2,7 @@ import hyper from "@macrostrat/hyper"; // Page for a list of maps import styles from "./main.module.sass"; import { tempImageIndex, s3Address } from "./raster-images"; +import { Icon, IconSize } from "@blueprintjs/core"; const h = hyper.styled(styles); @@ -26,6 +27,7 @@ export function Page({ sources }) { function SourceItem({ source }) { const { source_id, name } = source; const href = `/maps/${source_id}`; + const edit_href = `/maps/${source_id}/edit`; return h("li", [ h("span.source-id", {}, source_id), " ", @@ -33,5 +35,7 @@ function SourceItem({ source }) { " ", h("span.scale", {}, source.scale), h.if(source.rasterURL != null)([" ", h("span.raster", "Raster")]), + " ", + h("a", { href: edit_href }, [h(Icon, { icon: "edit", size: IconSize.SMALL })]), ]); } diff --git a/src/renderer/page-shell.ts b/src/renderer/page-shell.ts index 3574622a..cec62e75 100644 --- a/src/renderer/page-shell.ts +++ b/src/renderer/page-shell.ts @@ -16,6 +16,10 @@ export function PageShell({ pageContext: PageContext; }) { return h("div.app-shell", [ - h(PageContextProvider, { pageContext }, h(DarkModeProvider, children)), + h( + PageContextProvider, + { pageContext }, + h(DarkModeProvider, { followSystem: true }, children) + ), ]); } diff --git a/yarn.lock b/yarn.lock index ca9e74c8..778b3255 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1856,12 +1856,12 @@ __metadata: languageName: node linkType: hard -"@brillout/vite-plugin-import-build@npm:^0.2.20": - version: 0.2.20 - resolution: "@brillout/vite-plugin-import-build@npm:0.2.20" +"@brillout/vite-plugin-import-build@npm:0.2.22-commit-7f1bb0a": + version: 0.2.22-commit-7f1bb0a + resolution: "@brillout/vite-plugin-import-build@npm:0.2.22-commit-7f1bb0a" dependencies: "@brillout/import": ^0.2.3 - checksum: 761d9c8e370f385bc28164091e583a31e9f43437451e186a5ec9c244399988846db4fa8ca7e6582c0f1b5c09461a2840f88d979e82636e030b421e6ca6d1582d + checksum: e3a31e3a9254597076acce298b24bef938d00f34ef2ee5550631f866d13a9899738571fcd4ed0119351d25acc09bb43205519d52fe141f76c90779e1cb39683c languageName: node linkType: hard @@ -3189,6 +3189,12 @@ __metadata: languageName: unknown linkType: soft +"@macrostrat-web/security@workspace:*, @macrostrat-web/security@workspace:packages/security": + version: 0.0.0-use.local + resolution: "@macrostrat-web/security@workspace:packages/security" + languageName: unknown + linkType: soft + "@macrostrat/api-types@workspace:deps/web-components/packages/api-types": version: 0.0.0-use.local resolution: "@macrostrat/api-types@workspace:deps/web-components/packages/api-types" @@ -3709,10 +3715,12 @@ __metadata: "@babel/preset-typescript": ^7.18.6 "@blueprintjs/core": ^4.14.1 "@blueprintjs/select": 4 + "@blueprintjs/table": ^4 "@lagunovsky/redux-react-router": ^3.2.0 "@loadable/component": ^5.14.1 "@macrostrat-web/data-sheet-test": "workspace:*" "@macrostrat-web/globe": "workspace:*" + "@macrostrat-web/security": "workspace:*" "@macrostrat/api-utils": "workspace:*" "@macrostrat/api-views": "workspace:*" "@macrostrat/column-components": "workspace:*" @@ -3752,6 +3760,7 @@ __metadata: chroma-js: ^2.4.2 classnames: ^2.2.6 compression: ^1.7.4 + cookie-parser: ^1.4.6 cross-env: ^7.0.3 d3-array: ^3.1.1 d3-axis: ^3.0.0 @@ -3764,6 +3773,7 @@ __metadata: express: ^4.18.2 history: ^5.3.0 immutability-helper: ^3.1.1 + jose: ^5.1.2 mapbox-gl: ^2.15.0 new-github-issue-url: ^1.0.0 pbf: ^3.2.1 @@ -3790,9 +3800,10 @@ __metadata: transition-hook: ^1.5.2 ts-node: ^10.9.1 typescript: ^5.1.6 + use-debounce: ^9.0.4 use-react-router-breadcrumbs: ^3.2.1 use-resize-observer: ^9.1.0 - vike: ^0.4.150 + vike: 0.4.150-commit-63b1c32 vite: ^4.4.9 vite-plugin-cesium: ^1.2.22 vite-plugin-rewrite-all: ^1.0.1 @@ -10600,6 +10611,16 @@ __metadata: languageName: node linkType: hard +"cookie-parser@npm:^1.4.6": + version: 1.4.6 + resolution: "cookie-parser@npm:1.4.6" + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + checksum: 1e5a63aa82e8eb4e02d2977c6902983dee87b02e87ec5ec43ac3cb1e72da354003716570cd5190c0ad9e8a454c9d3237f4ad6e2f16d0902205a96a1c72b77ba5 + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -10607,6 +10628,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:0.4.1": + version: 0.4.1 + resolution: "cookie@npm:0.4.1" + checksum: bd7c47f5d94ab70ccdfe8210cde7d725880d2fcda06d8e375afbdd82de0c8d3b73541996e9ce57d35f67f672c4ee6d60208adec06b3c5fc94cebb85196084cf8 + languageName: node + linkType: hard + "cookie@npm:0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" @@ -18017,6 +18045,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.1.2": + version: 5.1.3 + resolution: "jose@npm:5.1.3" + checksum: c0225c3408b1c3fcfc40c68161e5e14d554c606ffa428250e0db618469df52f4ce32c69918e296c8c299db1081981638096be838426f61d926269d35602fd814 + languageName: node + linkType: hard + "jpeg-js@npm:^0.4.1": version: 0.4.4 resolution: "jpeg-js@npm:0.4.4" @@ -29230,6 +29265,15 @@ __metadata: languageName: node linkType: hard +"use-debounce@npm:^9.0.4": + version: 9.0.4 + resolution: "use-debounce@npm:9.0.4" + peerDependencies: + react: ">=16.8.0" + checksum: 37da4ecbe4e10a6230580cac03a8cae1788ea3e417dfdd92fcf654325458cf1b4567fd57bebf888edab62701a6abe47059a585008fd04228784f223f94d66ce4 + languageName: node + linkType: hard + "use-element-dimensions@npm:^2.1.3": version: 2.1.3 resolution: "use-element-dimensions@npm:2.1.3" @@ -29527,15 +29571,15 @@ __metadata: languageName: node linkType: hard -"vike@npm:^0.4.150": - version: 0.4.150 - resolution: "vike@npm:0.4.150" +"vike@npm:0.4.150-commit-63b1c32": + version: 0.4.150-commit-63b1c32 + resolution: "vike@npm:0.4.150-commit-63b1c32" dependencies: "@brillout/import": 0.2.3 "@brillout/json-serializer": ^0.5.8 "@brillout/picocolors": ^1.0.10 "@brillout/require-shim": ^0.1.2 - "@brillout/vite-plugin-import-build": ^0.2.20 + "@brillout/vite-plugin-import-build": 0.2.22-commit-7f1bb0a acorn: ^8.8.2 cac: ^6.7.14 es-module-lexer: ^1.3.0 @@ -29551,7 +29595,7 @@ __metadata: optional: true bin: vike: node/cli/bin-entry.js - checksum: 3c925b3339286ac6bb2e89fa4f066350b407efb7f128f2e6851fc8b2ce7975e369b46dc4821092fe5c1811656aac33656c6cbda511dfe45a72067bf73a4b3ada + checksum: 6e0b8e9aec401083bd05182ca63b212ad72260086e3464e32858d5369a28d2373030f24ae0deed93d71e91f1bfe91a5d02e5235613e14203d3c1bdf37181f04a languageName: node linkType: hard