From 19cf7bfe0d758407d2e110e80dc0c82c080e4af3 Mon Sep 17 00:00:00 2001 From: Kris Siepert Date: Mon, 16 Dec 2024 15:09:07 +0100 Subject: [PATCH 01/12] feat: add radix image upload criteria list --- .../CombinedFeaturePanel.tsx | 101 ++++++----- .../components/FeatureGallery.tsx | 35 +--- .../components/FeatureImageUpload.tsx | 68 +++++++ .../Gallery/GalleryAddImageButton.tsx | 22 --- .../ImageUploadCallToAction.tsx} | 2 +- .../ImageUpload/ImageUploadCriteriaList.tsx | 121 +++++++++++++ .../poi/PlaceOfInterestDetails.tsx | 91 ++++++---- .../[id]/images/[imageId]/index.tsx | 32 ++-- src/pages/[placeType]/[id]/images/upload.tsx | 168 ++---------------- 9 files changed, 339 insertions(+), 301 deletions(-) create mode 100644 src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx delete mode 100644 src/components/CombinedFeaturePanel/components/Gallery/GalleryAddImageButton.tsx rename src/components/CombinedFeaturePanel/components/{Gallery/GalleryCallToAction.tsx => ImageUpload/ImageUploadCallToAction.tsx} (96%) create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx diff --git a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx index a62e0d76b..088f77118 100644 --- a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx +++ b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx @@ -1,84 +1,95 @@ -import { useHotkeys } from '@blueprintjs/core' -import { uniqBy } from 'lodash' -import React, { useMemo, useState } from 'react' -import styled from 'styled-components' -import { t } from 'ttag' +import { useHotkeys } from "@blueprintjs/core"; +import { Box, Callout } from "@radix-ui/themes"; +import { uniqBy } from "lodash"; +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; +import { t } from "ttag"; import { - AnyFeature, getKey, isOSMFeature, isSearchResultFeature, -} from '../../lib/model/geo/AnyFeature' -import colors from '../../lib/util/colors' -import FeaturesDebugJSON from './components/FeaturesDebugJSON' -import PlaceOfInterestDetails from './type-specific/poi/PlaceOfInterestDetails' -import ErrorBoundary from '../shared/ErrorBoundary' -import OSMSidewalkDetails from './type-specific/surroundings/OSMSidewalkDetails' -import OSMBuildingDetails from './type-specific/building/OSMBuildingDetails' -import { Box, Callout } from '@radix-ui/themes' + type AnyFeature, + getKey, + isOSMFeature, + isSearchResultFeature, +} from "../../lib/model/geo/AnyFeature"; +import colors from "../../lib/util/colors"; +import ErrorBoundary from "../shared/ErrorBoundary"; +import FeaturesDebugJSON from "./components/FeaturesDebugJSON"; +import OSMBuildingDetails from "./type-specific/building/OSMBuildingDetails"; +import PlaceOfInterestDetails from "./type-specific/poi/PlaceOfInterestDetails"; +import OSMSidewalkDetails from "./type-specific/surroundings/OSMSidewalkDetails"; type Props = { features: AnyFeature[]; - focusImage?: string -} + activeImageId?: string; + isUploadDialogOpen?: boolean; +}; function FeatureSection({ feature }: { feature: AnyFeature }) { if (!isOSMFeature(feature)) { - return
Feature type not supported
+ return
Feature type not supported
; } if (feature.properties.building) { - return + return ; } if ( - feature.properties.highway === 'footway' - || feature.properties.highway === 'pedestrian' + feature.properties.highway === "footway" || + feature.properties.highway === "pedestrian" ) { - return + return ; } return (

Feature type not supported

- ) + ); } - export function CombinedFeaturePanel(props: Props) { - const features = uniqBy(props.features, (feature) => (isSearchResultFeature(feature) ? feature.properties.osm_id : feature._id)) + const features = uniqBy(props.features, (feature) => + isSearchResultFeature(feature) ? feature.properties.osm_id : feature._id, + ); - const [showDebugger, setShowDebugger] = useState(false) - const hotkeys = useMemo(() => [ - { - combo: 'j', - global: true, - label: 'Show JSON Feature Debugger', - onKeyDown: () => setShowDebugger(!showDebugger), - }, - - ], [showDebugger]) - const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys) - const surroundings = features?.length > 1 ? features.slice(1) : undefined + const [showDebugger, setShowDebugger] = useState(false); + const hotkeys = useMemo( + () => [ + { + combo: "j", + global: true, + label: "Show JSON Feature Debugger", + onKeyDown: () => setShowDebugger(!showDebugger), + }, + ], + [showDebugger], + ); + const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys); + const surroundings = features?.length > 1 ? features.slice(1) : undefined; return ( - {features[0] && } - {surroundings?.map((feature) => )} + {features[0] && ( + + )} + {surroundings?.map((feature) => ( + + ))} {(!features || features.length === 0) && ( - - {t`No features found.`} - + {t`No features found.`} )} -

- {showDebugger && } -

+

{showDebugger && }

- ) + ); } diff --git a/src/components/CombinedFeaturePanel/components/FeatureGallery.tsx b/src/components/CombinedFeaturePanel/components/FeatureGallery.tsx index d23e1d11a..56fa04173 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureGallery.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureGallery.tsx @@ -1,35 +1,9 @@ -import { CameraIcon } from "@radix-ui/react-icons"; -import { - AspectRatio, - Box, - Button, - Callout, - Card, - Dialog, - Flex, - Grid, - Inset, - VisuallyHidden, -} from "@radix-ui/themes"; -import { useRouter } from "next/router"; -import { - type FC, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -/* eslint-disable jsx-a11y/alt-text */ -/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -/* eslint-disable @next/next/no-img-element */ +import { type FC, useMemo } from "react"; import useSWR from "swr"; import useAccessibilityCloudAPI from "~/lib/fetchers/ac/useAccessibilityCloudAPI"; import type { AccessibilityCloudImage } from "~/lib/model/ac/Feature"; import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; import { Gallery } from "./Gallery/Gallery"; -import { GalleryAddImageButton } from "./Gallery/GalleryAddImageButton"; import { makeImageIds, makeImageLocation } from "./Gallery/util"; const fetcher = (urls: string[]) => { @@ -51,8 +25,8 @@ interface ImageResponse { export const FeatureGallery: FC<{ feature: AnyFeature; - focusImage?: string; -}> = ({ feature, focusImage }) => { + activeImageId?: string; +}> = ({ feature, activeImageId }) => { const ids = makeImageIds(feature); const { baseUrl, appToken } = useAccessibilityCloudAPI({ cached: true }); const { data } = useSWR( @@ -65,8 +39,7 @@ export const FeatureGallery: FC<{ return ( <> - - + ); }; diff --git a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx new file mode 100644 index 000000000..6ae1e8c07 --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx @@ -0,0 +1,68 @@ +import { CameraIcon } from "@radix-ui/react-icons"; +import { Button, Dialog, Flex } from "@radix-ui/themes"; +import React, { + type FC, + type MouseEventHandler, + useContext, + useRef, + useState, +} from "react"; +import { t } from "ttag"; +import { AppStateLink } from "~/components/App/AppStateLink"; +import { FeaturePanelContext } from "~/components/CombinedFeaturePanel/FeaturePanelContext"; +import { ImageUploadCallToAction } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCallToAction"; +import { ImageUploadCriteriaList } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList"; +import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; + +export const FeatureImageUpload: FC<{ + feature: AnyFeature; + isUploadDialogOpen?: boolean; +}> = ({ feature, isUploadDialogOpen }) => { + const { baseFeatureUrl } = useContext(FeaturePanelContext); + + const [dialogOpen, setDialogOpen] = useState(isUploadDialogOpen); + + const open = () => { + setDialogOpen(true); + }; + const close = () => { + setDialogOpen(false); + }; + + const linkRef = useRef(null); + const handleOnClickAddImageButton: MouseEventHandler = (event) => { + event.preventDefault(); + open(); + }; + + return ( + + + + + + + + + {t`Add new image`} + {t`Before uploading a new image, please make sure that the image meets the following criteria:`} + <> + + + + + + + + + ); +}; diff --git a/src/components/CombinedFeaturePanel/components/Gallery/GalleryAddImageButton.tsx b/src/components/CombinedFeaturePanel/components/Gallery/GalleryAddImageButton.tsx deleted file mode 100644 index 1ec661360..000000000 --- a/src/components/CombinedFeaturePanel/components/Gallery/GalleryAddImageButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { CameraIcon } from "@radix-ui/react-icons"; -import { Button, Flex } from "@radix-ui/themes"; -import { type FC, useContext } from "react"; -import { t } from "ttag"; -import { AppStateLink } from "~/components/App/AppStateLink"; -import { FeaturePanelContext } from "~/components/CombinedFeaturePanel/FeaturePanelContext"; -import { GalleryCallToAction } from "./GalleryCallToAction"; - -export const GalleryAddImageButton: FC = () => { - const { baseFeatureUrl } = useContext(FeaturePanelContext); - - return ( - - - - - ); -}; diff --git a/src/components/CombinedFeaturePanel/components/Gallery/GalleryCallToAction.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCallToAction.tsx similarity index 96% rename from src/components/CombinedFeaturePanel/components/Gallery/GalleryCallToAction.tsx rename to src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCallToAction.tsx index e43918090..c9b63b288 100644 --- a/src/components/CombinedFeaturePanel/components/Gallery/GalleryCallToAction.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCallToAction.tsx @@ -56,7 +56,7 @@ const CallToAction = styled.div` } `; -export const GalleryCallToAction: FC = () => ( +export const ImageUploadCallToAction: FC = () => ( {t`Your good deed of the day!`} diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx new file mode 100644 index 000000000..4a7c44a7a --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx @@ -0,0 +1,121 @@ +import { CheckIcon } from "@radix-ui/react-icons"; +import { Box, Card, Flex, Grid, Text } from "@radix-ui/themes"; +import React, { type FC } from "react"; +import styled from "styled-components"; +import { t } from "ttag"; + +const ImageCriteriaList = styled.ul` + list-style: none; + padding: 0; + + .image-criteria-list__list-item { + img { + max-width: 100%; + } + } + + .image-criteria-list__icon { + fill: var(--green-a10); + stroke: var(--green-a10); + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + } + + .image-criteria-list__heading { + margin: 0; + font-size: 1rem; + } + + .image-criteria-list__pictogram { + margin: 0; + + & > figcaption { + text-align: center; + } + } + `; + +export const ImageUploadCriteriaList: FC = () => { + return ( + + + +
  • + + + +

    + {t`It contains useful information on accessibility.`} +

    + + {t`For example by showing entrances, toilets or a map of the site.`} + + +
    + A pictogram of an entrance + +
    {t`Entrance`}
    +
    +
    +
    + A pictogram of a toilet + +
    {t`Toilet`}
    +
    +
    +
    + A topdown view showing navigational information + +
    {t`Site map`}
    +
    +
    +
    +
    +
    +
  • +
    + +
  • + + +

    + {t`Was taken by me.`} +

    +
    + + {t`By uploading this image, I hereby publish it in the public domain as renounce copyright protection.`} +   + + ({t`CC0 1.0 Universal license`}) + + +
  • +
    + +
  • + + +

    + {t`Doesn't show identifiable persons.`} +

    +
    +
  • +
    +
    +
    + ); +}; diff --git a/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx b/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx index fc72c390c..9f768ccab 100644 --- a/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx +++ b/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx @@ -1,34 +1,43 @@ -import { Callout } from '@blueprintjs/core' -import { t } from 'ttag' -import Link from 'next/link' -import React, { useContext } from 'react' -import { AnyFeature, isPlaceInfo } from '../../../../lib/model/geo/AnyFeature' -import FeatureAccessibility from '../../components/AccessibilitySection/FeatureAccessibility' -import FeatureContext from '../../components/FeatureContext' -import FeatureNameHeader from '../../components/FeatureNameHeader' -import AddressMapsLinkItems from '../../components/IconButtonList/AddressMapsLinkItems' -import ExternalInfoAndEditPageLinks from '../../components/IconButtonList/ExternalInfoAndEditPageLinks' -import PhoneNumberLinks from '../../components/IconButtonList/PhoneNumberLinks' -import PlaceWebsiteLink from '../../components/IconButtonList/PlaceWebsiteLink' -import StyledIconButtonList from '../../components/IconButtonList/StyledIconButtonList' -import FeatureImage from '../../components/image/FeatureImage' -import FeaturesDebugJSON from '../../components/FeaturesDebugJSON' -import NextToiletDirections from '../../components/AccessibilitySection/NextToiletDirections' -import { AppStateLink } from '../../../App/AppStateLink' -import { FeaturePanelContext } from '../../FeaturePanelContext' -import { useMap } from '../../../Map/useMap' -import { AccessibilityItems } from '../../components/AccessibilitySection/PlaceAccessibility/AccessibilityItems' -import { FeatureGallery } from '../../components/FeatureGallery' -import { bbox } from '@turf/turf' +import { Callout } from "@blueprintjs/core"; +import { bbox } from "@turf/turf"; +import Link from "next/link"; +import React, { useContext } from "react"; +import { t } from "ttag"; +import { FeatureImageUpload } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; +import { + type AnyFeature, + isPlaceInfo, +} from "../../../../lib/model/geo/AnyFeature"; +import { AppStateLink } from "../../../App/AppStateLink"; +import { useMap } from "../../../Map/useMap"; +import { FeaturePanelContext } from "../../FeaturePanelContext"; +import FeatureAccessibility from "../../components/AccessibilitySection/FeatureAccessibility"; +import NextToiletDirections from "../../components/AccessibilitySection/NextToiletDirections"; +import { AccessibilityItems } from "../../components/AccessibilitySection/PlaceAccessibility/AccessibilityItems"; +import FeatureContext from "../../components/FeatureContext"; +import { FeatureGallery } from "../../components/FeatureGallery"; +import FeatureNameHeader from "../../components/FeatureNameHeader"; +import FeaturesDebugJSON from "../../components/FeaturesDebugJSON"; +import AddressMapsLinkItems from "../../components/IconButtonList/AddressMapsLinkItems"; +import ExternalInfoAndEditPageLinks from "../../components/IconButtonList/ExternalInfoAndEditPageLinks"; +import PhoneNumberLinks from "../../components/IconButtonList/PhoneNumberLinks"; +import PlaceWebsiteLink from "../../components/IconButtonList/PlaceWebsiteLink"; +import StyledIconButtonList from "../../components/IconButtonList/StyledIconButtonList"; +import FeatureImage from "../../components/image/FeatureImage"; type Props = { feature: AnyFeature; - focusImage?: string; -} + activeImageId?: string; + isUploadDialogOpen?: boolean; +}; -export default function PlaceOfInterestDetails({ feature, focusImage }: Props) { - const { baseFeatureUrl } = useContext(FeaturePanelContext) - const map = useMap() +export default function PlaceOfInterestDetails({ + feature, + activeImageId, + isUploadDialogOpen, +}: Props) { + const { baseFeatureUrl } = useContext(FeaturePanelContext); + const map = useMap(); if (!feature.properties) { return ( @@ -41,7 +50,7 @@ export default function PlaceOfInterestDetails({ feature, focusImage }: Props) {

    - ) + ); } return ( @@ -49,20 +58,22 @@ export default function PlaceOfInterestDetails({ feature, focusImage }: Props) { { - console.log(feature.geometry?.coordinates) - const coordinates = feature.geometry?.coordinates + console.log(feature.geometry?.coordinates); + const coordinates = feature.geometry?.coordinates; if (!coordinates) { - return + return; } - const cameraOptions = map?.map?.cameraForBounds(bbox(feature), { maxZoom: 19 }); + const cameraOptions = map?.map?.cameraForBounds(bbox(feature), { + maxZoom: 19, + }); if (cameraOptions) { - map?.map?.flyTo({ ...cameraOptions, duration: 1000, padding: 100 }) + map?.map?.flyTo({ ...cameraOptions, duration: 1000, padding: 100 }); } // map.current?.flyTo({ center: { ...feature.geometry?.coordinates } }) }} > - {feature['@type'] === 'osm:Feature' && ( + {feature["@type"] === "osm:Feature" && ( )} @@ -73,7 +84,11 @@ export default function PlaceOfInterestDetails({ feature, focusImage }: Props) { - + + @@ -88,9 +103,7 @@ export default function PlaceOfInterestDetails({ feature, focusImage }: Props) { {!props.equipmentInfoId && } */} - - {t`Report`} - + {t`Report`} - ) + ); } diff --git a/src/pages/[placeType]/[id]/images/[imageId]/index.tsx b/src/pages/[placeType]/[id]/images/[imageId]/index.tsx index f569fbda5..eac24ba7b 100644 --- a/src/pages/[placeType]/[id]/images/[imageId]/index.tsx +++ b/src/pages/[placeType]/[id]/images/[imageId]/index.tsx @@ -1,18 +1,26 @@ -import { useContext, useMemo } from 'react' -import { useRouter } from 'next/router' -import { CombinedFeaturePanel } from '../../../../../components/CombinedFeaturePanel/CombinedFeaturePanel' -import { FeaturePanelContext } from '../../../../../components/CombinedFeaturePanel/FeaturePanelContext' -import { getLayout } from '../../../../../components/CombinedFeaturePanel/PlaceLayout' +import { useRouter } from "next/router"; +import { useContext, useMemo } from "react"; +import { CombinedFeaturePanel } from "../../../../../components/CombinedFeaturePanel/CombinedFeaturePanel"; +import { FeaturePanelContext } from "../../../../../components/CombinedFeaturePanel/FeaturePanelContext"; +import { getLayout } from "../../../../../components/CombinedFeaturePanel/PlaceLayout"; export default function ShowImagePage() { - const { features } = useContext(FeaturePanelContext) - const resolvedFeatures = useMemo(() => features.map(({ feature }) => feature?.requestedFeature).filter((x) => !!x), [features]) - const { query: { imageId } } = useRouter() + const { features } = useContext(FeaturePanelContext); + const resolvedFeatures = useMemo( + () => + features + .map(({ feature }) => feature?.requestedFeature) + .filter((feature) => !!feature), + [features], + ); + const { + query: { imageId }, + } = useRouter(); - const id = typeof imageId === 'string' ? imageId : imageId[0] + const id = typeof imageId === "string" ? imageId : imageId[0]; return ( - - ) + + ); } -ShowImagePage.getLayout = getLayout +ShowImagePage.getLayout = getLayout; diff --git a/src/pages/[placeType]/[id]/images/upload.tsx b/src/pages/[placeType]/[id]/images/upload.tsx index 6462ac0a1..56c16f03f 100644 --- a/src/pages/[placeType]/[id]/images/upload.tsx +++ b/src/pages/[placeType]/[id]/images/upload.tsx @@ -1,155 +1,21 @@ -/* eslint-disable @stylistic/js/indent */ -/* eslint-disable indent */ -/* eslint-disable @next/next/no-img-element */ -import React, { - FC, useContext, useRef, useState, -} from 'react' -import { t } from 'ttag' -import { StyledPhotoUploadView } from '../../../../components/CombinedFeaturePanel/PhotoUploadView' -import { CheckmarkIcon } from '../../../../components/icons/actions' -import { useCurrentAppToken } from '../../../../lib/context/AppContext' -import { FeaturePanelContext } from '../../../../components/CombinedFeaturePanel/FeaturePanelContext' -import { AppStateLink } from '../../../../components/App/AppStateLink' -import uploadPhotoForFeature from '../../../../lib/fetchers/ac/refactor-this/postImageUpload' -import { getLayout } from '../../../../components/CombinedFeaturePanel/PlaceLayout' -import { Button } from '@radix-ui/themes' - -const uncachedUrl = process.env.NEXT_PUBLIC_ACCESSIBILITY_CLOUD_UNCACHED_BASE_URL || '' - -const ExplanationContent : FC<{ onStateChanged: (state: 'uploading' | 'done' | 'error') => unknown }> = ({ - onStateChanged, -}) => { - const fileInputRef = useRef(null) - const appToken = useCurrentAppToken() - - const { features } = useContext(FeaturePanelContext) - const feature = features[0].feature.requestedFeature - - const handleChange: React.ChangeEventHandler = (evt) => { - (async () => { - if (!evt.target.files) { - return - } - onStateChanged('uploading') - try { - await uploadPhotoForFeature(feature, evt.target.files, appToken, uncachedUrl) - } catch { - onStateChanged('error') - } - onStateChanged('done') - })() - } +import { useContext, useMemo } from "react"; +import { CombinedFeaturePanel } from "~/components/CombinedFeaturePanel/CombinedFeaturePanel"; +import { FeaturePanelContext } from "~/components/CombinedFeaturePanel/FeaturePanelContext"; +import { getLayout } from "~/components/CombinedFeaturePanel/PlaceLayout"; + +export default function ShowImageUploadPage() { + const { features } = useContext(FeaturePanelContext); + const resolvedFeatures = useMemo( + () => + features + .map(({ feature }) => feature?.requestedFeature) + .filter((feature) => !!feature), + [features], + ); return ( - -

    {t`The following images...`}

    - -
    -
    - - {t`...give useful information on accessibility.`} -
    -
    -
    - A pictogram of an entrance -
    {t`Entrances`}
    -
    -
    - A topdown view showing navigational information -
    {t`Site map`}
    -
    -
    - A pictogram of a toilet -
    {t`Toilets`}
    -
    -
    -
    - -
    -
    - - {t`...were taken by me`} -
    -
    - {t`I hereby publish these images in the public domain and renounce copyright protection `} - ( - - {t`CC0 1.0 Universal license`} - - ). -
    -
    - -
    -
    - - {t`...do not show any identifiable persons.`} -
    -
    - -
    - - - - - -
    -
    - ) -} - -const UploadingPanel: FC = () => ( - -

    {t`Uplading...`}

    -
    - -) - -const SuccessfulPanel: FC = () => ( - -

    {t`Thank you`}

    -
    -
    {t`We're thankful for your contribution and will review your uploaded image shortly!`}
    -
    - -
    - - - -
    -
    -) - -const ErrorPanel: FC<{ setState: (state: 'idle') => unknown }> = ({ setState }) => ( - -

    {t`That did not quite work right!`}

    -
    -
    - {t`Something went wrong on our side, we're sorry about the inconvenience. Do you want to try again?`} -
    -
    -
    - - - - -
    -
    -) - -export default function Page() { - const [state, setState] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle') - - switch (state) { - case 'idle': - return - case 'uploading': - return - case 'done': - return - default: - return - } + + ); } -Page.getLayout = getLayout +ShowImageUploadPage.getLayout = getLayout; From 1b7252586bc2806fb600f82db14b1eb5f1d0d41c Mon Sep 17 00:00:00 2001 From: Kris Siepert Date: Tue, 17 Dec 2024 13:48:39 +0100 Subject: [PATCH 02/12] feat: add image upload dropzone --- package-lock.json | 45 ++++ package.json | 1 + .../CombinedFeaturePanel.tsx | 2 + .../components/FeatureImageUpload.tsx | 97 ++++++-- .../ImageUpload/ImageUploadCriteriaList.tsx | 207 +++++++++++------- .../ImageUpload/ImageUploadDropzone.tsx | 83 +++++++ .../ImageUpload/ImageUploadPreview.tsx | 93 ++++++++ .../ImageUpload/ImageUploadProgress.tsx | 28 +++ .../ImageUpload/ImageUploadProgressItem.tsx | 13 ++ .../poi/PlaceOfInterestDetails.tsx | 3 + src/pages/[placeType]/[id]/images/upload.tsx | 10 +- 11 files changed, 482 insertions(+), 100 deletions(-) create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress.tsx create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgressItem.tsx diff --git a/package-lock.json b/package-lock.json index 1bee6e937..b8c6629ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "query-string": "^6.13.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-dropzone": "^14.3.5", "react-error-boundary": "^4.1.2", "react-map-gl": "^7.1.7", "react-radio-group": "^3.0.3", @@ -10541,6 +10542,15 @@ "node": ">= 4.5.0" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -14834,6 +14844,24 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/file-selector/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -22092,6 +22120,23 @@ "react": "^18.3.1" } }, + "node_modules/react-dropzone": { + "version": "14.3.5", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz", + "integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-error-boundary": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", diff --git a/package.json b/package.json index 958740614..969d75cfb 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "query-string": "^6.13.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-dropzone": "^14.3.5", "react-error-boundary": "^4.1.2", "react-map-gl": "^7.1.7", "react-radio-group": "^3.0.3", diff --git a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx index 088f77118..9b6069bed 100644 --- a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx +++ b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx @@ -21,6 +21,7 @@ type Props = { features: AnyFeature[]; activeImageId?: string; isUploadDialogOpen?: boolean; + uploadStep?: string; }; function FeatureSection({ feature }: { feature: AnyFeature }) { @@ -75,6 +76,7 @@ export function CombinedFeaturePanel(props: Props) { feature={features[0]} activeImageId={props.activeImageId} isUploadDialogOpen={props.isUploadDialogOpen} + uploadStep={props.uploadStep} /> )} {surroundings?.map((feature) => ( diff --git a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx index 6ae1e8c07..2b8eecbea 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx @@ -1,10 +1,12 @@ import { CameraIcon } from "@radix-ui/react-icons"; import { Button, Dialog, Flex } from "@radix-ui/themes"; +import { useRouter } from "next/router"; import React, { type FC, type MouseEventHandler, useContext, - useRef, + useEffect, + useMemo, useState, } from "react"; import { t } from "ttag"; @@ -12,36 +14,93 @@ import { AppStateLink } from "~/components/App/AppStateLink"; import { FeaturePanelContext } from "~/components/CombinedFeaturePanel/FeaturePanelContext"; import { ImageUploadCallToAction } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCallToAction"; import { ImageUploadCriteriaList } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList"; +import { + ImageUploadDropzone, + type ImageWithPreview, +} from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone"; +import { ImageUploadPreview } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview"; +import { ImageUploadProgress } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress"; import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; export const FeatureImageUpload: FC<{ feature: AnyFeature; isUploadDialogOpen?: boolean; -}> = ({ feature, isUploadDialogOpen }) => { + uploadStep?: string; +}> = ({ feature, isUploadDialogOpen, uploadStep: unsanitizedUploadStep }) => { const { baseFeatureUrl } = useContext(FeaturePanelContext); + const router = useRouter(); - const [dialogOpen, setDialogOpen] = useState(isUploadDialogOpen); + const [uploadStep, setUploadStep] = useState(0); + const [isDialogOpen, setIsDialogOpen] = useState( + Boolean(isUploadDialogOpen), + ); + const [image, setImage] = useState(); + + const uploadUrl = useMemo(() => { + return `${baseFeatureUrl}/images/upload`; + }, [baseFeatureUrl]); const open = () => { - setDialogOpen(true); + setIsDialogOpen(true); }; const close = () => { - setDialogOpen(false); + setIsDialogOpen(false); }; - const linkRef = useRef(null); const handleOnClickAddImageButton: MouseEventHandler = (event) => { event.preventDefault(); open(); }; + // Set the initial upload step from the url query parameter. We don't + // want to navigate to steps 3 or 4 via query parameter and only with + // user interaction with the forms, that's why it is sanitized here + useEffect(() => { + const uploadStep = Number.parseInt(unsanitizedUploadStep ?? "1"); + setUploadStep( + !uploadStep || uploadStep > 2 || uploadStep < 0 ? 1 : uploadStep, + ); + }, [unsanitizedUploadStep]); + + // Reset the url when closing the dialog + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (!isDialogOpen && window.location.pathname !== baseFeatureUrl) { + router.push(baseFeatureUrl); + } + }, [isDialogOpen]); + + // Set the url when opening the dialog + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (isDialogOpen && window.location.pathname !== uploadUrl) { + router.push(uploadUrl, undefined, { shallow: true }); + } + }, [isDialogOpen]); + + // Continue to step 3 when the image gets selected + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (isDialogOpen && image && uploadStep === 2) { + setUploadStep(3); + } + }, [image]); + + // Continue to step 2 when the image gets reset + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (isDialogOpen && !image && uploadStep === 3) { + setUploadStep(2); + } + }, [image]); + return ( - + - - - + {t`Add a new image`} + + + + + + {uploadStep === 1 && } + {uploadStep === 2 && } + {uploadStep === 3 && ( + + )} ); diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx index 4a7c44a7a..e39246b08 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx @@ -1,8 +1,18 @@ -import { CheckIcon } from "@radix-ui/react-icons"; -import { Box, Card, Flex, Grid, Text } from "@radix-ui/themes"; +import { CheckIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; +import { + Box, + Button, + Card, + Flex, + Grid, + Text, + Tooltip, + VisuallyHidden, +} from "@radix-ui/themes"; import React, { type FC } from "react"; import styled from "styled-components"; import { t } from "ttag"; +import { AppStateLink } from "~/components/App/AppStateLink"; const ImageCriteriaList = styled.ul` list-style: none; @@ -36,86 +46,123 @@ const ImageCriteriaList = styled.ul` } `; -export const ImageUploadCriteriaList: FC = () => { +export const ImageUploadCriteriaList: FC<{ uploadUrl: string }> = ({ + uploadUrl, +}) => { return ( - - - -
  • - - - + <> + + {t`Before uploading a new image, please make sure that the image meets the following criteria:`} + + + + + +
  • + + + +

    + {t`It contains useful information on accessibility.`} +

    + + {t`For example by showing entrances, toilets or a map of the site.`} + + +
    + A pictogram of an entrance + +
    {t`Entrance`}
    +
    +
    +
    + A pictogram of a toilet + +
    {t`Toilet`}
    +
    +
    +
    + A topdown view showing navigational information + +
    {t`Site map`}
    +
    +
    +
    +
    +
    +
  • +
    + +
  • + +

    - {t`It contains useful information on accessibility.`} + {t`Was taken by me.`}

    - - {t`For example by showing entrances, toilets or a map of the site.`} - - -
    - A pictogram of an entrance - -
    {t`Entrance`}
    -
    -
    -
    - A pictogram of a toilet - -
    {t`Toilet`}
    -
    -
    -
    - A topdown view showing navigational information - -
    {t`Site map`}
    -
    -
    -
    - -
    -
  • -
    - -
  • - - -

    - {t`Was taken by me.`} -

    -
    - - {t`By uploading this image, I hereby publish it in the public domain as renounce copyright protection.`} -   - - ({t`CC0 1.0 Universal license`}) - - -
  • -
    - -
  • - - -

    - {t`Doesn't show identifiable persons.`} -

    -
    -
  • -
    -
    -
    + + + {t`By uploading this image, I hereby publish it in the public domain as renounce copyright protection.`} +   ( + + + + {t`CC0 1.0 Universal license`} + + + + + + ) + + {t`This link opens in a new window.`} + + + + + +
  • + + +

    + {t`Doesn't show identifiable persons.`} +

    +
    +
  • +
    + + + + + + + + ); }; diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx new file mode 100644 index 000000000..8ec812d94 --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx @@ -0,0 +1,83 @@ +import { + AspectRatio, + Box, + Button, + Card, + Flex, + Grid, + Inset, + Strong, + Text, + TextArea, + Tooltip, + VisuallyHidden, +} from "@radix-ui/themes"; +import { useRouter } from "next/router"; +import React, { type FC, useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import styled from "styled-components"; +import { t } from "ttag"; + +const Dropzone = styled.div<{ $isDragActive?: boolean }>` + padding: 3rem 4rem; + border-style: dashed; + border-width: 2px; + border-color: var(--accent-9); + border-radius: var(--radius-4); + background: ${(props) => (props.$isDragActive ? "var(--accent-3)" : "var(--gray-3)")}; + transition: all 100ms ease; + text-align: center; + + &:hover { + border-color: var(--accent-11); + } + + &:after { + border: none; + outline: none; + } +`; + +export type ImageWithPreview = File & { + preview: URL; +}; + +export const ImageUploadDropzone: FC<{ + setImage: (image: ImageWithPreview) => void; +}> = ({ setImage }) => { + const router = useRouter(); + + const onDrop = useCallback(([file]) => { + setImage( + Object.assign(file, { + preview: URL.createObjectURL(file), + }), + ); + }, []); + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + maxFiles: 1, + accept: { + "image/*": [], + }, + }); + + return ( + <> + + + + {t`Drag an image here to select it`} + + {t`or click the select button.`} + + + + + + + + ); +}; diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx new file mode 100644 index 000000000..b89685835 --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx @@ -0,0 +1,93 @@ +import { + AspectRatio, + Box, + Button, + Card, + Flex, + Inset, + Progress, + Text, + Tooltip, +} from "@radix-ui/themes"; +import React, { type FC, useState } from "react"; +import styled from "styled-components"; +import { t } from "ttag"; +import type { ImageWithPreview } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone"; + +const ImagePreview = styled.div` + position: relative; + + .image-upload-review__preview-image { + width: 100%; + height: 100%; + object-fit: cover; + } + + .image-upload-review__overlay { + position: absolute; + inset: 0; + z-index: 1; + background: rgba(0,0,0,0.7); + display: flex; + justify-content: center; + align-items: center; + padding: 4rem; + } +`; + +export const ImageUploadPreview: FC<{ + image: ImageWithPreview; + setImage: (image?: ImageWithPreview) => void; +}> = ({ image, setImage }) => { + const [isUploading, setIsUploading] = useState(false); + const cleanUp = () => { + URL.revokeObjectURL(image.preview); + }; + const reset = () => { + setImage(undefined); + }; + const upload = () => { + setIsUploading(true); + }; + + return ( + <> + {t`Please review your selected image:`} + + {isUploading && ( + + + + )} + + + + {image && ( + {t`Preview + )} + + + + + + + + + + ); +}; diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress.tsx new file mode 100644 index 000000000..159595dca --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress.tsx @@ -0,0 +1,28 @@ +import { Box, Grid, Progress, Text } from "@radix-ui/themes"; +import React, { type FC } from "react"; +import { t } from "ttag"; +import { ImageUploadProgressItem } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgressItem"; + +export const ImageUploadProgress: FC<{ uploadStep: number }> = ({ + uploadStep, +}) => { + return ( + + + + {t`1. Criteria`} + + + {t`2. Select`} + + + {t`3. Review`} + + + {t`4. Done`} + + + + + ); +}; diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgressItem.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgressItem.tsx new file mode 100644 index 000000000..663685ada --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgressItem.tsx @@ -0,0 +1,13 @@ +import { Box, Text } from "@radix-ui/themes"; +import React, { type FC, type ReactNode } from "react"; + +export const ImageUploadProgressItem: FC<{ + children: ReactNode; + active?: boolean; +}> = ({ children, active }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx b/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx index 9f768ccab..b5eec03fa 100644 --- a/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx +++ b/src/components/CombinedFeaturePanel/type-specific/poi/PlaceOfInterestDetails.tsx @@ -29,12 +29,14 @@ type Props = { feature: AnyFeature; activeImageId?: string; isUploadDialogOpen?: boolean; + uploadStep?: string; }; export default function PlaceOfInterestDetails({ feature, activeImageId, isUploadDialogOpen, + uploadStep, }: Props) { const { baseFeatureUrl } = useContext(FeaturePanelContext); const map = useMap(); @@ -88,6 +90,7 @@ export default function PlaceOfInterestDetails({ diff --git a/src/pages/[placeType]/[id]/images/upload.tsx b/src/pages/[placeType]/[id]/images/upload.tsx index 56c16f03f..df9338256 100644 --- a/src/pages/[placeType]/[id]/images/upload.tsx +++ b/src/pages/[placeType]/[id]/images/upload.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { useContext, useMemo } from "react"; import { CombinedFeaturePanel } from "~/components/CombinedFeaturePanel/CombinedFeaturePanel"; import { FeaturePanelContext } from "~/components/CombinedFeaturePanel/FeaturePanelContext"; @@ -13,8 +14,15 @@ export default function ShowImageUploadPage() { [features], ); + const { + query: { step }, + } = useRouter(); return ( - + ); } From df5700bfba7570d75f3e4eb2411a20bfbc1c2243 Mon Sep 17 00:00:00 2001 From: Kris Siepert Date: Wed, 18 Dec 2024 15:31:47 +0100 Subject: [PATCH 03/12] feat: add review step and implement actual upload --- .../CombinedFeaturePanel.tsx | 1 - .../components/FeatureImageUpload.tsx | 159 +++++++++++------- .../ImageUpload/ImageUploadCriteriaList.tsx | 23 +-- .../ImageUpload/ImageUploadDropzone.tsx | 42 ++--- .../ImageUpload/ImageUploadPreview.tsx | 66 ++++++-- .../poi/PlaceOfInterestDetails.tsx | 1 - src/pages/[placeType]/[id]/images/upload.tsx | 9 +- 7 files changed, 173 insertions(+), 128 deletions(-) diff --git a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx index 9b6069bed..dc5a8eb9f 100644 --- a/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx +++ b/src/components/CombinedFeaturePanel/CombinedFeaturePanel.tsx @@ -76,7 +76,6 @@ export function CombinedFeaturePanel(props: Props) { feature={features[0]} activeImageId={props.activeImageId} isUploadDialogOpen={props.isUploadDialogOpen} - uploadStep={props.uploadStep} /> )} {surroundings?.map((feature) => ( diff --git a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx index 2b8eecbea..ce8d7cd31 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx @@ -2,8 +2,10 @@ import { CameraIcon } from "@radix-ui/react-icons"; import { Button, Dialog, Flex } from "@radix-ui/themes"; import { useRouter } from "next/router"; import React, { + createContext, type FC, type MouseEventHandler, + useCallback, useContext, useEffect, useMemo, @@ -22,106 +24,135 @@ import { ImageUploadPreview } from "~/components/CombinedFeaturePanel/components import { ImageUploadProgress } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress"; import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; +type ImageUploadType = { + baseUploadUrl: string; + step: number; + nextStep: () => void; + previousStep: () => void; + image?: ImageWithPreview; + setImage: (image?: ImageWithPreview) => void; + open: () => void; + close: () => void; +}; +export const ImageUploadContext = createContext({ + baseUploadUrl: "", + step: 1, + nextStep() {}, + previousStep() {}, + image: undefined, + setImage(image) {}, + open() {}, + close() {}, +}); + export const FeatureImageUpload: FC<{ feature: AnyFeature; isUploadDialogOpen?: boolean; - uploadStep?: string; -}> = ({ feature, isUploadDialogOpen, uploadStep: unsanitizedUploadStep }) => { +}> = ({ feature, isUploadDialogOpen }) => { const { baseFeatureUrl } = useContext(FeaturePanelContext); const router = useRouter(); - const [uploadStep, setUploadStep] = useState(0); + const [step, setStep] = useState(1); + const [image, setImage] = useState(); const [isDialogOpen, setIsDialogOpen] = useState( Boolean(isUploadDialogOpen), ); - const [image, setImage] = useState(); - const uploadUrl = useMemo(() => { + const baseUploadUrl = useMemo(() => { return `${baseFeatureUrl}/images/upload`; }, [baseFeatureUrl]); - const open = () => { + const open = useCallback(() => { setIsDialogOpen(true); - }; - const close = () => { + }, []); + const close = useCallback(() => { setIsDialogOpen(false); + }, []); + const nextStep = useCallback(() => { + if (step < 4) setStep(step + 1); + }, [step]); + const previousStep = useCallback(() => { + if (step > 1) setStep(step - 1); + }, [step]); + + const handleOnClickAddImageButton: MouseEventHandler = useCallback( + (event) => { + event.preventDefault(); + open(); + }, + [], + ); + + const api: ImageUploadType = { + step, + baseUploadUrl, + nextStep, + previousStep, + image, + setImage, + open, + close, }; - const handleOnClickAddImageButton: MouseEventHandler = (event) => { + const confirmWindowUnload = useCallback((event: Event) => { event.preventDefault(); - open(); - }; + }, []); - // Set the initial upload step from the url query parameter. We don't - // want to navigate to steps 3 or 4 via query parameter and only with - // user interaction with the forms, that's why it is sanitized here useEffect(() => { - const uploadStep = Number.parseInt(unsanitizedUploadStep ?? "1"); - setUploadStep( - !uploadStep || uploadStep > 2 || uploadStep < 0 ? 1 : uploadStep, - ); - }, [unsanitizedUploadStep]); + if (image) { + window.addEventListener("beforeunload", confirmWindowUnload); + } else { + window.removeEventListener("beforeunload", confirmWindowUnload); + } + return () => { + window.removeEventListener("beforeunload", confirmWindowUnload); + }; + }, [image, confirmWindowUnload]); // Reset the url when closing the dialog // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (!isDialogOpen && window.location.pathname !== baseFeatureUrl) { - router.push(baseFeatureUrl); + router.push(baseFeatureUrl, undefined, { shallow: true }); } }, [isDialogOpen]); // Set the url when opening the dialog // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - if (isDialogOpen && window.location.pathname !== uploadUrl) { - router.push(uploadUrl, undefined, { shallow: true }); + if (isDialogOpen && window.location.pathname !== baseUploadUrl) { + router.push(baseUploadUrl, undefined, { shallow: true }); } }, [isDialogOpen]); - // Continue to step 3 when the image gets selected - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - if (isDialogOpen && image && uploadStep === 2) { - setUploadStep(3); - } - }, [image]); - - // Continue to step 2 when the image gets reset - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - if (isDialogOpen && !image && uploadStep === 3) { - setUploadStep(2); - } - }, [image]); - return ( - - - - - - - - - {t`Add a new image`} + + + + + + + + + + {t`Add a new image`} - - - + + + - {uploadStep === 1 && } - {uploadStep === 2 && } - {uploadStep === 3 && ( - - )} - - + {step === 1 && } + {step === 2 && } + {step === 3 && } + + + ); }; diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx index e39246b08..f2575e992 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx @@ -9,10 +9,10 @@ import { Tooltip, VisuallyHidden, } from "@radix-ui/themes"; -import React, { type FC } from "react"; +import React, { type FC, useContext } from "react"; import styled from "styled-components"; import { t } from "ttag"; -import { AppStateLink } from "~/components/App/AppStateLink"; +import { ImageUploadContext } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; const ImageCriteriaList = styled.ul` list-style: none; @@ -46,9 +46,9 @@ const ImageCriteriaList = styled.ul` } `; -export const ImageUploadCriteriaList: FC<{ uploadUrl: string }> = ({ - uploadUrl, -}) => { +export const ImageUploadCriteriaList: FC = () => { + const { nextStep, close } = useContext(ImageUploadContext); + return ( <> @@ -150,17 +150,8 @@ export const ImageUploadCriteriaList: FC<{ uploadUrl: string }> = ({ - diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx index 8ec812d94..d5ffe12fa 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx @@ -1,22 +1,9 @@ -import { - AspectRatio, - Box, - Button, - Card, - Flex, - Grid, - Inset, - Strong, - Text, - TextArea, - Tooltip, - VisuallyHidden, -} from "@radix-ui/themes"; -import { useRouter } from "next/router"; -import React, { type FC, useCallback, useState } from "react"; +import { Box, Button, Strong, Text } from "@radix-ui/themes"; +import React, { type FC, useCallback, useContext, useState } from "react"; import { useDropzone } from "react-dropzone"; import styled from "styled-components"; import { t } from "ttag"; +import { ImageUploadContext } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; const Dropzone = styled.div<{ $isDragActive?: boolean }>` padding: 3rem 4rem; @@ -42,18 +29,19 @@ export type ImageWithPreview = File & { preview: URL; }; -export const ImageUploadDropzone: FC<{ - setImage: (image: ImageWithPreview) => void; -}> = ({ setImage }) => { - const router = useRouter(); +export const ImageUploadDropzone: FC = () => { + const { setImage, nextStep, previousStep } = useContext(ImageUploadContext); - const onDrop = useCallback(([file]) => { - setImage( - Object.assign(file, { + const onDrop = useCallback( + ([file]) => { + const image = Object.assign(file, { preview: URL.createObjectURL(file), - }), - ); - }, []); + }); + setImage(image); + nextStep(); + }, + [setImage, nextStep], + ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, maxFiles: 1, @@ -74,7 +62,7 @@ export const ImageUploadDropzone: FC<{ - diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx index b89685835..bf37c06df 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx @@ -1,3 +1,4 @@ +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { AspectRatio, Box, @@ -5,14 +6,20 @@ import { Card, Flex, Inset, - Progress, + Spinner, + Strong, Text, - Tooltip, } from "@radix-ui/themes"; -import React, { type FC, useState } from "react"; +import React, { type FC, useContext, useState } from "react"; import styled from "styled-components"; import { t } from "ttag"; -import type { ImageWithPreview } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone"; +import { ImageUploadContext } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; +import { useCurrentAppToken } from "~/lib/context/AppContext"; +import uploadPhotoForFeature from "~/lib/fetchers/ac/refactor-this/postImageUpload"; +import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; + +const uncachedUrl = + process.env.NEXT_PUBLIC_ACCESSIBILITY_CLOUD_UNCACHED_BASE_URL || ""; const ImagePreview = styled.div` position: relative; @@ -28,26 +35,48 @@ const ImagePreview = styled.div` inset: 0; z-index: 1; background: rgba(0,0,0,0.7); + backdrop-filter: blur(6px); display: flex; justify-content: center; align-items: center; padding: 4rem; + flex-direction: column; + gap: .5rem; } `; export const ImageUploadPreview: FC<{ - image: ImageWithPreview; - setImage: (image?: ImageWithPreview) => void; -}> = ({ image, setImage }) => { - const [isUploading, setIsUploading] = useState(false); + feature: AnyFeature; +}> = ({ feature }) => { + const appToken = useCurrentAppToken(); + const { image, setImage, previousStep, nextStep } = + useContext(ImageUploadContext); + + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(); + const cleanUp = () => { URL.revokeObjectURL(image.preview); }; const reset = () => { setImage(undefined); + previousStep(); }; - const upload = () => { + const upload = async () => { setIsUploading(true); + try { + await uploadPhotoForFeature( + feature, + [image] as unknown as FileList, + appToken, + uncachedUrl, + ); + nextStep(); + } catch (error) { + setError(error); + } finally { + setIsUploading(false); + } }; return ( @@ -56,7 +85,22 @@ export const ImageUploadPreview: FC<{ {isUploading && ( - + + + {t`Uploading image, please wait...`} + + + )} + {error && ( + + + + {t`There was an error uploading your image!`} + + {t`Please try again later.`} + + {error.toString()} + )} @@ -65,7 +109,7 @@ export const ImageUploadPreview: FC<{ {image && ( {t`Preview diff --git a/src/pages/[placeType]/[id]/images/upload.tsx b/src/pages/[placeType]/[id]/images/upload.tsx index df9338256..5f67b90d5 100644 --- a/src/pages/[placeType]/[id]/images/upload.tsx +++ b/src/pages/[placeType]/[id]/images/upload.tsx @@ -14,15 +14,8 @@ export default function ShowImageUploadPage() { [features], ); - const { - query: { step }, - } = useRouter(); return ( - + ); } From cbfa5f14327eaa977fc2b8b36b36320e83a08268 Mon Sep 17 00:00:00 2001 From: Kris Siepert Date: Wed, 18 Dec 2024 15:40:14 +0100 Subject: [PATCH 04/12] feat: add success screen --- .../components/FeatureImageUpload.tsx | 2 ++ .../ImageUpload/ImageUploadSuccess.tsx | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadSuccess.tsx diff --git a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx index ce8d7cd31..7763a12c4 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx @@ -22,6 +22,7 @@ import { } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone"; import { ImageUploadPreview } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview"; import { ImageUploadProgress } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadProgress"; +import { ImageUploadSuccess } from "~/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadSuccess"; import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; type ImageUploadType = { @@ -151,6 +152,7 @@ export const FeatureImageUpload: FC<{ {step === 1 && } {step === 2 && } {step === 3 && } + {step === 4 && }
    diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadSuccess.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadSuccess.tsx new file mode 100644 index 000000000..e1da12c90 --- /dev/null +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadSuccess.tsx @@ -0,0 +1,28 @@ +import { CheckIcon } from "@radix-ui/react-icons"; +import { Button, Flex, Strong, Text } from "@radix-ui/themes"; +import React, { type FC, useContext } from "react"; +import { t } from "ttag"; +import { ImageUploadContext } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; + +export const ImageUploadSuccess: FC = () => { + const { close } = useContext(ImageUploadContext); + + return ( + <> + + + + {t`Image uploaded successfully!`} + + + {t`Thank you for your contribution. The image will be checked by our staff before it will be visible on Wheelmap. This can take a while, please be patient.`} + + + + + + + ); +}; From cf44c27fa02f911758676e5b90a536b41486fdda Mon Sep 17 00:00:00 2001 From: Kris Siepert Date: Wed, 18 Dec 2024 16:06:58 +0100 Subject: [PATCH 05/12] feat: improve accessibility of image upload --- .../components/FeatureImageUpload.tsx | 7 +- .../ImageUpload/ImageUploadCriteriaList.tsx | 115 +++++++++--------- .../ImageUpload/ImageUploadDropzone.tsx | 61 +++++++--- .../ImageUpload/ImageUploadPreview.tsx | 19 ++- .../ImageUpload/ImageUploadProgress.tsx | 2 +- .../ImageUpload/ImageUploadProgressItem.tsx | 7 +- .../ImageUpload/ImageUploadSuccess.tsx | 6 +- 7 files changed, 122 insertions(+), 95 deletions(-) diff --git a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx index 7763a12c4..190180f71 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureImageUpload.tsx @@ -143,7 +143,12 @@ export const FeatureImageUpload: FC<{ - {t`Add a new image`} + + {step === 1 && t`Add a new image`} + {step === 2 && t`Select an image`} + {step === 3 && t`Review your selected image`} + {step === 4 && t`Upload successful`} + diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx index f2575e992..88fb3bbc8 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadCriteriaList.tsx @@ -59,74 +59,69 @@ export const ImageUploadCriteriaList: FC = () => {
  • - - - -

    - {t`It contains useful information on accessibility.`} -

    - - {t`For example by showing entrances, toilets or a map of the site.`} - - -
    - A pictogram of an entrance - -
    {t`Entrance`}
    -
    -
    -
    - A pictogram of a toilet - -
    {t`Toilet`}
    -
    -
    -
    - A topdown view showing navigational information - -
    {t`Site map`}
    -
    -
    -
    -
    + + +

    + {t`It contains useful information on accessibility.`} +

    + + + {t`For example by showing entrances, toilets or a map of the site.`} + + +
    + A pictogram of an entrance + +
    {t`Entrance`}
    +
    +
    +
    + A pictogram of a toilet + +
    {t`Toilet`}
    +
    +
    +
    + A topdown view showing navigational information + +
    {t`Site map`}
    +
    +
    +
    +
  • - +

    - {t`Was taken by me.`} + {t`It was taken by me.`}

    - {t`By uploading this image, I hereby publish it in the public domain as renounce copyright protection.`} -   ( - - - - {t`CC0 1.0 Universal license`} - - - - - - ) + {t`By uploading this image, I hereby publish it in the public domain as renounce copyright protection: `} + + + {t`CC0 1.0 Universal license`} + + + {t`This link opens in a new window.`} @@ -136,9 +131,9 @@ export const ImageUploadCriteriaList: FC = () => {
  • - +

    - {t`Doesn't show identifiable persons.`} + {t`It doesn't show identifiable persons.`}

  • diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx index d5ffe12fa..4f7b2b02f 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadDropzone.tsx @@ -6,23 +6,37 @@ import { t } from "ttag"; import { ImageUploadContext } from "~/components/CombinedFeaturePanel/components/FeatureImageUpload"; const Dropzone = styled.div<{ $isDragActive?: boolean }>` - padding: 3rem 4rem; - border-style: dashed; - border-width: 2px; - border-color: var(--accent-9); - border-radius: var(--radius-4); - background: ${(props) => (props.$isDragActive ? "var(--accent-3)" : "var(--gray-3)")}; - transition: all 100ms ease; - text-align: center; - - &:hover { - border-color: var(--accent-11); - } - - &:after { - border: none; - outline: none; - } + padding: 3rem 4rem; + border-style: dashed; + border-width: 2px; + border-color: var(--accent-9); + border-radius: var(--radius-4); + background: ${(props) => (props.$isDragActive ? "var(--accent-3)" : "var(--gray-3)")}; + transition: all 100ms ease; + text-align: center; + + &:hover { + border-color: var(--accent-11); + } + + &:after { + border: none; + outline: none; + } + + .image-upload-dropzone__text--on-desktop { + @media (hover: none) { + display: none; + } + } + + .image-upload-dropzone__text--on-mobile { + display: none; + + @media (hover: none) { + display: block; + } + } `; export type ImageWithPreview = File & { @@ -54,10 +68,19 @@ export const ImageUploadDropzone: FC = () => { <> - + {t`Drag an image here to select it`} - {t`or click the select button.`} + + {t`or click the select button.`} + + + {t`Use the following button to select an image:`} + diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx index bf37c06df..d31af9519 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx @@ -22,19 +22,19 @@ const uncachedUrl = process.env.NEXT_PUBLIC_ACCESSIBILITY_CLOUD_UNCACHED_BASE_URL || ""; const ImagePreview = styled.div` - position: relative; - - .image-upload-review__preview-image { - width: 100%; - height: 100%; - object-fit: cover; - } + position: relative; + + .image-upload-review__preview-image { + width: 100%; + height: 100%; + object-fit: cover; + } .image-upload-review__overlay { position: absolute; inset: 0; z-index: 1; - background: rgba(0,0,0,0.7); + background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(6px); display: flex; justify-content: center; @@ -81,7 +81,6 @@ export const ImageUploadPreview: FC<{ return ( <> - {t`Please review your selected image:`} {isUploading && ( @@ -119,7 +118,7 @@ export const ImageUploadPreview: FC<{
    - + diff --git a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx index d31af9519..066f294be 100644 --- a/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx +++ b/src/components/CombinedFeaturePanel/components/ImageUpload/ImageUploadPreview.tsx @@ -21,16 +21,15 @@ import type { AnyFeature } from "~/lib/model/geo/AnyFeature"; const uncachedUrl = process.env.NEXT_PUBLIC_ACCESSIBILITY_CLOUD_UNCACHED_BASE_URL || ""; -const ImagePreview = styled.div` +const PreviewWrapper = styled.div` position: relative; - - .image-upload-review__preview-image { +`; +const PreviewImage = styled.img` width: 100%; height: 100%; - object-fit: cover; - } - - .image-upload-review__overlay { + object-fit: contain; + `; +const PreviewOverlay = styled(Box)` position: absolute; inset: 0; z-index: 1; @@ -42,7 +41,6 @@ const ImagePreview = styled.div` padding: 4rem; flex-direction: column; gap: .5rem; - } `; export const ImageUploadPreview: FC<{ @@ -81,17 +79,17 @@ export const ImageUploadPreview: FC<{ return ( <> - + {isUploading && ( - + {t`Uploading image, please wait...`} - + )} {error && ( - + {t`There was an error uploading your image!`} @@ -100,13 +98,13 @@ export const ImageUploadPreview: FC<{ {error.toString()} - + )} {image && ( - - +