From 001b55c59f0a84949935953e470a7038fb6bbcd2 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:41:47 +0100 Subject: [PATCH 01/27] Set Biome to use Spaces --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 2c98176a1..ea23b17cd 100644 --- a/biome.json +++ b/biome.json @@ -14,7 +14,7 @@ }, "formatter": { "enabled": true, - "indentStyle": "tab" + "indentStyle": "space" }, "organizeImports": { "enabled": true From b03dd89cf859aa5703bcd77f15f0f7083c498f33 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:46:09 +0100 Subject: [PATCH 02/27] Remove obsolete component --- .../NodeToolbar/FeatureClusterPanel.tsx | 207 ------------------ 1 file changed, 207 deletions(-) delete mode 100644 src/components/NodeToolbar/FeatureClusterPanel.tsx diff --git a/src/components/NodeToolbar/FeatureClusterPanel.tsx b/src/components/NodeToolbar/FeatureClusterPanel.tsx deleted file mode 100644 index 372f6872b..000000000 --- a/src/components/NodeToolbar/FeatureClusterPanel.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import sortBy from 'lodash/sortBy' -import * as React from 'react' -import styled from 'styled-components' -import { t } from 'ttag' -import { EquipmentInfo, PlaceInfo } from '@sozialhelden/a11yjson' -import { getFeatureId } from '../../lib/model/ac/Feature' -import Categories, { CategoryLookupTables } from '../../lib/model/ac/categories/Categories' -import { placeNameFor } from '../../lib/model/geo/placeNameFor' -import colors from '../../lib/util/colors' -import { Cluster } from '../Map/Cluster' -import StyledToolbar from './StyledToolbar' -import * as markers from '../icons/markers' -import ErrorBoundary from '../shared/ErrorBoundary' -import { StyledIconContainer } from '../shared/Icon' -import { Circle } from '../shared/IconButton' -import { PlaceNameH1 } from '../shared/PlaceName' -import StyledFrame from './AccessibilitySection/StyledFrame' -import NodeHeader, { StyledNodeHeader } from './NodeHeader' -// import { accessibilityCloudFeatureCache } from '../../lib/cache/AccessibilityCloudFeatureCache'; - -type Props = { - hidden?: boolean, - inEmbedMode?: boolean, - modalNodeState?: boolean, - cluster: Cluster | null, - categories: CategoryLookupTables, - onClose: () => void, - onSelectClusterIcon: () => void, - onFeatureSelected: (feature: PlaceInfo | EquipmentInfo) => void, - className?: string, - minimalTopPosition: number, -}; - -const ClusterIcon = function ({ cluster, className, onSelectClusterIcon }: Partial) { - const accessibility = cluster.accessibility || 'unknown' - const MarkerComponent = markers[`${accessibility}WithoutArrow`] || Circle - - return ( - - -
{cluster.features.length}
-
- ) -} - -export const StyledClusterIcon = styled(ClusterIcon)` - margin-block-start: 0; - margin-block-end: 0; - display: flex; - cursor: pointer; - - > div { - color: ${(props) => (props.cluster && props.cluster.foregroundColor) || 'white'}; - font-size: 24px; - z-index: 300; - } - - svg { - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - } - - svg g, - svg polygon, - svg path, - svg circle, - svg rect { - fill: ${(props) => (props.cluster && props.cluster.backgroundColor) || colors.tonedDownSelectedColor}; - } -` - -class UnstyledFeatureClusterPanel extends React.Component { - renderCloseLink() { - const { onClose } = this.props - return - } - - renderClusterEntry(feature: PlaceInfo | EquipmentInfo, allFeatures: (PlaceInfo | EquipmentInfo)[]) { - const { category, parentCategory } = Categories.getCategoriesForFeature( - this.props.categories, - feature, - ) - - const isEquipment = ['elevator', 'escalator'].includes(category._id) - const equipmentInfo = isEquipment ? (feature as EquipmentInfo) : undefined - const equipmentInfoId = equipmentInfo?._id || equipmentInfo?.properties._id - // const parentPlaceInfo = equipmentInfo && accessibilityCloudFeatureCache.getCachedFeature(equipmentInfo.properties.placeInfoId); - return ( - - ) - } - - renderClusterEntries(features: ArrayLike) { - const sortedFeatures = sortBy(features, (feature) => { - if (!feature.properties) { - return getFeatureId(feature) - } - const { category, parentCategory } = Categories.getCategoriesForFeature( - this.props.categories, - feature, - ) - - // TODO comment that this should be typed - return placeNameFor(feature.properties as any, category || parentCategory) - }) - - return sortedFeatures.map((feature) => ( -
  • {this.renderClusterEntry(feature, sortedFeatures)}
  • - )) - } - - render() { - const { cluster } = this.props - - if (!cluster || cluster.features.length === 0) { - return null - } - - // translator: Label caption of a cluster that contains multiple nodes with its count, e.g. '(3) Places' - const placesLabel = t`Places` - - return ( - - ) - } -} - -const FeatureClusterPanel = styled(UnstyledFeatureClusterPanel)` - section.cluster-entries { - > .styled-frame { - z-index: 0; - padding: 0; - > ul { - list-style: none; - margin: 0; - padding: 0; - - > li { - &:not(:first-child) { - border-top: 1px solid ${colors.borderColor}; - } - - > button { - border: unset; - background: unset; - padding: 0; - width: 100%; - cursor: pointer; - font-size: unset; - text-align: unset; - - > header { - /* prevent clipping borders of styled frame */ - background: unset; - padding-right: 1rem; - - &:hover, - &:focus { - color: ${colors.linkColorDarker}; - background-color: ${colors.linkBackgroundColorTransparent}; - } - } - } - } - } - } - } - -}` - -export default FeatureClusterPanel From 63a455601da73e6893712746b6176baae336e82b Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:47:16 +0100 Subject: [PATCH 03/27] Fix MapboxGL buttons in dark mode Now using the correct CSS selector to display dark mode content because dark mode can be adapted by the user. --- src/components/Map/mapbox-dark-mode.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Map/mapbox-dark-mode.css b/src/components/Map/mapbox-dark-mode.css index 46a2212e7..3d9b4f41a 100644 --- a/src/components/Map/mapbox-dark-mode.css +++ b/src/components/Map/mapbox-dark-mode.css @@ -1,6 +1,6 @@ /* This adapts MapboxGL controls to dark mode when enabled. */ -@media (prefers-color-scheme: dark) { +html.dark { .mapboxgl-ctrl-bottom-left, .mapboxgl-ctrl-bottom-right, .mapboxgl-ctrl-top-left, .mapboxgl-ctrl-top-right { z-index: auto !important; } From 85e4d17ec2dff6e33c794e7878c8f2f509ebedb9 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:47:43 +0100 Subject: [PATCH 04/27] Introduce more usages --- src/components/App/MainMenu/AppLinks.tsx | 9 +++++---- src/components/App/MainMenu/MainMenu.tsx | 5 +++-- src/components/CombinedFeaturePanel/PlaceLayout.tsx | 8 ++++---- .../components/AccessibilitySection/EditButton.tsx | 9 ++++----- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/components/App/MainMenu/AppLinks.tsx b/src/components/App/MainMenu/AppLinks.tsx index 559433070..cc581c7d5 100644 --- a/src/components/App/MainMenu/AppLinks.tsx +++ b/src/components/App/MainMenu/AppLinks.tsx @@ -9,6 +9,7 @@ import { translatedStringFromObject } from '../../../lib/i18n/translatedStringFr import { insertPlaceholdersToAddPlaceUrl } from '../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl' import Spinner from '../../ActivityIndicator/Spinner' import SessionLink from '../../Session/SessionLink' +import { AppStateLink } from '../AppStateLink' const Badge = styled.span` border-radius: 0.5rlh; @@ -32,9 +33,9 @@ function JoinedEventLink(props: { label: string | null; url: string | null }) { const label = joinedMappingEvent ? joinedMappingEvent.name : props.label return ( - + {label} - + ) } @@ -106,10 +107,10 @@ export default function AppLinks(props: {}) { if (typeof url === 'string') { return ( - + {label} {badgeLabel && {badgeLabel}} - + ) } diff --git a/src/components/App/MainMenu/MainMenu.tsx b/src/components/App/MainMenu/MainMenu.tsx index 28061ee0d..23cfcb279 100644 --- a/src/components/App/MainMenu/MainMenu.tsx +++ b/src/components/App/MainMenu/MainMenu.tsx @@ -11,6 +11,7 @@ import CloseIcon from "../../icons/actions/Close"; import VectorImage from "../../shared/VectorImage"; import AppLinks from "./AppLinks"; import { Button, Card } from "@radix-ui/themes"; +import { AppStateLink } from "../AppStateLink"; type Props = { onToggle: (isMainMenuOpen: boolean) => void; @@ -240,7 +241,7 @@ export default function MainMenu(props: Props) { const homeLink = (
    - + - +
    ); diff --git a/src/components/CombinedFeaturePanel/PlaceLayout.tsx b/src/components/CombinedFeaturePanel/PlaceLayout.tsx index 3d86864c4..1edda1849 100644 --- a/src/components/CombinedFeaturePanel/PlaceLayout.tsx +++ b/src/components/CombinedFeaturePanel/PlaceLayout.tsx @@ -6,9 +6,9 @@ import { FeaturePanelContextProvider } from './FeaturePanelContext' import ErrorBoundary from '../shared/ErrorBoundary' import { useAppStateAwareRouter } from '../../lib/util/useAppStateAwareRouter' import { getLayout as getMapLayout } from '../App/MapLayout' -import Link from 'next/link' -import { Button, IconButton } from '@radix-ui/themes' +import { IconButton } from '@radix-ui/themes' import { Cross1Icon } from '@radix-ui/react-icons' +import { AppStateLink } from '../App/AppStateLink' const PositionedCloseLink = styled(({ to }: { to?: Url }) => { const { push } = useAppStateAwareRouter() @@ -30,11 +30,11 @@ export default function PlaceLayout({ }) { return ( - + - + {children} diff --git a/src/components/CombinedFeaturePanel/components/AccessibilitySection/EditButton.tsx b/src/components/CombinedFeaturePanel/components/AccessibilitySection/EditButton.tsx index c5e72178c..b5335a4f5 100644 --- a/src/components/CombinedFeaturePanel/components/AccessibilitySection/EditButton.tsx +++ b/src/components/CombinedFeaturePanel/components/AccessibilitySection/EditButton.tsx @@ -1,8 +1,7 @@ -import React from 'react' -import { t } from 'ttag' -import { AppStateLink } from '../../../App/AppStateLink' -import { Button, IconButton } from '@radix-ui/themes'; -import { MagnifyingGlassIcon, Pencil1Icon, Pencil2Icon } from '@radix-ui/react-icons'; +import { Pencil1Icon } from '@radix-ui/react-icons'; +import { IconButton } from '@radix-ui/themes'; +import { t } from 'ttag'; +import { AppStateLink } from '../../../App/AppStateLink'; export function EditButton({ editURL }: { editURL: string; }) { return ( From 5778a7c2a507ca2490634c5c211d7b2bfd9cfbe3 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:47:57 +0100 Subject: [PATCH 05/27] Remove obsolete code from --- .../components/FeatureNameHeader.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/components/CombinedFeaturePanel/components/FeatureNameHeader.tsx b/src/components/CombinedFeaturePanel/components/FeatureNameHeader.tsx index 20bdc2e60..e971039c5 100644 --- a/src/components/CombinedFeaturePanel/components/FeatureNameHeader.tsx +++ b/src/components/CombinedFeaturePanel/components/FeatureNameHeader.tsx @@ -31,22 +31,6 @@ type Props = { size?: 'small' | 'medium' | 'big'; } -const StyledHeader = styled.header` - line-height: 1; - display: flex; - gap: 1rem; - align-items: center; - position: sticky; - top: 0; - z-index: 1; - width: 100%; - padding: 0 0 10px 0; - - ${PlaceNameH1} { - flex-grow: 2; - } -` - export default function FeatureNameHeader(props: Props) { const { feature, children, onClickCurrentMarkerIcon, onHeaderClicked, From 30e6a0900bb324b004c5c4a232ad208a463ec0b8 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:48:13 +0100 Subject: [PATCH 06/27] Fix screenreader hint in --- src/components/SearchPanel/SearchPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SearchPanel/SearchPanel.tsx b/src/components/SearchPanel/SearchPanel.tsx index 065db292a..9d95ecfdf 100644 --- a/src/components/SearchPanel/SearchPanel.tsx +++ b/src/components/SearchPanel/SearchPanel.tsx @@ -128,9 +128,9 @@ export default function SearchPanel({ if (!searchResults && isSearching) { contentBelowSearchField = (
    - {/* + {t`Searching`} - */} +
    ) From a1fe412e161ad5102311f2bf28a6ff5ab41ee3b4 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 10 Dec 2024 14:48:25 +0100 Subject: [PATCH 07/27] Smaller Biome/typing fixes in --- src/components/SearchPanel/SearchPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/SearchPanel/SearchPanel.tsx b/src/components/SearchPanel/SearchPanel.tsx index 9d95ecfdf..52e90722e 100644 --- a/src/components/SearchPanel/SearchPanel.tsx +++ b/src/components/SearchPanel/SearchPanel.tsx @@ -12,12 +12,12 @@ import { } from '../../lib/model/ac/filterAccessibility' import Spinner from '../ActivityIndicator/Spinner' import ErrorBoundary from '../shared/ErrorBoundary' -import { PlaceFilter } from './AccessibilityFilterModel' +import type { PlaceFilter } from './AccessibilityFilterModel' import { StyledToolbar } from './StyledToolbar' import { useAppStateAwareRouter } from '../../lib/util/useAppStateAwareRouter' import { cx } from '../../lib/util/cx' import { useMapOverlapRef } from '../Map/GlobalMapContext' -import { EnrichedSearchResult } from './EnrichedSearchResult' +import type { EnrichedSearchResult } from './EnrichedSearchResult' import { IconButton, VisuallyHidden } from '@radix-ui/themes' import { Cross1Icon } from '@radix-ui/react-icons' @@ -30,7 +30,7 @@ export type Props = PlaceFilter & { searchQuery?: null | string; onChangeSearchQuery?: (newSearchQuery: string) => void; onSubmit?: (searchQuery: string) => void; - onClose?: () => void | null; + onClose?: () => void; onClick?: () => void; isExpanded?: boolean; searchResults?: null | EnrichedSearchResult[]; @@ -114,7 +114,7 @@ export default function SearchPanel({ const closeLink = ( { clearSearchAndFocusSearchField() if (onClose) onClose() From d82a3b82b80c0c62c35915d704581b9bd3c9d585 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 11:29:24 +0100 Subject: [PATCH 08/27] Don't open automatically on start --- src/components/App/MapLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/App/MapLayout.tsx b/src/components/App/MapLayout.tsx index e4e36e77a..1375b2814 100644 --- a/src/components/App/MapLayout.tsx +++ b/src/components/App/MapLayout.tsx @@ -67,7 +67,7 @@ export default function MapLayout({ return ( - + From 1f5798071eddd6e4f23dc6e5c0e23321e40ec3d4 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 12:47:29 +0100 Subject: [PATCH 09/27] Make useViewportSize hook SSR compatible While currently unused, I guess we'll need this hook soon, so I'd not delete the file (yet). --- src/lib/util/useViewportSize.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/lib/util/useViewportSize.ts b/src/lib/util/useViewportSize.ts index 01a8f5db5..db27717de 100644 --- a/src/lib/util/useViewportSize.ts +++ b/src/lib/util/useViewportSize.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { useIsomorphicLayoutEffect } from '../../components/shared/useIsomorphicLayoutEffect' const getBreakpointSize = (width: number) => (width <= 512 ? 'small' : 'big') @@ -8,9 +9,9 @@ const getBreakpointSize = (width: number) => (width <= 512 ? 'small' : 'big') */ export const useWindowSize = () => { const [windowSize, setWindowSize] = useState({ - width: window.innerHeight, - height: window.innerHeight, - size: getBreakpointSize(window.innerWidth), + width: global.window?.innerHeight, + height: global.window?.innerHeight, + size: getBreakpointSize(global.window?.innerWidth), } as const) const updateWindowSize = useCallback((windowWidth: number, windowHeight: number) => { @@ -19,16 +20,17 @@ export const useWindowSize = () => { height: windowHeight, size: getBreakpointSize(windowWidth), }) - }, [setWindowSize]) + }, []) - useEffect(() => { + useIsomorphicLayoutEffect(() => { const handleResize = () => { - updateWindowSize(window.innerWidth, window.innerHeight) + updateWindowSize(global.window?.innerWidth, global.window?.innerHeight) } - window.addEventListener('resize', handleResize) - return () => { window.removeEventListener('resize', handleResize) } - }, [setWindowSize, updateWindowSize]) + global.window?.addEventListener('resize', handleResize) + handleResize(); + return () => { global.window?.removeEventListener('resize', handleResize) } + }, [updateWindowSize]) return windowSize } From 09414edde765f124b424a109f30818bc0fcafcf1 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 15:06:10 +0100 Subject: [PATCH 10/27] Refactor/fix main menu Uses Radix UI menu component now. --- src/components/App/MainMenu/AppLink.tsx | 14 + src/components/App/MainMenu/AppLinks.tsx | 147 ++++--- src/components/App/MainMenu/MainMenu.tsx | 385 ++++-------------- .../App/MainMenu/SessionMenuItem.tsx | 69 ++++ src/components/App/MainMenu/useExpertMode.tsx | 25 ++ src/components/App/MapLayout.tsx | 27 +- src/components/Session/ProfilePanel.tsx | 30 -- src/components/Session/SessionLink.tsx | 36 -- src/lib/context/useCurrentMappingEvent.tsx | 2 +- .../ac/refactor-this/fetchMappingEvent.ts | 10 +- src/lib/fetchers/ac/useDocumentSWR.tsx | 11 +- src/pages/_app.tsx | 32 +- src/pages/me/index.tsx | 14 - 13 files changed, 309 insertions(+), 493 deletions(-) create mode 100644 src/components/App/MainMenu/AppLink.tsx create mode 100644 src/components/App/MainMenu/SessionMenuItem.tsx create mode 100644 src/components/App/MainMenu/useExpertMode.tsx delete mode 100644 src/components/Session/ProfilePanel.tsx delete mode 100644 src/components/Session/SessionLink.tsx delete mode 100644 src/pages/me/index.tsx diff --git a/src/components/App/MainMenu/AppLink.tsx b/src/components/App/MainMenu/AppLink.tsx new file mode 100644 index 000000000..82e6d0a77 --- /dev/null +++ b/src/components/App/MainMenu/AppLink.tsx @@ -0,0 +1,14 @@ +import type Link from "next/link"; +import type { ComponentProps } from "react"; +import { AppStateLink } from "../AppStateLink"; +import { Button, DropdownMenu } from "@radix-ui/themes"; + +export default function AppLink(props: ComponentProps & { buttonProps?: ComponentProps}) { + const { buttonProps, children } = props + + return + + {children} + + +} \ No newline at end of file diff --git a/src/components/App/MainMenu/AppLinks.tsx b/src/components/App/MainMenu/AppLinks.tsx index cc581c7d5..167cbd5b0 100644 --- a/src/components/App/MainMenu/AppLinks.tsx +++ b/src/components/App/MainMenu/AppLinks.tsx @@ -1,6 +1,4 @@ -import { useHotkeys } from '@blueprintjs/core' -import Link from 'next/link' -import { useMemo, useState } from 'react' +import React from 'react' import styled from 'styled-components' import { useCurrentApp } from '../../../lib/context/AppContext' import { useCurrentMappingEvent } from '../../../lib/context/useCurrentMappingEvent' @@ -8,8 +6,12 @@ import { useUniqueSurveyId } from '../../../lib/context/useUniqueSurveyId' import { translatedStringFromObject } from '../../../lib/i18n/translatedStringFromObject' import { insertPlaceholdersToAddPlaceUrl } from '../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl' import Spinner from '../../ActivityIndicator/Spinner' -import SessionLink from '../../Session/SessionLink' -import { AppStateLink } from '../AppStateLink' +import SessionMenuItem from './SessionMenuItem' +import type { ButtonProps } from '@radix-ui/themes' +import AppLink from './AppLink' +import { useExpertMode } from './useExpertMode' +import type { IApp } from '../../../lib/model/ac/App' +import type { MappingEvent } from '../../../lib/model/ac/MappingEvent' const Badge = styled.span` border-radius: 0.5rlh; @@ -19,7 +21,7 @@ const Badge = styled.span` margin: 0.1rem; ` -function JoinedEventLink(props: { label: string | null; url: string | null }) { +function JoinedEventLink(props: { label?: string; url?: string }) { const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent() if (isValidating) { @@ -33,89 +35,76 @@ function JoinedEventLink(props: { label: string | null; url: string | null }) { const label = joinedMappingEvent ? joinedMappingEvent.name : props.label return ( - + {label} - + ) } -export default function AppLinks(props: {}) { +function expandLinkMetadata(link: IApp['related']['appLinks'][string], app: IApp, joinedMappingEvent?: MappingEvent, uniqueSurveyId: string) { + const baseUrl = `https://${app._id}/` + const localizedUrl = translatedStringFromObject(link.url); + const url = link.url + && insertPlaceholdersToAddPlaceUrl( + baseUrl, + localizedUrl, + joinedMappingEvent, + uniqueSurveyId, + ) + const label = translatedStringFromObject(link.label) + const badgeLabel = translatedStringFromObject(link.badgeLabel) + const isExternal = localizedUrl?.startsWith('http') + return { + ...link, + url, + label, + badgeLabel, + isExternal, + } +} + +export default function AppLinks() { const { data: joinedMappingEvent } = useCurrentMappingEvent() const app = useCurrentApp() - const baseUrl = `https://${app._id}/` const uniqueSurveyId = useUniqueSurveyId() - const { - related: { appLinks }, + related: { appLinks } = {}, } = app - const [toogle, setToggle] = useState(false) - const hotkeys = useMemo(() => [ - { - combo: 'l', - global: true, - label: 'Toggle OSM Power User Mode', - onKeyDown: () => setToggle(!toogle), - }, - - ], [toogle]) - const { handleKeyDown } = useHotkeys(hotkeys) - - const links = Object.values(appLinks) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - .map((link) => { - const url = link.url - && insertPlaceholdersToAddPlaceUrl( - baseUrl, - translatedStringFromObject(link.url), - joinedMappingEvent, - uniqueSurveyId, - ) - const label = translatedStringFromObject(link.label) - const badgeLabel = translatedStringFromObject(link.badgeLabel) - const classNamesFromTags = link.tags && link.tags.map((tag) => `${tag}-link`) - const className = ['nav-link'].concat(classNamesFromTags).join(' ') - - const isAddPlaceLink = link.tags && link.tags.indexOf('add-place') !== -1 - const isAddPlaceLinkWithoutCustomUrl = isAddPlaceLink && (!url || url == '/add-place') - - if (isAddPlaceLinkWithoutCustomUrl) { - return ( - - {label} - {badgeLabel && {badgeLabel}} - - ) - } - - const isEventsLink = link.tags && link.tags.indexOf('events') !== -1 - if (isEventsLink) { - return - } - - const isSessionLink = link.tags && link.tags.indexOf('session') !== -1 - if (isSessionLink) { - return ( - toogle && - ) - } - - if (typeof url === 'string') { - return ( - - {label} - {badgeLabel && {badgeLabel}} - - ) - } - - return null - }) + const { isExpertMode } = useExpertMode() + + const links = React.useMemo( + () => Object.values(appLinks ?? {}) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + .map((link) => expandLinkMetadata(link, app, joinedMappingEvent, uniqueSurveyId)) + .map(({ tags, label, badgeLabel, url }) => { + const isEventsLink = tags?.includes('events') + if (isEventsLink) { + return + } + + const isSessionLink = tags?.includes('session') + if (isSessionLink) { + return ( + isExpertMode && + ) + } + + if (typeof url === 'string') { + const buttonProps: ButtonProps = { + variant: tags?.includes('primary') ? 'solid' : 'soft', + highContrast: true, + }; + return ( + + {label} + {badgeLabel && {badgeLabel}} + + ) + } + + return null + }), [app, appLinks, isExpertMode, joinedMappingEvent, uniqueSurveyId]); return <>{links} } diff --git a/src/components/App/MainMenu/MainMenu.tsx b/src/components/App/MainMenu/MainMenu.tsx index 23cfcb279..26aaadf35 100644 --- a/src/components/App/MainMenu/MainMenu.tsx +++ b/src/components/App/MainMenu/MainMenu.tsx @@ -1,316 +1,107 @@ -import { useFocusTrap } from "@primer/react/lib-esm/hooks/useFocusTrap"; -import { hsl } from "d3-color"; -import Link from "next/link"; import * as React from "react"; import styled from "styled-components"; import { t } from "ttag"; import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; -import { ClientSideConfiguration } from "../../../lib/model/ac/ClientSideConfiguration"; -import colors, { alpha } from "../../../lib/util/colors"; -import CloseIcon from "../../icons/actions/Close"; +import type { ClientSideConfiguration } from "../../../lib/model/ac/ClientSideConfiguration"; import VectorImage from "../../shared/VectorImage"; import AppLinks from "./AppLinks"; -import { Button, Card } from "@radix-ui/themes"; +import { Box, Button, Card, DropdownMenu, Flex, Inset, Popover, Text, Theme, useThemeContext } from "@radix-ui/themes"; import { AppStateLink } from "../AppStateLink"; +import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/router"; type Props = { - onToggle: (isMainMenuOpen: boolean) => void; - isOpen: boolean; - clientSideConfiguration: ClientSideConfiguration; - className?: string; + clientSideConfiguration: ClientSideConfiguration; + className?: string; }; -function MenuIcon(props) { - return ( - - - - - - - - ); -} - -const MENU_BUTTON_VISIBILITY_BREAKPOINT = 1024; - -const openMenuHoverColor = hsl(colors.primaryColor).brighter(1.4); -openMenuHoverColor.opacity = 0.5; - -const StyledNav = styled.nav` - box-sizing: border-box; - padding: 0; - display: flex; - flex-direction: row; - align-items: center; - z-index: 20; - overflow: hidden; - - .logo { - margin-left: 10px; - margin-right: 10px; - object-fit: contain; - object-position: left; - svg { - max-width: 140px; - max-height: 24px; - width: auto; - } - } - - .claim { - font-weight: 300; - opacity: 0.6; - transition: opacity 0.3s ease-out; - padding-left: 5px; - flex: 1; - display: flex; - justify-content: start; - align-items: center; - - @media (max-width: 1280px) { - font-size: 80%; - } - @media (max-width: 1180px) { - display: none; - } - } - - #main-menu { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: flex-end; - align-items: stretch; - height: 100%; - overflow: hidden; - flex: 3; - min-height: 50px; - } - - &.is-open { - #main-menu { - opacity: 1; - } - } - - .nav-link { - padding: 2px 10px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; - } - - .primary-link { - font: inherit; - border: 0; - margin: 0; - font-weight: 500; - cursor: pointer; - background-color: transparent; - - &, - &:visited { - color: ${colors.linkColor}; - } - } - - button.menu { - position: fixed; - top: 0; - top: constant(safe-area-inset-top); - top: env(safe-area-inset-top); - right: 0; - right: constant(safe-area-inset-right); - right: env(safe-area-inset-right); - width: 70px; - height: 50px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: opacity 0.3s ease-out; - } - - position: absolute; +const StyledCard = styled(Card)` + position: fixed; top: 0; - padding-top: constant(safe-area-inset-top); - padding-top: env(safe-area-inset-top); - backdrop-filter: var(--backdrop-filter-panel); left: 0; right: 0; - - @media (max-width: ${MENU_BUTTON_VISIBILITY_BREAKPOINT}px) { - flex-wrap: wrap; - flex-direction: column; - align-items: flex-start; - backdrop-filter: var(--backdrop-filter-panel); - - #main-menu { - justify-content: flex-end; - min-height: 0; - } - - .activity-indicator { - position: fixed; - top: 0; - top: constant(safe-area-inset-top); - top: env(safe-area-inset-top); - right: 0; - right: constant(safe-area-inset-right); - right: env(safe-area-inset-right); - margin-right: 80px; - margin-top: 20px; - } - - button.menu { - opacity: 1; - pointer-events: inherit; - } - - .flexible-separator { - display: none; - } - - &.is-open { - #main-menu { - margin: 16px; - align-self: flex-end; - } - } - } - - @media (max-height: 400px) { - padding: 2px 10px 2px 10px; - padding-left: constant(safe-area-inset-left); - padding-left: env(safe-area-inset-left); - &, - button.menu, - button.home-button { - height: 44px; - min-height: auto; - } - &.is-open { - height: auto; - } - } + height: auto; + z-index: 1; `; export default function MainMenu(props: Props) { - const [isMenuButtonVisible, setIsMenuButtonVisible] = React.useState(false); - - const onResize = React.useCallback(() => { - if (window.innerWidth > MENU_BUTTON_VISIBILITY_BREAKPOINT) { - setIsMenuButtonVisible(false); - } else { - setIsMenuButtonVisible(true); - props.onToggle(false); - } - }, [props.onToggle, setIsMenuButtonVisible]); - - React.useEffect(() => { - window.addEventListener("resize", onResize); - onResize(); - return () => { - window.removeEventListener("resize", onResize); - }; - }, []); - - const toggleMenu = React.useCallback( - (event: React.MouseEvent) => { - props.onToggle(!props.isOpen); - event.preventDefault(); - }, - [props.onToggle, props.isOpen], - ); - - const productName = - translatedStringFromObject( - props.clientSideConfiguration.textContent?.product.name, - ) || "Wheelmap"; - - const homeLink = ( -
    - - - -
    - ); - - const closeButton = ( - - ); - - const { isOpen, className, clientSideConfiguration } = props; - const claim = translatedStringFromObject( - clientSideConfiguration?.textContent?.product?.claim, - ); - - const classList = [ - className, - isOpen || !isMenuButtonVisible ? "is-open" : null, - "main-menu", - ].filter(Boolean); - - const { containerRef } = useFocusTrap({ - disabled: !isMenuButtonVisible || !isOpen, - }); - - return ( - - } + const { clientSideConfiguration } = props; + const router = useRouter(); + const { pathname } = router; + const [isOpen, setIsOpen] = React.useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: when pathname changes, the effect must be triggered. + React.useEffect(() => { + setIsOpen(false); + }, [pathname]); + + // The product name is configurable in the app's whitelabel settings. + const productName = + translatedStringFromObject( + props.clientSideConfiguration.textContent?.product?.name, + ) || "A11yMap"; + const claimString = translatedStringFromObject( + clientSideConfiguration?.textContent?.product?.claim, + ); + + const logoHomeLink = ( + + + + ); + + const menuButton = ( + + ); + + const appLinksPopover = ( + + {menuButton} + + + + + ); + + const radius = useThemeContext().radius; + + return ( + + + + + + + {logoHomeLink} + {claimString} + + + + + + + + + ); } diff --git a/src/components/App/MainMenu/SessionMenuItem.tsx b/src/components/App/MainMenu/SessionMenuItem.tsx new file mode 100644 index 000000000..b09b70b23 --- /dev/null +++ b/src/components/App/MainMenu/SessionMenuItem.tsx @@ -0,0 +1,69 @@ +import { signIn, signOut, useSession } from "next-auth/react"; +import { t } from "ttag"; +import { UserIcon } from "../../icons/ui-elements"; +import { DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; +import React from "react"; + +function AuthenticatedMenuContent() { + const { data: session } = useSession(); + const username = session?.user?.name; + const handleSignOut = React.useCallback(() => signOut(), []); + + return ( + <> + + + + {!session?.user?.image && } + {session?.user?.image && ( + {t`${username}'s + )} + {t`You’re signed in as ${username}.`} + + + {t`Sign out`} + + ); +} + +function NonAuthenticatedMenuContent() { + const handleSignIn = React.useCallback(() => signIn("osm"), []); + return ( + <> + + + {t`Sign in with OpenStreetMap to enable more features.`} + + {t`Sign in`} + + ); +} + +function LoadingMenuContent() { + return ( + + + + ); +} + +export default function SessionMenuItem() { + const { status } = useSession(); + return { + loading: , + authenticated: , + unauthenticated: , + }[status]; +} diff --git a/src/components/App/MainMenu/useExpertMode.tsx b/src/components/App/MainMenu/useExpertMode.tsx new file mode 100644 index 000000000..4ce130f2d --- /dev/null +++ b/src/components/App/MainMenu/useExpertMode.tsx @@ -0,0 +1,25 @@ +import { useHotkeys } from "@blueprintjs/core"; +import { useState, useMemo, useCallback } from "react"; + +import React from 'react' + +const ExpertModeContext = React.createContext<{ isExpertMode: boolean, toggleExpertMode: () => void }>({ isExpertMode: false, toggleExpertMode: () => { + debugger +} }); + +export default ExpertModeContext + +export function useExpertMode() { + return React.useContext(ExpertModeContext); +} + +export function ExpertModeContextProvider({ children, defaultValue = false }) { + const [isExpertMode, setExpertMode] = useState(defaultValue); + const toggleExpertMode = useCallback(() => { + setExpertMode(() => !isExpertMode); + }, [isExpertMode]); + + return + {children} + ; +} \ No newline at end of file diff --git a/src/components/App/MapLayout.tsx b/src/components/App/MapLayout.tsx index 1375b2814..eddafc831 100644 --- a/src/components/App/MapLayout.tsx +++ b/src/components/App/MapLayout.tsx @@ -16,6 +16,8 @@ import { MapFilterContextProvider } from '../Map/filter/MapFilterContext' import { isFirstStart } from '../../lib/util/savedState' import { Theme, ThemePanel } from '@radix-ui/themes' import { ThemeProvider } from 'next-themes' +import { useExpertMode } from './MainMenu/useExpertMode' +import { useHotkeys } from '@blueprintjs/core' // onboarding is a bad candidate for SSR, as it dependently renders based on a local storage setting // these diverge between server and client (see: https://nextjs.org/docs/messages/react-hydration-error) @@ -50,19 +52,20 @@ export default function MapLayout({ }) { const app = React.useContext(AppContext) const { clientSideConfiguration } = app || {} - const [isMenuOpen, setIsMenuOpen] = React.useState(false) const firstStart = isFirstStart() - const toggleMainMenu = React.useCallback((newValue?: boolean) => { - setIsMenuOpen(typeof newValue === 'boolean' ? newValue : !isMenuOpen) - }, [isMenuOpen]) const [containerRef, { width, height }] = useMeasure({ debounce: 100 }) - const router = useRouter() - const { pathname } = router - React.useEffect(() => { - setIsMenuOpen(false) - }, [pathname]) + const { toggleExpertMode } = useExpertMode() + const expertModeHotkeys = React.useMemo(() => [ + { + combo: '9', + global: true, + label: 'Toggle expert mode', + onKeyDown: toggleExpertMode, + }, + ], [toggleExpertMode]); + useHotkeys(expertModeHotkeys); return ( @@ -72,11 +75,9 @@ export default function MapLayout({ - + />} {firstStart && }
    - } - - if (status === 'authenticated' && session?.user) { - const { name, image } = session.user; - return ( - - - {t`You’re signed in as ${name}.`} - - - ) - } - - return ( - - {t`You are not signed in.`} - {t`Sign in to use your OpenStreetMap profile and enable advanced Wheelmap features.`} - - - ) -} diff --git a/src/components/Session/SessionLink.tsx b/src/components/Session/SessionLink.tsx deleted file mode 100644 index 794269f3f..000000000 --- a/src/components/Session/SessionLink.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useSession } from 'next-auth/react' -import Link from 'next/link' -import { t } from 'ttag' -import { UserIcon } from '../icons/ui-elements' -import { Spinner } from '@radix-ui/themes' - -export default function SessionLink({ label, className }: { label: string, className?: string }) { - const { data: session, status } = useSession() - const username = session?.user.name - - if (status === 'loading') { - return - } - - if (status === 'authenticated') { - return ( - - {session?.user.image && ( - - )} - {!session?.user.image && } - - ) - } - - return ( - - {t`Sign in`} - - ) -} diff --git a/src/lib/context/useCurrentMappingEvent.tsx b/src/lib/context/useCurrentMappingEvent.tsx index 277bc487b..c537aca5d 100644 --- a/src/lib/context/useCurrentMappingEvent.tsx +++ b/src/lib/context/useCurrentMappingEvent.tsx @@ -3,5 +3,5 @@ import { useCurrentMappingEventId } from './MappingEventContext'; export function useCurrentMappingEvent() { const { data: _id } = useCurrentMappingEventId(); - return useMappingEvent(_id); + return _id ? useMappingEvent(_id) : { data: undefined, isValidating: false }; } diff --git a/src/lib/fetchers/ac/refactor-this/fetchMappingEvent.ts b/src/lib/fetchers/ac/refactor-this/fetchMappingEvent.ts index 7624f8378..d10a1c6b4 100644 --- a/src/lib/fetchers/ac/refactor-this/fetchMappingEvent.ts +++ b/src/lib/fetchers/ac/refactor-this/fetchMappingEvent.ts @@ -1,10 +1,10 @@ -import { MappingEvent } from '../../../model/ac/MappingEvent'; +import type { IImage } from '../../../model/ac/Image'; import useDocumentSWR from '../useDocumentSWR' import { useMemo } from 'react' export function useMappingEvent(_id: string) { - const response = useDocumentSWR({ - collectionName: 'MappingEvents', + const response = useDocumentSWR({ + type: 'ac:MappingEvent', _id, params: new URLSearchParams({ includeRelated: 'images' }), }); @@ -18,8 +18,8 @@ export function useMappingEvent(_id: string) { data: { ...response.data, images: Object.keys(response.data.related.images) - .map((_id) => response.data.related.images[_id]) - .filter((image) => image['objectId'] === response.data._id), + .map((_id) => response.data?.related.images[_id]) + .filter((image: IImage) => image.objectId === response.data?._id) as IImage[], }, }; }, [response]); diff --git a/src/lib/fetchers/ac/useDocumentSWR.tsx b/src/lib/fetchers/ac/useDocumentSWR.tsx index 2081a3a7a..e548d85ca 100644 --- a/src/lib/fetchers/ac/useDocumentSWR.tsx +++ b/src/lib/fetchers/ac/useDocumentSWR.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' -import useSWR, { SWRResponse } from 'swr' -import { AccessibilityCloudRDFType, AccessibilityCloudTypeMapping } from '../../model/typing/AccessibilityCloudTypeMapping' -import ResourceError from '../ResourceError' +import useSWR, { type SWRResponse } from 'swr' +import type { AccessibilityCloudRDFType, AccessibilityCloudTypeMapping } from '../../model/typing/AccessibilityCloudTypeMapping' +import type ResourceError from '../ResourceError' import { fetchDocumentWithTypeTag } from './fetchDocument' import useAccessibilityCloudAPI from './useAccessibilityCloudAPI' @@ -44,7 +44,7 @@ type ExtraAPIResultFields = { * ``` */ -export default function useDocumentSWR({ +export default function useDocumentSWR({ type, _id, params, @@ -53,6 +53,9 @@ export default function useDocumentSWR): SWRResponse { const { baseUrl, appToken } = useAccessibilityCloudAPI({ cached }) const paramsWithAppToken = new URLSearchParams(params) + if (!appToken) { + throw new Error('Cannot fetch documents from accessibility.cloud without an appToken. Please supply an appToken in the environment.') + } paramsWithAppToken.append('appToken', appToken) const paramsString = paramsWithAppToken.toString() const swrConfig = useMemo(() => ({}), []) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 1b77dbbf7..7ac1ec53f 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,30 +1,30 @@ // babel-preset-react-app uses useBuiltIn "entry". We therefore need an entry // polyfill import to be replaced with polyfills we need for our targeted browsers. import { HotkeysProvider } from '@blueprintjs/core' -import { ILanguageSubtag, parseLanguageTag } from '@sozialhelden/ietf-language-tags' +import { type ILanguageSubtag, parseLanguageTag } from '@sozialhelden/ietf-language-tags' import { pick, uniq } from 'lodash' -import { NextPage } from 'next' +import type { NextPage } from 'next' import { SessionProvider } from 'next-auth/react' import type { AppProps } from 'next/app' import { default as NextApp } from 'next/app' import Head from 'next/head' import * as queryString from 'query-string' import * as React from 'react' -import { IncomingMessage, ServerResponse } from 'http' -import { SWRConfig, SWRConfiguration } from 'swr' +import type { IncomingMessage, ServerResponse } from 'http' +import { SWRConfig, type SWRConfiguration } from 'swr' import { t } from 'ttag' import { toast } from 'react-toastify' import { AppContext } from '../lib/context/AppContext' import CountryContext from '../lib/context/CountryContext' -import EnvContext, { EnvironmentVariables } from '../lib/context/EnvContext' +import EnvContext, { type EnvironmentVariables } from '../lib/context/EnvContext' import { HostnameContext } from '../lib/context/HostnameContext' import { LanguageTagContext } from '../lib/context/LanguageTagContext' import { UserAgentContext, parseUserAgentString } from '../lib/context/UserAgentContext' -import composeContexts, { ContextAndValue } from '../lib/context/composeContexts' +import composeContexts, { type ContextAndValue } from '../lib/context/composeContexts' import { parseAcceptLanguageString } from '../lib/i18n/parseAcceptLanguageString' -import { IApp } from '../lib/model/ac/App' +import type { IApp } from '../lib/model/ac/App' import fetchApp from '../lib/fetchers/ac/fetchApp' -import ResourceError from '../lib/fetchers/ResourceError' +import type ResourceError from '../lib/fetchers/ResourceError' import { patchFetcher } from '../lib/util/patchClientFetch' import { ErrorMessage } from '../components/SWRError/ErrorMessage' import { addToEnvironment, getEnvironment } from '../lib/util/globalEnvironment' @@ -33,6 +33,7 @@ import "@radix-ui/themes/styles.css"; import StyledComponentsRegistry from '../lib/context/Registry' import '../app/app.css' import '../app/inter.css' +import ExpertModeContext, { ExpertModeContextProvider } from '../components/App/MainMenu/useExpertMode' export type NextPageWithLayout = NextPage & { getLayout?: (page: React.ReactElement) => React.ReactNode @@ -99,6 +100,7 @@ export default function MyApp(props: AppProps & AppPropsWithLayout) addToEnvironment(environmentVariables) const environment = getEnvironment() + // biome-ignore lint/suspicious/noExplicitAny: The type of the context value is not known or important at this point. const contexts: ContextAndValue[] = [ [UserAgentContext, parseUserAgentString(userAgentString)], [AppContext, app], @@ -117,12 +119,14 @@ export default function MyApp(props: AppProps & AppPropsWithLayout) - - {composeContexts( - contexts, - getLayout(), - )} - + + + {composeContexts( + contexts, + getLayout(), + )} + + diff --git a/src/pages/me/index.tsx b/src/pages/me/index.tsx deleted file mode 100644 index 57b9e1826..000000000 --- a/src/pages/me/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { ReactElement } from 'react' -import MapLayout, { getLayout } from '../../components/App/MapLayout' -import ProfilePanel from '../../components/Session/ProfilePanel' -import Toolbar from '../../components/shared/Toolbar' - -export default function Page() { - return ( - - - - ) -} - -Page.getLayout = getLayout From b78cac18386ec4bc68b285ff05ec5d8b233d25ab Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 15:24:33 +0100 Subject: [PATCH 11/27] Use `l` hotkey again for expert / OSM mode --- src/components/App/MapLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/App/MapLayout.tsx b/src/components/App/MapLayout.tsx index eddafc831..aa465dbae 100644 --- a/src/components/App/MapLayout.tsx +++ b/src/components/App/MapLayout.tsx @@ -59,7 +59,7 @@ export default function MapLayout({ const { toggleExpertMode } = useExpertMode() const expertModeHotkeys = React.useMemo(() => [ { - combo: '9', + combo: 'l', global: true, label: 'Toggle expert mode', onKeyDown: toggleExpertMode, From fb5ce2e87721325d6848a4fa2fce5a401979b512 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 15:24:44 +0100 Subject: [PATCH 12/27] Improve `useExpertMode()` readability --- src/components/App/MainMenu/useExpertMode.tsx | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/components/App/MainMenu/useExpertMode.tsx b/src/components/App/MainMenu/useExpertMode.tsx index 4ce130f2d..abef732b6 100644 --- a/src/components/App/MainMenu/useExpertMode.tsx +++ b/src/components/App/MainMenu/useExpertMode.tsx @@ -1,14 +1,19 @@ -import { useHotkeys } from "@blueprintjs/core"; -import { useState, useMemo, useCallback } from "react"; +import React, { useState, useCallback } from "react"; -import React from 'react' +type ExpertModeContextType = { + isExpertMode: boolean; + toggleExpertMode: () => void; +}; +const defaultValue = { isExpertMode: false, toggleExpertMode: () => {} }; +const ExpertModeContext = + React.createContext(defaultValue); -const ExpertModeContext = React.createContext<{ isExpertMode: boolean, toggleExpertMode: () => void }>({ isExpertMode: false, toggleExpertMode: () => { - debugger -} }); - -export default ExpertModeContext +export default ExpertModeContext; +/** + * @returns {ExpertModeContextType} Information if the user has enabled 'expert mode' in the app, + * and a function to toggle this feature. + */ export function useExpertMode() { return React.useContext(ExpertModeContext); } @@ -19,7 +24,9 @@ export function ExpertModeContextProvider({ children, defaultValue = false }) { setExpertMode(() => !isExpertMode); }, [isExpertMode]); - return - {children} - ; -} \ No newline at end of file + return ( + + {children} + + ); +} From 9c21b4eda3eac4ec60b86b21ecfd27cc1ce83af2 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Wed, 11 Dec 2024 15:08:18 +0100 Subject: [PATCH 13/27] Use Radix theme colors for category and filter buttons --- .../SearchPanel/AccessibilityFilterButton.tsx | 6 +++--- .../SearchPanel/AccessibilityFilterMenu.tsx | 5 ++--- src/components/SearchPanel/CategoryButton.tsx | 17 ++++++++--------- src/components/SearchPanel/CategoryMenu.tsx | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/SearchPanel/AccessibilityFilterButton.tsx b/src/components/SearchPanel/AccessibilityFilterButton.tsx index ac50c521c..700d2546d 100644 --- a/src/components/SearchPanel/AccessibilityFilterButton.tsx +++ b/src/components/SearchPanel/AccessibilityFilterButton.tsx @@ -25,7 +25,7 @@ type Props = { export const Caption = styled.span` flex: 1; - color: ${colors.darkSelectedColor}; + color: var(--accent-a12); ` function AccessibilityFilterButton(props: Props) { @@ -91,12 +91,12 @@ export default styled(AccessibilityFilterButton)` ${(props) => props.isActive && css` - background-color: ${colors.coldBackgroundColor}; + background-color: var(--color-panel-translucent); `}; &:hover, &:focus { - background-color: ${colors.linkBackgroundColorTransparent}; + background-color: var(--color-surface); } } ` diff --git a/src/components/SearchPanel/AccessibilityFilterMenu.tsx b/src/components/SearchPanel/AccessibilityFilterMenu.tsx index 86aca7935..f7d406e92 100644 --- a/src/components/SearchPanel/AccessibilityFilterMenu.tsx +++ b/src/components/SearchPanel/AccessibilityFilterMenu.tsx @@ -5,9 +5,8 @@ import { t } from 'ttag' import { yesNoUnknownArray, } from '../../lib/model/ac/Feature' -import colors from '../../lib/util/colors' import AccessibilityFilterButton from './AccessibilityFilterButton' -import { PlaceFilter } from './AccessibilityFilterModel' +import type { PlaceFilter } from './AccessibilityFilterModel' type Props = PlaceFilter & { className?: string; @@ -111,7 +110,7 @@ function AccessibilityFilterMenu(props: Props) { } const StyledAccessibilityFilterMenu = styled(AccessibilityFilterMenu)` - border-top: 1px solid ${colors.borderColor}; + border-top: 1px solid var(--gray-4); header { display: flex; diff --git a/src/components/SearchPanel/CategoryButton.tsx b/src/components/SearchPanel/CategoryButton.tsx index 158d5aac8..6ada37652 100644 --- a/src/components/SearchPanel/CategoryButton.tsx +++ b/src/components/SearchPanel/CategoryButton.tsx @@ -3,7 +3,6 @@ import { t } from 'ttag' import { YesNoLimitedUnknown, YesNoUnknown } from '../../lib/model/ac/Feature' import { isAccessibilityFiltered } from '../../lib/model/ac/filterAccessibility' -import colors from '../../lib/util/colors' import CloseIcon from '../icons/actions/Close' import IconButton, { Caption, Circle } from '../shared/IconButton' import CombinedIcon from './CombinedIcon' @@ -31,7 +30,7 @@ export const StyledCategoryIconButton = styled(IconButton)` } ${Circle} { - background-color: ${colors.tonedDownSelectedColor}; + background-color: var(--accent-a11); margin: 2.5px; svg.icon { @@ -50,24 +49,24 @@ export const StyledCategoryIconButton = styled(IconButton)` } &.active { - background-color: ${colors.coldBackgroundColor}; + background-color: var(--color-panel-translucent); ${Circle} { - background-color: ${colors.selectedColor}; + background-color: var(--color-surface); } } &:hover, &:focus { - background-color: ${colors.linkBackgroundColorTransparent}; + background-color: var(--color-surface); ${Circle} { - background-color: ${colors.halfTonedDownSelectedColor}; + background-color: var(--accent-11); } &.active { ${Circle} { - background-color: ${colors.tonedDownSelectedColor}; + background-color: var(--accent-a12); } } } @@ -82,7 +81,7 @@ export const StyledCategoryIconButton = styled(IconButton)` ${Caption} { font-size: 0.8em; margin-top: 0.5em; - color: ${colors.darkSelectedColor}; + color: var(--accent-12); } } @@ -104,7 +103,7 @@ export const StyledCategoryIconButton = styled(IconButton)` flex: 1; justify-content: flex-start; display: flex; - color: ${colors.darkSelectedColor}; + color: var(--accent-12); } } ` diff --git a/src/components/SearchPanel/CategoryMenu.tsx b/src/components/SearchPanel/CategoryMenu.tsx index ed2fbbc89..82c05b5d1 100644 --- a/src/components/SearchPanel/CategoryMenu.tsx +++ b/src/components/SearchPanel/CategoryMenu.tsx @@ -4,7 +4,7 @@ import map from 'lodash/map' import { Circle } from '../shared/IconButton' import CategoryButton from './CategoryButton' -import { YesNoLimitedUnknown, YesNoUnknown } from '../../lib/model/ac/Feature' +import type { YesNoLimitedUnknown, YesNoUnknown } from '../../lib/model/ac/Feature' import { isAccessibilityFiltered } from '../../lib/model/ac/filterAccessibility' import { getRootCategoryTable } from '../../lib/model/ac/categories/getRootCategoryTable' From ae48904380caa1a72f3be020494671dccdd47097 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 16 Dec 2024 23:26:25 +0100 Subject: [PATCH 14/27] Allow to show links directly in the main toolbar again --- src/components/App/AppStateLink.tsx | 2 +- src/components/App/MainMenu/AppLink.tsx | 14 --- src/components/App/MainMenu/AppLinks.tsx | 110 ------------------ src/components/App/MainMenu/AutoLink.tsx | 84 +++++++++++++ src/components/App/MainMenu/MainMenuLinks.tsx | 56 +++++++++ .../{MainMenu.tsx => MainToolbar.tsx} | 71 +++++------ .../App/MainMenu/SessionMenuItem.tsx | 24 ++-- src/components/App/MainMenu/useAppLinks.tsx | 55 +++++++++ src/components/App/MapLayout.tsx | 11 +- src/components/shared/VectorImage.tsx | 19 +-- src/lib/model/ac/App.ts | 12 +- src/lib/model/ac/IAppLink.ts | 14 +++ 12 files changed, 262 insertions(+), 210 deletions(-) delete mode 100644 src/components/App/MainMenu/AppLink.tsx delete mode 100644 src/components/App/MainMenu/AppLinks.tsx create mode 100644 src/components/App/MainMenu/AutoLink.tsx create mode 100644 src/components/App/MainMenu/MainMenuLinks.tsx rename src/components/App/MainMenu/{MainMenu.tsx => MainToolbar.tsx} (54%) create mode 100644 src/components/App/MainMenu/useAppLinks.tsx create mode 100644 src/lib/model/ac/IAppLink.ts diff --git a/src/components/App/AppStateLink.tsx b/src/components/App/AppStateLink.tsx index 31af2b67e..c48578f38 100644 --- a/src/components/App/AppStateLink.tsx +++ b/src/components/App/AppStateLink.tsx @@ -1,5 +1,5 @@ import Link from 'next/link' -import { ComponentProps, useMemo } from 'react' +import { type ComponentProps, useMemo } from 'react' import { preserveSearchParams, useAppStateAwareRouter } from '../../lib/util/useAppStateAwareRouter' export const AppStateLink = ({ href, ...props }: ComponentProps) => { diff --git a/src/components/App/MainMenu/AppLink.tsx b/src/components/App/MainMenu/AppLink.tsx deleted file mode 100644 index 82e6d0a77..000000000 --- a/src/components/App/MainMenu/AppLink.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type Link from "next/link"; -import type { ComponentProps } from "react"; -import { AppStateLink } from "../AppStateLink"; -import { Button, DropdownMenu } from "@radix-ui/themes"; - -export default function AppLink(props: ComponentProps & { buttonProps?: ComponentProps}) { - const { buttonProps, children } = props - - return - - {children} - - -} \ No newline at end of file diff --git a/src/components/App/MainMenu/AppLinks.tsx b/src/components/App/MainMenu/AppLinks.tsx deleted file mode 100644 index 167cbd5b0..000000000 --- a/src/components/App/MainMenu/AppLinks.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { useCurrentApp } from '../../../lib/context/AppContext' -import { useCurrentMappingEvent } from '../../../lib/context/useCurrentMappingEvent' -import { useUniqueSurveyId } from '../../../lib/context/useUniqueSurveyId' -import { translatedStringFromObject } from '../../../lib/i18n/translatedStringFromObject' -import { insertPlaceholdersToAddPlaceUrl } from '../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl' -import Spinner from '../../ActivityIndicator/Spinner' -import SessionMenuItem from './SessionMenuItem' -import type { ButtonProps } from '@radix-ui/themes' -import AppLink from './AppLink' -import { useExpertMode } from './useExpertMode' -import type { IApp } from '../../../lib/model/ac/App' -import type { MappingEvent } from '../../../lib/model/ac/MappingEvent' - -const Badge = styled.span` - border-radius: 0.5rlh; - padding: 0.2rem 0.3rem; - font-size: 0.75rem; - text-transform: uppercase; - margin: 0.1rem; -` - -function JoinedEventLink(props: { label?: string; url?: string }) { - const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent() - - if (isValidating) { - return - } - - const href = joinedMappingEvent - ? `/events/${joinedMappingEvent._id}` - : '/events' - - const label = joinedMappingEvent ? joinedMappingEvent.name : props.label - - return ( - - {label} - - ) -} - -function expandLinkMetadata(link: IApp['related']['appLinks'][string], app: IApp, joinedMappingEvent?: MappingEvent, uniqueSurveyId: string) { - const baseUrl = `https://${app._id}/` - const localizedUrl = translatedStringFromObject(link.url); - const url = link.url - && insertPlaceholdersToAddPlaceUrl( - baseUrl, - localizedUrl, - joinedMappingEvent, - uniqueSurveyId, - ) - const label = translatedStringFromObject(link.label) - const badgeLabel = translatedStringFromObject(link.badgeLabel) - const isExternal = localizedUrl?.startsWith('http') - return { - ...link, - url, - label, - badgeLabel, - isExternal, - } -} - -export default function AppLinks() { - const { data: joinedMappingEvent } = useCurrentMappingEvent() - const app = useCurrentApp() - const uniqueSurveyId = useUniqueSurveyId() - const { - related: { appLinks } = {}, - } = app - - const { isExpertMode } = useExpertMode() - - const links = React.useMemo( - () => Object.values(appLinks ?? {}) - .sort((a, b) => (a.order || 0) - (b.order || 0)) - .map((link) => expandLinkMetadata(link, app, joinedMappingEvent, uniqueSurveyId)) - .map(({ tags, label, badgeLabel, url }) => { - const isEventsLink = tags?.includes('events') - if (isEventsLink) { - return - } - - const isSessionLink = tags?.includes('session') - if (isSessionLink) { - return ( - isExpertMode && - ) - } - - if (typeof url === 'string') { - const buttonProps: ButtonProps = { - variant: tags?.includes('primary') ? 'solid' : 'soft', - highContrast: true, - }; - return ( - - {label} - {badgeLabel && {badgeLabel}} - - ) - } - - return null - }), [app, appLinks, isExpertMode, joinedMappingEvent, uniqueSurveyId]); - - return <>{links} -} diff --git a/src/components/App/MainMenu/AutoLink.tsx b/src/components/App/MainMenu/AutoLink.tsx new file mode 100644 index 000000000..19ed8c50a --- /dev/null +++ b/src/components/App/MainMenu/AutoLink.tsx @@ -0,0 +1,84 @@ +import type React from "react"; +import styled from "styled-components"; +import { useCurrentMappingEvent } from "../../../lib/context/useCurrentMappingEvent"; +import Spinner from "../../ActivityIndicator/Spinner"; +import SessionMenuItem from "./SessionMenuItem"; +import { Button, DropdownMenu, type ButtonProps } from "@radix-ui/themes"; +import { useExpertMode } from "./useExpertMode"; +import type { expandLinkMetadata } from "./useAppLinks"; +import { AppStateLink } from "../AppStateLink"; +import Link from "next/link"; + +const Badge = styled.span` + border-radius: 0.5rlh; + padding: 0.2rem 0.3rem; + font-size: 0.75rem; + text-transform: uppercase; + margin: 0.1rem; +`; + +function JoinedEventLink(props: { label?: string; url?: string, asMenuItem?: boolean }) { + const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent(); + + if (isValidating) { + return ; + } + + const href = joinedMappingEvent + ? `/events/${joinedMappingEvent._id}` + : "/events"; + + const label = joinedMappingEvent ? joinedMappingEvent.name : props.label; + + return ( + + {label} + + + ); +} + +function ButtonOrMenuItem({ asMenuItem, buttonProps, children }: { asMenuItem?: boolean, buttonProps?: ButtonProps, children?: React.ReactNode }) { + return asMenuItem + ? + {children} + + : ; + +} + +export function AutoLink({ + tags, + label, + badgeLabel, + url, + asMenuItem, +}: ReturnType & { asMenuItem?: boolean }) { + const { isExpertMode } = useExpertMode(); + const isEventsLink = tags?.includes("events"); + if (isEventsLink) { + return ; + } + + const isSessionLink = tags?.includes("session"); + if (isSessionLink) { + return isExpertMode ? : null; + } + + if (typeof url === "string") { + const buttonProps: ButtonProps = { + variant: tags?.includes("primary") ? "solid" : "soft", + }; + + const isExternal = url.startsWith("http"); + const LinkElement = isExternal ? Link : AppStateLink + const link = + {label} + {badgeLabel && {badgeLabel}} + ; + + return {link}; + } + + return null; +} diff --git a/src/components/App/MainMenu/MainMenuLinks.tsx b/src/components/App/MainMenu/MainMenuLinks.tsx new file mode 100644 index 000000000..1246e9ef8 --- /dev/null +++ b/src/components/App/MainMenu/MainMenuLinks.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { useAppLinks } from "./useAppLinks"; +import { + Button, + DropdownMenu, + Flex, + Theme, +} from "@radix-ui/themes"; +import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/router"; +import { t } from "ttag"; +import { AutoLink } from "./AutoLink"; + +export default function MainMenuLinks() { + const router = useRouter(); + const { pathname } = router; + const [isOpen, setIsOpen] = React.useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: when pathname changes, the effect must be triggered. + React.useEffect(() => { + setIsOpen(false); + }, [pathname]); + + const menuButton = ( + + ); + + const appLinks = useAppLinks(); + const alwaysVisible = appLinks.filter(l => l.importance === "alwaysVisible"); + const advertisedIfPossible = appLinks.filter(l => !l.importance || l.importance === "advertisedIfPossible"); + const insignificant = appLinks.filter(l => l.importance === "insignificant"); + const shownInMenu = [...advertisedIfPossible, ...insignificant].sort((a, b) => (a.order || 0) - (b.order || 0)); + + const appLinksPopover = ( + + + {menuButton} + + {shownInMenu.map((appLink) => )} + + + + ); + return + {alwaysVisible.map( + (appLink) => + )} + {appLinksPopover} + ; +} diff --git a/src/components/App/MainMenu/MainMenu.tsx b/src/components/App/MainMenu/MainToolbar.tsx similarity index 54% rename from src/components/App/MainMenu/MainMenu.tsx rename to src/components/App/MainMenu/MainToolbar.tsx index 26aaadf35..981080bb4 100644 --- a/src/components/App/MainMenu/MainMenu.tsx +++ b/src/components/App/MainMenu/MainToolbar.tsx @@ -4,11 +4,17 @@ import { t } from "ttag"; import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; import type { ClientSideConfiguration } from "../../../lib/model/ac/ClientSideConfiguration"; import VectorImage from "../../shared/VectorImage"; -import AppLinks from "./AppLinks"; -import { Box, Button, Card, DropdownMenu, Flex, Inset, Popover, Text, Theme, useThemeContext } from "@radix-ui/themes"; +import { + Button, + Card, + Flex, + Inset, + Text, + Theme, + useThemeContext, +} from "@radix-ui/themes"; import { AppStateLink } from "../AppStateLink"; -import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons"; -import { useRouter } from "next/router"; +import MainMenuLinks from "./MainMenuLinks"; type Props = { clientSideConfiguration: ClientSideConfiguration; @@ -22,18 +28,16 @@ const StyledCard = styled(Card)` right: 0; height: auto; z-index: 1; + + @media (max-width: 768px) { + .claim { + display: none; + } + } `; -export default function MainMenu(props: Props) { +export default function MainToolbar(props: Props) { const { clientSideConfiguration } = props; - const router = useRouter(); - const { pathname } = router; - const [isOpen, setIsOpen] = React.useState(false); - - // biome-ignore lint/correctness/useExhaustiveDependencies: when pathname changes, the effect must be triggered. - React.useEffect(() => { - setIsOpen(false); - }, [pathname]); // The product name is configurable in the app's whitelabel settings. const productName = @@ -45,12 +49,8 @@ export default function MainMenu(props: Props) { ); const logoHomeLink = ( - - - - ); - - const menuButton = ( - ); - const appLinksPopover = ( - - {menuButton} - - - - - ); - const radius = useThemeContext().radius; return ( @@ -92,12 +73,16 @@ export default function MainMenu(props: Props) { {logoHomeLink} - {claimString} + + {claimString} + - + diff --git a/src/components/App/MainMenu/SessionMenuItem.tsx b/src/components/App/MainMenu/SessionMenuItem.tsx index b09b70b23..de5bc11f1 100644 --- a/src/components/App/MainMenu/SessionMenuItem.tsx +++ b/src/components/App/MainMenu/SessionMenuItem.tsx @@ -1,7 +1,7 @@ import { signIn, signOut, useSession } from "next-auth/react"; import { t } from "ttag"; import { UserIcon } from "../../icons/ui-elements"; -import { DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; +import { Box, Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; import React from "react"; function AuthenticatedMenuContent() { @@ -11,7 +11,6 @@ function AuthenticatedMenuContent() { return ( <> - {!session?.user?.image && } @@ -30,32 +29,23 @@ function AuthenticatedMenuContent() { {t`You’re signed in as ${username}.`} - {t`Sign out`} + ); } function NonAuthenticatedMenuContent() { const handleSignIn = React.useCallback(() => signIn("osm"), []); - return ( - <> - - - {t`Sign in with OpenStreetMap to enable more features.`} - - {t`Sign in`} - - ); + return ; } function LoadingMenuContent() { return ( - + - + ); } diff --git a/src/components/App/MainMenu/useAppLinks.tsx b/src/components/App/MainMenu/useAppLinks.tsx new file mode 100644 index 000000000..31b25c25a --- /dev/null +++ b/src/components/App/MainMenu/useAppLinks.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useCurrentApp } from "../../../lib/context/AppContext"; +import { useUniqueSurveyId } from "../../../lib/context/useUniqueSurveyId"; +import { useCurrentMappingEvent } from "../../../lib/context/useCurrentMappingEvent"; +import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; +import { insertPlaceholdersToAddPlaceUrl } from "../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl"; +import type { IApp } from "../../../lib/model/ac/App"; +import type IAppLink from '~/lib/model/ac/IAppLink'; +import type { MappingEvent } from "../../../lib/model/ac/MappingEvent"; + + +export function useAppLinks() { + const { data: joinedMappingEvent } = useCurrentMappingEvent(); + const app = useCurrentApp(); + const uniqueSurveyId = useUniqueSurveyId(); + const { + related: { appLinks } = {}, + } = app; + + const links = React.useMemo( + () => Object.values(appLinks ?? {}) + .map((link) => expandLinkMetadata(link, app, uniqueSurveyId, joinedMappingEvent) + ), + [app, appLinks, joinedMappingEvent, uniqueSurveyId] + ); + return links; +} + +export function expandLinkMetadata( + link: IAppLink, + app: IApp, + uniqueSurveyId: string, + joinedMappingEvent?: MappingEvent, +) { + const baseUrl = `https://${app._id}/`; + const localizedUrl = translatedStringFromObject(link.url); + const url = + link.url && + insertPlaceholdersToAddPlaceUrl( + baseUrl, + localizedUrl, + joinedMappingEvent, + uniqueSurveyId, + ); + const label = translatedStringFromObject(link.label); + const badgeLabel = translatedStringFromObject(link.badgeLabel); + const isExternal = localizedUrl?.startsWith("http"); + return { + ...link, + url, + label, + badgeLabel, + isExternal, + }; +} diff --git a/src/components/App/MapLayout.tsx b/src/components/App/MapLayout.tsx index aa465dbae..ebc22ef7c 100644 --- a/src/components/App/MapLayout.tsx +++ b/src/components/App/MapLayout.tsx @@ -9,7 +9,7 @@ import dynamic from 'next/dynamic' import { AppContext } from '../../lib/context/AppContext' import LoadableMapView from '../Map/LoadableMapView' import HeadMetaTags from './HeadMetaTags' -import MainMenu from './MainMenu/MainMenu' +import MainToolbar from './MainMenu/MainToolbar' import ErrorBoundary from '../shared/ErrorBoundary' import { GlobalMapContextProvider } from '../Map/GlobalMapContext' import { MapFilterContextProvider } from '../Map/filter/MapFilterContext' @@ -17,7 +17,7 @@ import { isFirstStart } from '../../lib/util/savedState' import { Theme, ThemePanel } from '@radix-ui/themes' import { ThemeProvider } from 'next-themes' import { useExpertMode } from './MainMenu/useExpertMode' -import { useHotkeys } from '@blueprintjs/core' +import { HotkeyConfig, useHotkeys } from '@blueprintjs/core' // onboarding is a bad candidate for SSR, as it dependently renders based on a local storage setting // these diverge between server and client (see: https://nextjs.org/docs/messages/react-hydration-error) @@ -57,12 +57,13 @@ export default function MapLayout({ const [containerRef, { width, height }] = useMeasure({ debounce: 100 }) const { toggleExpertMode } = useExpertMode() - const expertModeHotkeys = React.useMemo(() => [ + const expertModeHotkeys: HotkeyConfig[] = React.useMemo(() => [ { - combo: 'l', + combo: 'mod+e', global: true, label: 'Toggle expert mode', onKeyDown: toggleExpertMode, + allowInInput: false, }, ], [toggleExpertMode]); useHotkeys(expertModeHotkeys); @@ -75,7 +76,7 @@ export default function MapLayout({ - {clientSideConfiguration && } {firstStart && } diff --git a/src/components/shared/VectorImage.tsx b/src/components/shared/VectorImage.tsx index c14075971..dc3d1c7c3 100644 --- a/src/components/shared/VectorImage.tsx +++ b/src/components/shared/VectorImage.tsx @@ -12,15 +12,16 @@ export const shadowCSS = css` filter: drop-shadow(0 2px 0px rgba(0, 0, 0, 0.06)) drop-shadow(0 5px 10px rgba(0, 0, 0, 0.06)); ` -const Container = styled.span < -ContainerProps >` -display: inline-block; -vertical-align: middle; -svg { - ${(p) => (p.hasShadow === false ? null : shadowCSS)} - max-height: ${(p) => p.maxHeight || '1.5em'}; - max-width: ${(p) => p.maxWidth || '1.5em'}; -} +const Container = styled.div` + /* Centers the contained image verticallly */ + line-height: 0; + display: inline-block; + vertical-align: middle; + svg { + ${(p) => (p.hasShadow === false ? null : shadowCSS)} + max-height: ${(p) => p.maxHeight || '1.5em'}; + max-width: ${(p) => p.maxWidth || '1.5em'}; + } ` type Props = diff --git a/src/lib/model/ac/App.ts b/src/lib/model/ac/App.ts index ab3ec0834..c59a67470 100644 --- a/src/lib/model/ac/App.ts +++ b/src/lib/model/ac/App.ts @@ -1,15 +1,5 @@ -import type { LocalizedString } from '@sozialhelden/a11yjson' import type { ClientSideConfiguration } from './ClientSideConfiguration' - -export interface IAppLink { - _id: string; - appId: string; - label: LocalizedString; - badgeLabel?: LocalizedString; - url: LocalizedString; - order?: number; - tags?: string[]; -} +import IAppLink from './IAppLink'; export interface IApp { _id: string; diff --git a/src/lib/model/ac/IAppLink.ts b/src/lib/model/ac/IAppLink.ts new file mode 100644 index 000000000..f3965f3a8 --- /dev/null +++ b/src/lib/model/ac/IAppLink.ts @@ -0,0 +1,14 @@ +import type { LocalizedString } from "@sozialhelden/a11yjson"; + +export type ImportanceValue = 'alwaysVisible' | 'advertisedIfPossible' | 'insignificant'; + +export default interface IAppLink { + _id: string; + appId: string; + label: LocalizedString; + badgeLabel?: LocalizedString; + url: LocalizedString; + order?: number; + tags?: string[]; + importance?: ImportanceValue; +} From fca8c168ba836c8ec4e6d53fb72c22a9579b03c8 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 16 Dec 2024 23:27:10 +0100 Subject: [PATCH 15/27] Radix-ify the onboarding --- src/components/App/MapLayout.tsx | 4 +- .../Onboarding/LocationFailedStep.tsx | 7 +- .../Onboarding/LocationNoPermissionStep.tsx | 21 +- src/components/Onboarding/LocationStep.tsx | 87 +---- .../Onboarding/OnboardingDialog.tsx | 268 +------------- src/components/Onboarding/OnboardingStep.tsx | 189 +++++----- .../Onboarding/components/LocationSearch.tsx | 346 ++++++++---------- src/components/Onboarding/language.ts | 18 +- 8 files changed, 279 insertions(+), 661 deletions(-) diff --git a/src/components/App/MapLayout.tsx b/src/components/App/MapLayout.tsx index ebc22ef7c..063ab9884 100644 --- a/src/components/App/MapLayout.tsx +++ b/src/components/App/MapLayout.tsx @@ -53,6 +53,8 @@ export default function MapLayout({ const app = React.useContext(AppContext) const { clientSideConfiguration } = app || {} const firstStart = isFirstStart() + const router = useRouter() + const isOnboardingVisible = firstStart || router.pathname === '/onboarding' const [containerRef, { width, height }] = useMeasure({ debounce: 100 }) @@ -79,7 +81,7 @@ export default function MapLayout({ {clientSideConfiguration && } - {firstStart && } + {isOnboardingVisible && }
    -
    + -
    + ) } diff --git a/src/components/Onboarding/LocationNoPermissionStep.tsx b/src/components/Onboarding/LocationNoPermissionStep.tsx index f8ee53a8e..1fd061d58 100644 --- a/src/components/Onboarding/LocationNoPermissionStep.tsx +++ b/src/components/Onboarding/LocationNoPermissionStep.tsx @@ -3,19 +3,12 @@ import styled from 'styled-components' import { AppContext } from '../../lib/context/AppContext' import StyledMarkdown from '../shared/StyledMarkdown' import { LocationNoPermissionPrimaryText, selectProductName } from './language' -import { LocationSearch } from './components/LocationSearch' import type { PhotonResultFeature } from '../../lib/fetchers/fetchPhotonFeatures' import { getLocationSettingsUrl } from '../../lib/goToLocationSettings' import { LocationContainer } from './components/LocationContainer' +import { Box, Button, Flex } from '@radix-ui/themes' +import { t } from 'ttag' -const Container = styled(LocationContainer)` - .footer { - > .input, - > .button { - flex: 1; - } - } -` export const LocationNoPermissionStep: FC<{ onSubmit: (location?: PhotonResultFeature) => unknown; @@ -23,16 +16,16 @@ export const LocationNoPermissionStep: FC<{ const { clientSideConfiguration } = useContext(AppContext) ?? { } const [url] = getLocationSettingsUrl() return ( - + {LocationNoPermissionPrimaryText( selectProductName(clientSideConfiguration), url, )} -
    - -
    -
    + + + + ) } diff --git a/src/components/Onboarding/LocationStep.tsx b/src/components/Onboarding/LocationStep.tsx index 896aa4b8b..a44066e58 100644 --- a/src/components/Onboarding/LocationStep.tsx +++ b/src/components/Onboarding/LocationStep.tsx @@ -12,69 +12,7 @@ import { LocationStepPrimaryText, } from './language' import { getLocationSettingsUrl } from '../../lib/goToLocationSettings' -import { LocationContainer } from './components/LocationContainer' - -const Container = styled(LocationContainer)` - .footer { - > .accept { - flex: 0; - display: flex; - gap: 10px; - min-width: fit-content; - justify-items: center; - align-items: center; - padding: 0 24px; - - > .text { - transition: 0.25s transform ease; - transform: translateX(12px); - } - - &.active { - > .text { - transform: translateX(0); - } - - > .loader { - opacity: 1; - } - } - - > .loader { - width: 22px; - height: 22px; - border: 3px solid #fff; - border-bottom-color: transparent; - border-radius: 50%; - display: inline-block; - box-sizing: border-box; - animation: rotation 1s linear infinite; - transition: 0.25s opacity ease; - opacity: 0; - } - } - > .deny { - flex: 1; - } - - @keyframes rotation { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } - } -` - -const ReducedSecondaryButton = styled(SecondaryButton)` - // width is 100% - width: auto; - // conforms more with the call to action button - padding: 0.5em 0.75em; - border-radius: 0.5rem; -` +import { Box, Button, Flex, Spinner } from '@radix-ui/themes' type Stage = 'idle' | 'acquiring' | 'failed-not-exited' @@ -118,8 +56,6 @@ export const LocationStep: FC<{ if (stage.retries >= maxRetries) { onFailed() return - onFailed() - return } setStage({ stage: 'failed-not-exited', retries: stage.retries + 1 }) return @@ -127,7 +63,7 @@ export const LocationStep: FC<{ onGeneralError(error) }) - }, [onAccept, stage, setStage, onGeneralError, maxRetries, onFailed]) + }, [onAccept, stage, onGeneralError, maxRetries, onFailed]) const isAcquiring = stage.stage === 'acquiring' const [url] = getLocationSettingsUrl() @@ -138,21 +74,22 @@ export const LocationStep: FC<{ }` return ( - + {primaryText} -
    - + +
    -
    + {stage.stage === 'acquiring' && } + + + ); }; diff --git a/src/components/Onboarding/OnboardingDialog.tsx b/src/components/Onboarding/OnboardingDialog.tsx index e2c5cc044..3a8a00f56 100644 --- a/src/components/Onboarding/OnboardingDialog.tsx +++ b/src/components/Onboarding/OnboardingDialog.tsx @@ -11,256 +11,7 @@ import { LocationStep } from './LocationStep' import { OnboardingStep } from './OnboardingStep' import type { PhotonResultFeature } from '../../lib/fetchers/fetchPhotonFeatures' import { log } from '../../lib/util/logger' - -const StyledModalDialog = styled(ModalDialog)` - isolation: isolate; - @keyframes fadeIn { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } - } - - @keyframes appear { - 0% { - transform: scale3d(1.2, 1.2, 1); - opacity: 0; - } - - 100% { - transform: scale3d(1, 1, 1); - opacity: 1; - } - } - - @keyframes highlight { - 0% { - transform: scale3d(1, 1, 1); - background-color: ${colors.linkColor}; - } - - 5% { - transform: scale3d(1.05, 1.05, 1); - background-color: ${colors.linkColorDarker}; - } - - 10% { - transform: scale3d(1, 1, 1); - background-color: ${colors.linkColorDarker}; - } - - 100% { - transform: scale3d(1, 1, 1); - background-color: ${colors.linkColor}; - } - } - - @media (max-width: 320px) { - font-size: 90%; - } - - .modal-dialog-fullscreen-overlay { - background-color: transparent; - } - - .close-dialog { - display: none; - } - - .modal-dialog-content { - display: flex; - flex-direction: column; - max-width: 80%; - max-width: 800px; - padding: 15px; - overflow: auto; - border-radius: 20px; - background-color: ${colors.neutralBackgroundColor}; - color: ${colors.textColor}; - box-shadow: 0 5px 30px rgba(0, 0, 0, 0.15), 0 2px 5px rgba(0, 0, 0, 0.3); - animation: fadeIn 0.5s linear; - width: 100%; /* Fix IE 11. @TODO Safe to be moved to ModalDialog component? */ - - @media (max-height: 478px) { - font-size: 0.8rem; - #wheelmap-icon-descriptions { - margin: 0; - } - } - - .logo { - width: 250px; - height: 53px; /* IE 11 does not preserve aspect ratio correctly and needs a fixed height. */ - - @media (max-width: 414px) { - width: 200px; - height: 42px; - } - - @media (max-height: 478px) { - width: 150px; - height: 32px; - } - - object-fit: contain; - } - - .claim { - @media (min-width: 414px) { - font-size: 1.25rem; - } - @media (min-height: 414px) { - font-size: 1.25rem; - } - } - - > footer, - > header, - > section.main-section { - text-align: center; - max-width: 560px; - align-self: center; - } - - ul { - display: flex; - flex-direction: row; - justify-content: center; - - list-style-type: none; - - @media (orientation: portrait) { - align-items: start; - } - @media (orientation: landscape) { - margin: 1rem 0; - } - - /* @media (max-width: 768px) { - margin: 0 !important; - } */ - @media (max-width: 414px) { - flex-direction: column !important; - } - @media (max-height: 414px) { - flex-wrap: wrap; - } - - li { - /* flex: 1; */ - display: flex; - flex-direction: column; - - justify-content: space-around; - align-items: center; - text-align: center; - background-color: transparent; - overflow-x: hidden; - overflow-wrap: break-word; - - @media (max-width: 414px), (max-height: 414px) { - height: 3em; - flex-direction: row !important; - text-align: left !important; - /* padding: 0 10px !important; */ - - figure { - margin-right: 0.5rem; - min-width: 30px; - width: 30px; - height: 30px; - } - } - - &:not(:last-child) { - margin-right: 5px; - } - - figure { - top: 0; - left: 0; - } - - &.ac-marker-yes { - color: ${colors.positiveColorDarker}; - } - - &.ac-marker-limited { - color: ${colors.warningColorDarker}; - } - - &.ac-marker-no { - color: ${colors.negativeColorDarker}; - } - - &.ac-marker-unknown { - color: ${colors.markers.foreground.unknown}; - .ac-big-icon-marker { - background-color: ${colors.markers.background.unknown}; - } - } - - header { - margin-bottom: 10px; - max-width: 10rem; - @media (max-width: 768px) { - margin-bottom: 0px !important; - flex: 1; - } - @media (min-width: 769px) { - height: 3em; - } - min-height: 2em; - display: flex; - flex-direction: column; - justify-content: center; - } - - footer { - font-size: 90%; - opacity: 0.7; - display: none; - } - } - } - - .ac-big-icon-marker { - display: flex; - justify-content: center; - align-items: center; - position: relative; - transform: none; - animation: none; - margin: 15px; - left: auto; - top: auto; - svg { - opacity: 0.6; - } - } - } - - .button-footer { - display: flex; - flex-direction: column; - min-height: 80px; - - .button-continue-with-cookies { - margin: 0.5em; - } - } - - .cookies-footer { - opacity: 0.5; - } - - p { - margin: 1em; - } -` +import { Dialog } from '@radix-ui/themes' type OnboardingState = | 'onboarding' @@ -313,7 +64,7 @@ const OnboardingDialog: React.FC = ({ onClose }) => { onClose(location) }, }), - [setStep, step, onClose], + [step, onClose], ) const viewSelector = useCallback( @@ -350,14 +101,15 @@ const OnboardingDialog: React.FC = ({ onClose }) => { ) return ( - - {viewSelector(step)} - + + {viewSelector(step)} + + ) } diff --git a/src/components/Onboarding/OnboardingStep.tsx b/src/components/Onboarding/OnboardingStep.tsx index a0cfdb2f6..5e77a9253 100644 --- a/src/components/Onboarding/OnboardingStep.tsx +++ b/src/components/Onboarding/OnboardingStep.tsx @@ -1,128 +1,107 @@ -import * as React from 'react' -import { useEffect } from 'react' -import { AppContext } from '../../lib/context/AppContext' +import * as React from "react"; +import { useEffect } from "react"; +import { AppContext } from "../../lib/context/AppContext"; import { accessibilityDescription, accessibilityName, -} from '../../lib/model/accessibility/accessibilityStrings' -import ChevronRight from '../icons/actions/ChevronRight' -import { CallToActionButton } from '../shared/Button' -import Icon from '../shared/Icon' -import VectorImage from '../shared/VectorImage' +} from "../../lib/model/accessibility/accessibilityStrings"; +import Icon from "../shared/Icon"; +import VectorImage from "../shared/VectorImage"; import { selectHeaderMarkdownHTML, selectProductName, startButtonCaption, - unknownAccessibilityIncentiveText, -} from './language' +} from "./language"; +import { Box, Button, Card, Dialog, Flex, Grid, Text } from "@radix-ui/themes"; +import { YesNoLimitedUnknown } from "../../lib/model/ac/Feature"; +import { t } from "ttag"; export const OnboardingStep: React.FC<{ onClose?: () => unknown; -}> = ({ onClose = () => { } }) => { - const { clientSideConfiguration } = React.useContext(AppContext) ?? { } - const headerMarkdownHTML = selectHeaderMarkdownHTML(clientSideConfiguration) +}> = ({ onClose = () => {} }) => { + const { clientSideConfiguration } = React.useContext(AppContext) ?? {}; + const headerMarkdownHTML = selectHeaderMarkdownHTML(clientSideConfiguration); - const callToActionButton = React.createRef() + const callToActionButton = React.createRef(); const handleClose = () => { // Prevent that touch up opens a link underneath the primary button after closing // the onboarding dialog - setTimeout(() => onClose(), 10) - } + setTimeout(() => onClose(), 10); + }; useEffect(() => { setTimeout(() => { - callToActionButton.current?.focus() - }, 100) - }, [callToActionButton]) + callToActionButton.current?.focus(); + }, 100); + }, [callToActionButton]); + + // translator: Button caption shown on the onboarding screen. To find it, click the logo at the top. + const startButtonCaption = t`Okay, let’s go!` return ( <> -
    - + - {headerMarkdownHTML && ( -

    - )} -

    -
    -
      -
    • - -
      {accessibilityName('yes')}
      -
      {accessibilityDescription('yes')}
      -
    • -
    • - -
      {accessibilityName('limited')}
      -
      {accessibilityDescription('limited')}
      -
    • -
    • - -
      {accessibilityName('no')}
      -
      {accessibilityDescription('no')}
      -
    • -
    • - -
      {accessibilityName('unknown')}
      -
      {unknownAccessibilityIncentiveText}
      -
    • -
    -
    -
    - - {startButtonCaption} - - -
    + {headerMarkdownHTML && ( + + )} + + + + + + + + + + + - ) + ); +}; +function AccessibilityCard(props: { value: YesNoLimitedUnknown }) { + const { value } = props; + + // translator: Shown on the onboarding screen. To find it, click the logo at the top. + const unknownAccessibilityIncentiveText = t`Help out by marking places!` + + return + + + + + + {accessibilityName(value)} + {accessibilityDescription(value) || unknownAccessibilityIncentiveText} + + + ; } + diff --git a/src/components/Onboarding/components/LocationSearch.tsx b/src/components/Onboarding/components/LocationSearch.tsx index 24fb01642..132163b8c 100644 --- a/src/components/Onboarding/components/LocationSearch.tsx +++ b/src/components/Onboarding/components/LocationSearch.tsx @@ -1,225 +1,193 @@ +import { FC, startTransition, useContext, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import type { FeatureCollection, Point } from "geojson"; +import { center as turfCenter } from "@turf/turf"; +import fetchPhotonFeatures, { + type PhotonResultFeature, +} from "../../../lib/fetchers/fetchPhotonFeatures"; +import { SearchConfirmText, SearchSkipText } from "../language"; +import CountryContext from "../../../lib/context/CountryContext"; +import fetchCountryGeometry from "../../../lib/fetchers/fetchCountryGeometry"; +import { useCurrentLanguageTagStrings } from "../../../lib/context/LanguageTagContext"; +import { Button, ChevronDownIcon, DropdownMenu } from "@radix-ui/themes"; +import { t } from "ttag"; import { - FC, useContext, useMemo, useRef, useState, -} from 'react' -import { - useFloating, - useClick, - useDismiss, - useRole, - useListNavigation, - useInteractions, - FloatingFocusManager, - offset, - flip, - size, - autoUpdate, - FloatingPortal, -} from '@floating-ui/react' -import useSWR from 'swr' -import type { FeatureCollection, Point } from 'geojson' -import { center as turfCenter } from '@turf/turf' -import fetchPhotonFeatures, { type PhotonResultFeature } from '../../../lib/fetchers/fetchPhotonFeatures' -import { CallToActionButton } from '../../shared/Button' -import { SearchConfirmText, SearchSkipText } from '../language' -import CountryContext from '../../../lib/context/CountryContext' -import fetchCountryGeometry from '../../../lib/fetchers/fetchCountryGeometry' -import colors from '../../../lib/util/colors' -import { useCurrentLanguageTagStrings } from '../../../lib/context/LanguageTagContext' + Combobox, + ComboboxItem, + ComboboxLabel, + ComboboxList, + ComboboxProvider, +} from "@ariakit/react"; +import * as RadixSelect from "@radix-ui/react-select"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; + -export const LocationSearch: FC<{ onUserSelection: (selection?: PhotonResultFeature) => unknown }> = ({ onUserSelection }) => { - const [{ value, origin, selection }, setValue] = useState({ value: '', origin: 'system', selection: '' }) +export const LocationSearch: FC<{ + onUserSelection: (selection?: PhotonResultFeature) => unknown; +}> = ({ onUserSelection }) => { + const comboboxRef = useRef(null); + const listboxRef = useRef(null); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [value, setValue] = useState(""); - const region = useContext(CountryContext) - const { data: regionGeometry } = useSWR({ }, fetchCountryGeometry) + const region = useContext(CountryContext); + const { data: regionGeometry } = useSWR( + {}, + fetchCountryGeometry, + ); const bias = useMemo(() => { if (!regionGeometry) { - return undefined + return undefined; } - const location = regionGeometry.features.find((x) => x.properties?.['ISO3166-1'] === region) + const location = regionGeometry.features.find( + (x) => x.properties?.["ISO3166-1"] === region, + ); - let center: Point | undefined + let center: Point | undefined; if (location) { - if ('centroid' in location) { - center = location.centroid as Point + if ("centroid" in location) { + center = location.centroid as Point; } else { - center = turfCenter(location).geometry + center = turfCenter(location).geometry; } } const computedBias = { lon: center?.coordinates[0].toString(), lat: center?.coordinates[1].toString(), - zoom: '5', - location_bias_scale: '1.0', - } as const - return computedBias - }, [regionGeometry, region]) + zoom: "5", + location_bias_scale: "1.0", + } as const; + return computedBias; + }, [regionGeometry, region]); - const languageTag = useCurrentLanguageTagStrings()?.[0] || 'en'; - const { data } = useSWR({ languageTag, query: value, additionalQueryParameters: { layer: 'city', ...bias } }, fetchPhotonFeatures) - const filteredData = useMemo(() => { + const languageTag = useCurrentLanguageTagStrings()?.[0] || "en"; + const { data } = useSWR( + { + languageTag, + query: searchValue, + additionalQueryParameters: { layer: "city", ...bias }, + }, + fetchPhotonFeatures, + ); + const filteredData: ({ key: string } & PhotonResultFeature)[] = useMemo(() => { if (!data) { - return [] + return [ + { + key: "Tokyo / Japan", + properties: { + name: "Tokyo", + country: "Japan", + }, + }, + ]; } - const bucket: ({ key: string } & PhotonResultFeature)[] = [] + const bucket: ({ key: string } & PhotonResultFeature)[] = []; for (let i = 0; i < data.features.length; i += 1) { - const entry = data.features[i] + const entry = data.features[i]; if (!entry.properties) { - continue + continue; } - const key = `${entry.properties.city ?? entry.properties.name} / ${entry.properties.country}` + const key = `${entry.properties.city ?? entry.properties.name} / ${entry.properties.country}`; if (bucket.some((x) => x.key === key)) { - continue + continue; } bucket.push({ key, ...entry, - }) + }); } - return bucket - }, [data]) - - const [isOpen, setIsOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(null) - const [selectedIndex, setSelectedIndex] = useState(null) - - const { refs, floatingStyles, context } = useFloating({ - placement: 'bottom-start', - open: isOpen, - onOpenChange: setIsOpen, - whileElementsMounted: autoUpdate, - middleware: [ - offset(5), - flip({ padding: 10 }), - size({ - apply({ rects, elements, availableHeight }) { - Object.assign(elements.floating.style, { - maxHeight: `${availableHeight}px`, - minWidth: `${rects.reference.width}px`, - }) - }, - padding: 10, - }), - ], - }) - - const listRef = useRef>([]) - const isTypingRef = useRef(false) - - const click = useClick(context, { event: 'mousedown' }) - const dismiss = useDismiss(context) - const role = useRole(context, { role: 'listbox' }) - const listNav = useListNavigation(context, { - listRef, - activeIndex, - selectedIndex, - onNavigate: setActiveIndex, - // This is a large list, allow looping. - loop: true, - }) - - const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( - [dismiss, role, listNav, click], - ) - - const handleSelect = (index: number) => { - const { key } = filteredData[index] - if (selectedIndex === index) { - setValue({ value: key, origin: 'selection', selection: key }) - setSelectedIndex(-1) - return - } - - setValue({ value, origin, selection: key }) - setSelectedIndex(index) - } + return bucket; + }, [data]); return ( <> - { setValue({ value: e.target.value, origin: 'user', selection: '' }) }} - onKeyDown={(evt) => { - if (evt.key === 'Enter') { - evt.preventDefault() - handleSelect(0) - } - }} - {...getReferenceProps()} - ref={refs.setReference} - /> - { onUserSelection(filteredData.find((x) => x.key === selection)) }}> - { selection.length > 0 ? SearchConfirmText : SearchSkipText } - - {data && origin === 'user' && ( - - -
    - {filteredData.map((feature, i) => ( -
    { - listRef.current[i] = node - }} - role="option" - tabIndex={i === activeIndex ? 0 : -1} - aria-selected={i === selectedIndex && i === activeIndex} - style={{ - padding: 10, - cursor: 'default', - zIndex: 10001, - }} - {...getItemProps({ - // Handle pointer select. - onClick() { - handleSelect(i) - }, - // Handle keyboard select. - onKeyDown(evt) { - if (evt.key === 'Enter') { - evt.preventDefault() - handleSelect(i) - } + onValueChange={setValue} + open={open} + onOpenChange={setOpen} + > + { + startTransition(() => { + setSearchValue(value); + }); + }} + > + {t`Enter a location name`} - if (evt.key === ' ' && !isTypingRef.current) { - evt.preventDefault() - handleSelect(i) - } - }, - })} + + + + + + + + + role="dialog" + aria-label="Languages" + position="popper" + className="popover" + sideOffset={4} + alignOffset={-16} + > +
    +
    + +
    + { + event.preventDefault(); + event.stopPropagation(); + }} + /> +
    + + {filteredData.map((match) => ( + - {feature.key} - - {i === selectedIndex ? ' ✓' : ''} - -
    + + {match.properties.name} + + ))} -
    -
    -
    - )} + + + + + - ) -} + ); +}; diff --git a/src/components/Onboarding/language.ts b/src/components/Onboarding/language.ts index c4575fcbb..d5ecbcaa6 100644 --- a/src/components/Onboarding/language.ts +++ b/src/components/Onboarding/language.ts @@ -29,15 +29,6 @@ export const selectHeaderMarkdownHTML = ( return headerMarkdown && parse(translatedStringFromObject(headerMarkdown) ?? '') } -// translator: Shown on the onboarding screen. To find it, click the logo at the top. -export const unknownAccessibilityIncentiveText = t`Help out by marking places!` - -// translator: Button caption shown on the onboarding screen. To find it, click the logo at the top. -export const startButtonCaption = t`Okay, let’s go!` - -// translator: The alternative description of the app logo for screen readers -export const appLogoAltText = t`App Logo` - /** * Location Step Texts */ @@ -72,14 +63,9 @@ export const LocationNoPermissionPrimaryText = ( productName: string, uri: string, ) => t` -# No Problem! - -If you change our mind at any time, you can grant location permission for ${productName} at any time through -[your devices' location setting](${uri}) - -You can still use all features of Wheelmap. +**No Problem!** If you change your mind, grant location permissions for ${productName} in [your deviceʼs location settings](${uri}). -Do you want to start in the center of city instead? +You can still use all features of the app. ` /** From beb5703f791a494ba9d26f5c55955a6606f72103 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 16 Dec 2024 23:27:29 +0100 Subject: [PATCH 16/27] Fix a linter error in App.ts --- src/lib/model/ac/App.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/model/ac/App.ts b/src/lib/model/ac/App.ts index c59a67470..4608d3212 100644 --- a/src/lib/model/ac/App.ts +++ b/src/lib/model/ac/App.ts @@ -1,5 +1,5 @@ import type { ClientSideConfiguration } from './ClientSideConfiguration' -import IAppLink from './IAppLink'; +import type IAppLink from './IAppLink'; export interface IApp { _id: string; From 2e5a22515d4491476ebf2967905e8e687adff649 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 16 Dec 2024 23:36:53 +0100 Subject: [PATCH 17/27] Improve main menu styling, add responsive breakpoint --- src/components/App/MainMenu/AutoLink.tsx | 2 +- src/components/App/MainMenu/MainMenuLinks.tsx | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/App/MainMenu/AutoLink.tsx b/src/components/App/MainMenu/AutoLink.tsx index 19ed8c50a..c1e17fa6c 100644 --- a/src/components/App/MainMenu/AutoLink.tsx +++ b/src/components/App/MainMenu/AutoLink.tsx @@ -67,7 +67,7 @@ export function AutoLink({ if (typeof url === "string") { const buttonProps: ButtonProps = { - variant: tags?.includes("primary") ? "solid" : "soft", + variant: tags?.includes("primary") ? "solid" : "ghost", }; const isExternal = url.startsWith("http"); diff --git a/src/components/App/MainMenu/MainMenuLinks.tsx b/src/components/App/MainMenu/MainMenuLinks.tsx index 1246e9ef8..485032b75 100644 --- a/src/components/App/MainMenu/MainMenuLinks.tsx +++ b/src/components/App/MainMenu/MainMenuLinks.tsx @@ -10,6 +10,7 @@ import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons"; import { useRouter } from "next/router"; import { t } from "ttag"; import { AutoLink } from "./AutoLink"; +import { useWindowSize } from "../../../lib/util/useViewportSize"; export default function MainMenuLinks() { const router = useRouter(); @@ -31,11 +32,18 @@ export default function MainMenuLinks() { ); + const windowSize = useWindowSize(); const appLinks = useAppLinks(); + const isBigViewport = windowSize.width >= 1024; + + // We show some links outside, and some inside the menu. + // First, we sort the links into categories: const alwaysVisible = appLinks.filter(l => l.importance === "alwaysVisible"); const advertisedIfPossible = appLinks.filter(l => !l.importance || l.importance === "advertisedIfPossible"); const insignificant = appLinks.filter(l => l.importance === "insignificant"); - const shownInMenu = [...advertisedIfPossible, ...insignificant].sort((a, b) => (a.order || 0) - (b.order || 0)); + + const shownInMenu = [...isBigViewport ? [] : advertisedIfPossible, ...insignificant].sort((a, b) => (a.order || 0) - (b.order || 0)); + const shownInToolbar = [...isBigViewport ? advertisedIfPossible : [], ...alwaysVisible].sort((a, b) => (a.order || 0) - (b.order || 0)); const appLinksPopover = ( @@ -47,8 +55,8 @@ export default function MainMenuLinks() { ); - return - {alwaysVisible.map( + return + {shownInToolbar.map( (appLink) => )} {appLinksPopover} From 160dc6e2c02e4426f337f868fcc4bf205fcbc981 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 01:02:57 +0100 Subject: [PATCH 18/27] Fix a React hydration error & main menu keyboard focus --- src/components/App/AppStateLink.tsx | 11 ++-- src/components/App/MainMenu/AutoLink.tsx | 54 +++++++++++-------- src/components/App/MainMenu/MainMenuLinks.tsx | 3 +- src/components/App/MainMenu/MainToolbar.tsx | 5 +- src/components/App/useAppStateAwareHref.tsx | 8 +++ src/components/Map/filterLayers.ts | 2 +- src/lib/util/useViewportSize.ts | 24 ++++----- 7 files changed, 61 insertions(+), 46 deletions(-) create mode 100644 src/components/App/useAppStateAwareHref.tsx diff --git a/src/components/App/AppStateLink.tsx b/src/components/App/AppStateLink.tsx index c48578f38..1d6f23262 100644 --- a/src/components/App/AppStateLink.tsx +++ b/src/components/App/AppStateLink.tsx @@ -1,10 +1,11 @@ import Link from 'next/link' -import { type ComponentProps, useMemo } from 'react' -import { preserveSearchParams, useAppStateAwareRouter } from '../../lib/util/useAppStateAwareRouter' +import type { ComponentProps } from 'react' +import { useAppStateAwareHref } from './useAppStateAwareHref' -export const AppStateLink = ({ href, ...props }: ComponentProps) => { - const { searchParams, query } = useAppStateAwareRouter() - const extendedHref = useMemo(() => preserveSearchParams(href, searchParams, query), [href, searchParams, query]) +export const AppStateLink = ({ href, ...props }: ComponentProps) => { + const extendedHref = useAppStateAwareHref(href) return } + + diff --git a/src/components/App/MainMenu/AutoLink.tsx b/src/components/App/MainMenu/AutoLink.tsx index c1e17fa6c..b706fc21e 100644 --- a/src/components/App/MainMenu/AutoLink.tsx +++ b/src/components/App/MainMenu/AutoLink.tsx @@ -1,4 +1,4 @@ -import type React from "react"; +import React, { useCallback } from "react"; import styled from "styled-components"; import { useCurrentMappingEvent } from "../../../lib/context/useCurrentMappingEvent"; import Spinner from "../../ActivityIndicator/Spinner"; @@ -8,6 +8,9 @@ import { useExpertMode } from "./useExpertMode"; import type { expandLinkMetadata } from "./useAppLinks"; import { AppStateLink } from "../AppStateLink"; import Link from "next/link"; +import { Ref } from "colorjs.io/fn"; +import { useAppStateAwareHref } from "../useAppStateAwareHref"; +import { useRouter } from "next/router"; const Badge = styled.span` border-radius: 0.5rlh; @@ -17,35 +20,40 @@ const Badge = styled.span` margin: 0.1rem; `; -function JoinedEventLink(props: { label?: string; url?: string, asMenuItem?: boolean }) { +function JoinedEventLink(props: { label?: string; url?: string; asMenuItem?: boolean; buttonProps?: ButtonProps }) { const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent(); - if (isValidating) { - return ; - } - const href = joinedMappingEvent ? `/events/${joinedMappingEvent._id}` : "/events"; const label = joinedMappingEvent ? joinedMappingEvent.name : props.label; + const content = isValidating ? : label; + const router = useRouter(); + // XXX: This should be an , but using it would keyboard navigation, as there + // is a bug in Radix UI that appears to not update the ref correctly. + const hrefWithParams = useAppStateAwareHref(href); + const openHref = useCallback(() => { + router.push(hrefWithParams); + }, [router, hrefWithParams]); - return ( - - {label} - - - ); + return props.asMenuItem + ? + {content} + + : ; } -function ButtonOrMenuItem({ asMenuItem, buttonProps, children }: { asMenuItem?: boolean, buttonProps?: ButtonProps, children?: React.ReactNode }) { +const ButtonOrMenuItem = React.forwardRef( + ({ asMenuItem, buttonProps, children }: { asMenuItem?: boolean, buttonProps?: ButtonProps, children?: React.ReactNode }, + forwardedRef: React.Ref + ) => { return asMenuItem - ? + ? {children} - : ; - -} + : ; +}); export function AutoLink({ tags, @@ -54,22 +62,22 @@ export function AutoLink({ url, asMenuItem, }: ReturnType & { asMenuItem?: boolean }) { + const buttonProps: ButtonProps = { + variant: tags?.includes("primary") ? "solid" : "ghost", + highContrast: !tags?.includes("primary"), + }; const { isExpertMode } = useExpertMode(); const isEventsLink = tags?.includes("events"); if (isEventsLink) { - return ; + return ; } const isSessionLink = tags?.includes("session"); if (isSessionLink) { - return isExpertMode ? : null; + return isExpertMode ? : null; } if (typeof url === "string") { - const buttonProps: ButtonProps = { - variant: tags?.includes("primary") ? "solid" : "ghost", - }; - const isExternal = url.startsWith("http"); const LinkElement = isExternal ? Link : AppStateLink const link = diff --git a/src/components/App/MainMenu/MainMenuLinks.tsx b/src/components/App/MainMenu/MainMenuLinks.tsx index 485032b75..c3f8e4f3a 100644 --- a/src/components/App/MainMenu/MainMenuLinks.tsx +++ b/src/components/App/MainMenu/MainMenuLinks.tsx @@ -33,6 +33,7 @@ export default function MainMenuLinks() { ); const windowSize = useWindowSize(); + console.log('windowSize', windowSize); const appLinks = useAppLinks(); const isBigViewport = windowSize.width >= 1024; @@ -58,7 +59,7 @@ export default function MainMenuLinks() { return {shownInToolbar.map( (appLink) => - )} + )} {appLinksPopover} ; } diff --git a/src/components/App/MainMenu/MainToolbar.tsx b/src/components/App/MainMenu/MainToolbar.tsx index 981080bb4..d03240c2d 100644 --- a/src/components/App/MainMenu/MainToolbar.tsx +++ b/src/components/App/MainMenu/MainToolbar.tsx @@ -15,6 +15,7 @@ import { } from "@radix-ui/themes"; import { AppStateLink } from "../AppStateLink"; import MainMenuLinks from "./MainMenuLinks"; +import Link from "next/link"; type Props = { clientSideConfiguration: ClientSideConfiguration; @@ -50,7 +51,7 @@ export default function MainToolbar(props: Props) { const logoHomeLink = ( ); diff --git a/src/components/App/useAppStateAwareHref.tsx b/src/components/App/useAppStateAwareHref.tsx new file mode 100644 index 000000000..7ea9c933a --- /dev/null +++ b/src/components/App/useAppStateAwareHref.tsx @@ -0,0 +1,8 @@ +import { useMemo } from "react"; +import { useAppStateAwareRouter, preserveSearchParams } from "../../lib/util/useAppStateAwareRouter"; + +export function useAppStateAwareHref(href: string | import("url").UrlObject) { + const { searchParams, query } = useAppStateAwareRouter(); + const extendedHref = useMemo(() => preserveSearchParams(href, searchParams, query), [href, searchParams, query]); + return extendedHref; +} diff --git a/src/components/Map/filterLayers.ts b/src/components/Map/filterLayers.ts index 2ac168a36..a0d2894ce 100644 --- a/src/components/Map/filterLayers.ts +++ b/src/components/Map/filterLayers.ts @@ -164,7 +164,7 @@ export function filterLayers( layout: omit(localizedLayer.layout, 'line-z-offset'), paint: omit(localizedLayer.paint, 'line-occlusion-opacity'), } - console.log(enhancedLayer) + const accessibilityCloudLayer = { ...enhancedLayer, id: enhancedLayer.id + '-ac', source: 'ac:PlaceInfo', 'source-layer': 'place-infos' }; if (layer.id.startsWith('osm-selected')) { diff --git a/src/lib/util/useViewportSize.ts b/src/lib/util/useViewportSize.ts index db27717de..231cbd8d1 100644 --- a/src/lib/util/useViewportSize.ts +++ b/src/lib/util/useViewportSize.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import { useState } from 'react' import { useIsomorphicLayoutEffect } from '../../components/shared/useIsomorphicLayoutEffect' -const getBreakpointSize = (width: number) => (width <= 512 ? 'small' : 'big') /** * Determine the viewport size depending on the inner sizing of the window, useful @@ -9,28 +8,25 @@ const getBreakpointSize = (width: number) => (width <= 512 ? 'small' : 'big') */ export const useWindowSize = () => { const [windowSize, setWindowSize] = useState({ - width: global.window?.innerHeight, - height: global.window?.innerHeight, - size: getBreakpointSize(global.window?.innerWidth), + width: 1024, + height: 768, } as const) - const updateWindowSize = useCallback((windowWidth: number, windowHeight: number) => { - setWindowSize({ - width: windowWidth, - height: windowHeight, - size: getBreakpointSize(windowWidth), - }) - }, []) + + const hasWindow = typeof global.window !== 'undefined'; useIsomorphicLayoutEffect(() => { const handleResize = () => { - updateWindowSize(global.window?.innerWidth, global.window?.innerHeight) + setWindowSize({ + width: global.window?.innerWidth || 1024, + height: global.window?.innerHeight || 768, + }) } global.window?.addEventListener('resize', handleResize) handleResize(); return () => { global.window?.removeEventListener('resize', handleResize) } - }, [updateWindowSize]) + }, []) return windowSize } From 38bf04bdb15cbdfa7470045028d5f4e5131f55dd Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 21:18:26 +0100 Subject: [PATCH 19/27] Add biome as default VSCode formatter --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fe9547255..446da78a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,5 +26,8 @@ "prettier.enable": false, "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" } -} +} \ No newline at end of file From 9a12c207d68c81c0fe412c587434236cbfcaa48f Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 21:49:27 +0100 Subject: [PATCH 20/27] =?UTF-8?q?Code=20cosmetics=20for=20main=20menu=20?= =?UTF-8?q?=F0=9F=92=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 8 +- package.json | 2 +- src/components/App/MainMenu/AutoLink.tsx | 92 --------- .../App/MainMenu/GlobalActivityIndicator.tsx | 46 ----- src/components/App/MainMenu/LogoHomeLink.tsx | 25 +++ src/components/App/MainMenu/MainMenuLinks.tsx | 92 ++++----- src/components/App/MainMenu/MainToolbar.tsx | 48 ++--- src/components/App/MainMenu/SearchIcon.tsx | 23 --- .../App/MainMenu/SessionMenuItem.tsx | 59 ------ .../App/MainMenu/StatusBarBackground.ts | 5 - .../App/MainMenu/link-types/AutoLink.tsx | 60 ++++++ .../App/MainMenu/link-types/ErroneousLink.tsx | 16 ++ .../link-types/ExternalOrInternalAppLink.tsx | 29 +++ .../MainMenu/link-types/JoinedEventLink.tsx | 37 ++++ .../MainMenu/link-types/MenuItemOrButton.tsx | 22 ++ .../MainMenu/link-types/SessionElement.tsx | 64 ++++++ .../translateAndInterpolateAppLink.tsx | 40 ++++ src/components/App/MainMenu/useAppLinks.tsx | 95 +++++---- .../filter/useCreateMapFilterContextState.ts | 4 - .../Onboarding/LocationFailedStep.tsx | 38 ++-- .../Onboarding/LocationNoPermissionStep.tsx | 27 ++- .../Onboarding/components/LocationSearch.tsx | 193 ------------------ 22 files changed, 436 insertions(+), 589 deletions(-) delete mode 100644 src/components/App/MainMenu/AutoLink.tsx delete mode 100644 src/components/App/MainMenu/GlobalActivityIndicator.tsx create mode 100644 src/components/App/MainMenu/LogoHomeLink.tsx delete mode 100644 src/components/App/MainMenu/SearchIcon.tsx delete mode 100644 src/components/App/MainMenu/SessionMenuItem.tsx delete mode 100644 src/components/App/MainMenu/StatusBarBackground.ts create mode 100644 src/components/App/MainMenu/link-types/AutoLink.tsx create mode 100644 src/components/App/MainMenu/link-types/ErroneousLink.tsx create mode 100644 src/components/App/MainMenu/link-types/ExternalOrInternalAppLink.tsx create mode 100644 src/components/App/MainMenu/link-types/JoinedEventLink.tsx create mode 100644 src/components/App/MainMenu/link-types/MenuItemOrButton.tsx create mode 100644 src/components/App/MainMenu/link-types/SessionElement.tsx create mode 100644 src/components/App/MainMenu/translateAndInterpolateAppLink.tsx delete mode 100644 src/components/Onboarding/components/LocationSearch.tsx diff --git a/package-lock.json b/package-lock.json index 1bee6e937..af70e083f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -135,7 +135,7 @@ "rimraf": "^2.6.3", "source-map-explorer": "^2.5.3", "ttag-cli": "^1.9.3", - "typescript": "^5.5.4", + "typescript": "^5.7.2", "webdriverio": "^6.1.5" }, "engines": { @@ -25044,9 +25044,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 958740614..1948eed14 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "rimraf": "^2.6.3", "source-map-explorer": "^2.5.3", "ttag-cli": "^1.9.3", - "typescript": "^5.5.4", + "typescript": "^5.7.2", "webdriverio": "^6.1.5" }, "scripts": { diff --git a/src/components/App/MainMenu/AutoLink.tsx b/src/components/App/MainMenu/AutoLink.tsx deleted file mode 100644 index b706fc21e..000000000 --- a/src/components/App/MainMenu/AutoLink.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useCallback } from "react"; -import styled from "styled-components"; -import { useCurrentMappingEvent } from "../../../lib/context/useCurrentMappingEvent"; -import Spinner from "../../ActivityIndicator/Spinner"; -import SessionMenuItem from "./SessionMenuItem"; -import { Button, DropdownMenu, type ButtonProps } from "@radix-ui/themes"; -import { useExpertMode } from "./useExpertMode"; -import type { expandLinkMetadata } from "./useAppLinks"; -import { AppStateLink } from "../AppStateLink"; -import Link from "next/link"; -import { Ref } from "colorjs.io/fn"; -import { useAppStateAwareHref } from "../useAppStateAwareHref"; -import { useRouter } from "next/router"; - -const Badge = styled.span` - border-radius: 0.5rlh; - padding: 0.2rem 0.3rem; - font-size: 0.75rem; - text-transform: uppercase; - margin: 0.1rem; -`; - -function JoinedEventLink(props: { label?: string; url?: string; asMenuItem?: boolean; buttonProps?: ButtonProps }) { - const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent(); - - const href = joinedMappingEvent - ? `/events/${joinedMappingEvent._id}` - : "/events"; - - const label = joinedMappingEvent ? joinedMappingEvent.name : props.label; - const content = isValidating ? : label; - const router = useRouter(); - // XXX: This should be an , but using it would keyboard navigation, as there - // is a bug in Radix UI that appears to not update the ref correctly. - const hrefWithParams = useAppStateAwareHref(href); - const openHref = useCallback(() => { - router.push(hrefWithParams); - }, [router, hrefWithParams]); - - return props.asMenuItem - ? - {content} - - : ; -} - -const ButtonOrMenuItem = React.forwardRef( - ({ asMenuItem, buttonProps, children }: { asMenuItem?: boolean, buttonProps?: ButtonProps, children?: React.ReactNode }, - forwardedRef: React.Ref - ) => { - return asMenuItem - ? - {children} - - : ; -}); - -export function AutoLink({ - tags, - label, - badgeLabel, - url, - asMenuItem, -}: ReturnType & { asMenuItem?: boolean }) { - const buttonProps: ButtonProps = { - variant: tags?.includes("primary") ? "solid" : "ghost", - highContrast: !tags?.includes("primary"), - }; - const { isExpertMode } = useExpertMode(); - const isEventsLink = tags?.includes("events"); - if (isEventsLink) { - return ; - } - - const isSessionLink = tags?.includes("session"); - if (isSessionLink) { - return isExpertMode ? : null; - } - - if (typeof url === "string") { - const isExternal = url.startsWith("http"); - const LinkElement = isExternal ? Link : AppStateLink - const link = - {label} - {badgeLabel && {badgeLabel}} - ; - - return {link}; - } - - return null; -} diff --git a/src/components/App/MainMenu/GlobalActivityIndicator.tsx b/src/components/App/MainMenu/GlobalActivityIndicator.tsx deleted file mode 100644 index 7c32e1a41..000000000 --- a/src/components/App/MainMenu/GlobalActivityIndicator.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import debounce from 'lodash/debounce' -import * as React from 'react' -import { globalFetchManager } from '../../lib/FetchManager' -import Spinner from '../ActivityIndicator/Spinner' - -type Props = { - className?: string; -}; - -type State = { - isShown: boolean; - lastError?: Error | null; -}; - -export default class GlobalActivityIndicator extends React.Component { - state = { isShown: false } - - updateState = debounce( - () => { - const isShown = globalFetchManager.isLoading() - const { lastError } = globalFetchManager - this.setState({ isShown, lastError }) - }, - 50, - { maxWait: 50, leading: true }, - ) - - componentDidMount() { - globalFetchManager.addEventListener('start', this.updateState) - globalFetchManager.addEventListener('stop', this.updateState) - globalFetchManager.addEventListener('error', this.updateState) - } - - componentWillUnmount() { - globalFetchManager.removeEventListener('start', this.updateState) - globalFetchManager.removeEventListener('stop', this.updateState) - globalFetchManager.removeEventListener('error', this.updateState) - } - - render() { - if (this.state.isShown) { - return - } - return null - } -} diff --git a/src/components/App/MainMenu/LogoHomeLink.tsx b/src/components/App/MainMenu/LogoHomeLink.tsx new file mode 100644 index 000000000..fb53fe69f --- /dev/null +++ b/src/components/App/MainMenu/LogoHomeLink.tsx @@ -0,0 +1,25 @@ +import { Button } from "@radix-ui/themes"; +import Link from "next/link"; +import { t } from "ttag"; +import type { IBranding } from "../../../lib/model/ac/IBranding"; +import VectorImage from "../../shared/VectorImage"; + +export default function LogoHomeLink({ + branding, + productName, +}: { branding: IBranding | undefined; productName: string }) { + return ( + + ); +} diff --git a/src/components/App/MainMenu/MainMenuLinks.tsx b/src/components/App/MainMenu/MainMenuLinks.tsx index c3f8e4f3a..d5b5de040 100644 --- a/src/components/App/MainMenu/MainMenuLinks.tsx +++ b/src/components/App/MainMenu/MainMenuLinks.tsx @@ -1,65 +1,63 @@ -import * as React from "react"; -import { useAppLinks } from "./useAppLinks"; -import { - Button, - DropdownMenu, - Flex, - Theme, -} from "@radix-ui/themes"; import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons"; +import { Button, DropdownMenu, Flex, Theme } from "@radix-ui/themes"; import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; import { t } from "ttag"; -import { AutoLink } from "./AutoLink"; -import { useWindowSize } from "../../../lib/util/useViewportSize"; +import AutoLink from "./link-types/AutoLink"; +import { useAppLinks as useCurrentAppLinks } from "./useAppLinks"; export default function MainMenuLinks() { const router = useRouter(); const { pathname } = router; - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: when pathname changes, the effect must be triggered. - React.useEffect(() => { - setIsOpen(false); - }, [pathname]); + // biome-ignore lint/correctness/useExhaustiveDependencies: when pathname changes, close the main menu. + useEffect(() => setIsOpen(false), [pathname]); - const menuButton = ( - - ); - - const windowSize = useWindowSize(); - console.log('windowSize', windowSize); - const appLinks = useAppLinks(); - const isBigViewport = windowSize.width >= 1024; + const { linksInToolbar, linksInDropdownMenu } = useCurrentAppLinks(); - // We show some links outside, and some inside the menu. - // First, we sort the links into categories: - const alwaysVisible = appLinks.filter(l => l.importance === "alwaysVisible"); - const advertisedIfPossible = appLinks.filter(l => !l.importance || l.importance === "advertisedIfPossible"); - const insignificant = appLinks.filter(l => l.importance === "insignificant"); + const menuLinkElements = useMemo( + () => + linksInDropdownMenu.map((appLink) => ( + + )), + [linksInDropdownMenu], + ); - const shownInMenu = [...isBigViewport ? [] : advertisedIfPossible, ...insignificant].sort((a, b) => (a.order || 0) - (b.order || 0)); - const shownInToolbar = [...isBigViewport ? advertisedIfPossible : [], ...alwaysVisible].sort((a, b) => (a.order || 0) - (b.order || 0)); + const toolbarLinkElements = useMemo( + () => + linksInToolbar.map((appLink) => ( + + )), + [linksInToolbar], + ); - const appLinksPopover = ( + const dropdownMenuButton = menuLinkElements.length > 0 && ( - {menuButton} - - {shownInMenu.map((appLink) => )} - + + + + {menuLinkElements} ); - return - {shownInToolbar.map( - (appLink) => - )} - {appLinksPopover} - ; + + return ( + + {toolbarLinkElements} + {dropdownMenuButton} + + ); } diff --git a/src/components/App/MainMenu/MainToolbar.tsx b/src/components/App/MainMenu/MainToolbar.tsx index d03240c2d..98af25f4b 100644 --- a/src/components/App/MainMenu/MainToolbar.tsx +++ b/src/components/App/MainMenu/MainToolbar.tsx @@ -1,11 +1,4 @@ -import * as React from "react"; -import styled from "styled-components"; -import { t } from "ttag"; -import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; -import type { ClientSideConfiguration } from "../../../lib/model/ac/ClientSideConfiguration"; -import VectorImage from "../../shared/VectorImage"; import { - Button, Card, Flex, Inset, @@ -13,16 +6,18 @@ import { Theme, useThemeContext, } from "@radix-ui/themes"; -import { AppStateLink } from "../AppStateLink"; +import styled from "styled-components"; +import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; +import type { ClientSideConfiguration } from "../../../lib/model/ac/ClientSideConfiguration"; import MainMenuLinks from "./MainMenuLinks"; -import Link from "next/link"; +import LogoHomeLink from "./LogoHomeLink"; type Props = { clientSideConfiguration: ClientSideConfiguration; className?: string; }; -const StyledCard = styled(Card)` +const StyledBar = styled(Card)` position: fixed; top: 0; left: 0; @@ -39,41 +34,24 @@ const StyledCard = styled(Card)` export default function MainToolbar(props: Props) { const { clientSideConfiguration } = props; + const { textContent, branding } = clientSideConfiguration; + const { product } = textContent || {}; + const { name, claim } = product || {}; // The product name is configurable in the app's whitelabel settings. - const productName = - translatedStringFromObject( - props.clientSideConfiguration.textContent?.product?.name, - ) || "A11yMap"; - const claimString = translatedStringFromObject( - clientSideConfiguration?.textContent?.product?.claim, - ); - - const logoHomeLink = ( - - ); + const productName = translatedStringFromObject(name) || "A11yMap"; + const claimString = translatedStringFromObject(claim); const radius = useThemeContext().radius; return ( - + - {logoHomeLink} + - + ); } diff --git a/src/components/App/MainMenu/SearchIcon.tsx b/src/components/App/MainMenu/SearchIcon.tsx deleted file mode 100644 index 18e171b4a..000000000 --- a/src/components/App/MainMenu/SearchIcon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react' - -export default function SearchIcon(props) { - return ( - - - - - - - - ) -} diff --git a/src/components/App/MainMenu/SessionMenuItem.tsx b/src/components/App/MainMenu/SessionMenuItem.tsx deleted file mode 100644 index de5bc11f1..000000000 --- a/src/components/App/MainMenu/SessionMenuItem.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { signIn, signOut, useSession } from "next-auth/react"; -import { t } from "ttag"; -import { UserIcon } from "../../icons/ui-elements"; -import { Box, Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; -import React from "react"; - -function AuthenticatedMenuContent() { - const { data: session } = useSession(); - const username = session?.user?.name; - const handleSignOut = React.useCallback(() => signOut(), []); - - return ( - <> - - - {!session?.user?.image && } - {session?.user?.image && ( - {t`${username}'s - )} - {t`You’re signed in as ${username}.`} - - - - - ); -} - -function NonAuthenticatedMenuContent() { - const handleSignIn = React.useCallback(() => signIn("osm"), []); - return ; -} - -function LoadingMenuContent() { - return ( - - - - ); -} - -export default function SessionMenuItem() { - const { status } = useSession(); - return { - loading: , - authenticated: , - unauthenticated: , - }[status]; -} diff --git a/src/components/App/MainMenu/StatusBarBackground.ts b/src/components/App/MainMenu/StatusBarBackground.ts deleted file mode 100644 index 6a3cd105b..000000000 --- a/src/components/App/MainMenu/StatusBarBackground.ts +++ /dev/null @@ -1,5 +0,0 @@ -import styled from 'styled-components' - -const StatusBarBackground = styled.div`` - -export default StatusBarBackground diff --git a/src/components/App/MainMenu/link-types/AutoLink.tsx b/src/components/App/MainMenu/link-types/AutoLink.tsx new file mode 100644 index 000000000..ef39857ae --- /dev/null +++ b/src/components/App/MainMenu/link-types/AutoLink.tsx @@ -0,0 +1,60 @@ +import type { ButtonProps } from "@radix-ui/themes"; +import { useMemo } from "react"; +import ErroneousLink from "./ErroneousLink"; +import ExternalOrInternalAppLink from "./ExternalOrInternalAppLink"; +import JoinedEventLink from "./JoinedEventLink"; +import SessionElement from "./SessionElement"; +import type { translateAndInterpolateAppLink } from "../translateAndInterpolateAppLink"; +import { useExpertMode } from "../useExpertMode"; + +export type IAutoLinkProps = { + asMenuItem: boolean; + buttonProps?: ButtonProps; + label?: string; + badgeLabel?: string; + url?: string; +}; + +function useAppLinkButtonProps(tags: string[] | undefined): ButtonProps { + return useMemo( + () => ({ + variant: tags?.includes("primary") ? "solid" : "ghost", + highContrast: !tags?.includes("primary"), + }), + [tags], + ); +} + +/** + * A link that can be used in the main menu, which can be either a button (if rendered in the + * toolbar) or a menu item (if rendered inside the dropdown menu). + */ +export default function AutoLink({ + tags, + label, + badgeLabel, + url, + asMenuItem, +}: ReturnType & { + asMenuItem: boolean; +}) { + const { isExpertMode } = useExpertMode(); + const buttonProps: ButtonProps = useAppLinkButtonProps(tags); + + let Element: React.ComponentType | null; + if (tags?.includes("events")) { + Element = JoinedEventLink; + } else if (tags?.includes("session")) { + Element = isExpertMode ? SessionElement : null; + } else if (typeof url === "string") { + Element = ExternalOrInternalAppLink; + } else { + Element = ErroneousLink; + } + + return ( + Element && ( + + ) + ); +} diff --git a/src/components/App/MainMenu/link-types/ErroneousLink.tsx b/src/components/App/MainMenu/link-types/ErroneousLink.tsx new file mode 100644 index 000000000..6ea2bb927 --- /dev/null +++ b/src/components/App/MainMenu/link-types/ErroneousLink.tsx @@ -0,0 +1,16 @@ +import { Badge, Tooltip } from "@radix-ui/themes"; +import { t } from "ttag"; +import MenuItemOrButton from "./MenuItemOrButton"; +import type { IAutoLinkProps } from "./AutoLink"; + +export default function ErroneousLink(props: IAutoLinkProps) { + return ( + + + + {props.label || t`Link`} + + + + ); +} diff --git a/src/components/App/MainMenu/link-types/ExternalOrInternalAppLink.tsx b/src/components/App/MainMenu/link-types/ExternalOrInternalAppLink.tsx new file mode 100644 index 000000000..1eb800e05 --- /dev/null +++ b/src/components/App/MainMenu/link-types/ExternalOrInternalAppLink.tsx @@ -0,0 +1,29 @@ +import { Badge, type ButtonProps, Tooltip } from "@radix-ui/themes"; +import Link from "next/link"; +import { AppStateLink } from "../../AppStateLink"; +import ErroneousLink from "./ErroneousLink"; +import type { IAutoLinkProps } from "./AutoLink"; +import MenuItemOrButton from "./MenuItemOrButton"; + +export default function ExternalOrInternalAppLink(props: IAutoLinkProps) { + const { label, badgeLabel, url } = props; + let children: React.ReactNode; + if (!url) { + children = ; + } else { + const isExternal = url.startsWith("http"); + const LinkElement = isExternal ? Link : AppStateLink; + children = ( + + {label} + {badgeLabel && {badgeLabel}} + + ); + } + + return {children}; +} diff --git a/src/components/App/MainMenu/link-types/JoinedEventLink.tsx b/src/components/App/MainMenu/link-types/JoinedEventLink.tsx new file mode 100644 index 000000000..37d14d0d4 --- /dev/null +++ b/src/components/App/MainMenu/link-types/JoinedEventLink.tsx @@ -0,0 +1,37 @@ +import { useRouter } from "next/router"; +import { useCallback } from "react"; +import { useCurrentMappingEvent } from "../../../../lib/context/useCurrentMappingEvent"; +import Spinner from "../../../ActivityIndicator/Spinner"; +import { useAppStateAwareHref } from "../../useAppStateAwareHref"; +import type { IAutoLinkProps } from "./AutoLink"; +import MenuItemOrButton from "./MenuItemOrButton"; +import { Text } from "@radix-ui/themes"; + +/** + * Links to the current mapping event, or the events overview if no event is joined. + */ +function JoinedEventLink(props: IAutoLinkProps) { + const { data: joinedMappingEvent, isValidating } = useCurrentMappingEvent(); + + const href = joinedMappingEvent + ? `/events/${joinedMappingEvent._id}` + : "/events"; + + const label = joinedMappingEvent ? joinedMappingEvent.name : props.label; + const children = isValidating ? : {label}; + const router = useRouter(); + // XXX: This should be an , but this would break keyboard navigation: + // A bug in Radix UI (?) appears to not update the internal `ref` correctly. + const hrefWithParams = useAppStateAwareHref(href); + const openHref = useCallback(() => { + router.push(hrefWithParams); + }, [router, hrefWithParams]); + + return ( + + {children} + + ); +} + +export default JoinedEventLink; diff --git a/src/components/App/MainMenu/link-types/MenuItemOrButton.tsx b/src/components/App/MainMenu/link-types/MenuItemOrButton.tsx new file mode 100644 index 000000000..9743308b7 --- /dev/null +++ b/src/components/App/MainMenu/link-types/MenuItemOrButton.tsx @@ -0,0 +1,22 @@ +import { Button, DropdownMenu } from "@radix-ui/themes"; +import type { IAutoLinkProps } from "./AutoLink"; + +/** + * Use this to render a menu item or a button, depending on the `asMenuItem` prop. + */ +export default function MenuItemOrButton({ + asMenuItem, + buttonProps, + children, + onClick, +}: IAutoLinkProps & { children: React.ReactElement; onClick?: () => void }) { + return asMenuItem ? ( + + {children} + + ) : ( + + ); +} diff --git a/src/components/App/MainMenu/link-types/SessionElement.tsx b/src/components/App/MainMenu/link-types/SessionElement.tsx new file mode 100644 index 000000000..635e84f5d --- /dev/null +++ b/src/components/App/MainMenu/link-types/SessionElement.tsx @@ -0,0 +1,64 @@ +import { + Button, + DropdownMenu, + Flex, + Skeleton, + Text, + Tooltip, +} from "@radix-ui/themes"; +import { signIn, signOut, useSession } from "next-auth/react"; +import React from "react"; +import { t } from "ttag"; +import MenuItemOrButton from "./MenuItemOrButton"; +import { IAutoLinkProps } from "./AutoLink"; + +function AuthenticatedMenuContent() { + const { data: session } = useSession(); + const username = session?.user?.name; + const handleSignOut = React.useCallback(() => signOut(), []); + + return ( + + {session?.user?.image && ( + + {t`${username}'s + + )} + + + ); +} + +function NonAuthenticatedMenuContent() { + const handleSignIn = React.useCallback(() => signIn("osm"), []); + return ; +} + +function LoadingMenuContent() { + return ; +} + +/** + * A sign-in/sign-out button that changes its content based on the session status. + */ +export default function SessionElement(props: IAutoLinkProps) { + const { status } = useSession(); + const children = { + loading: , + authenticated: , + unauthenticated: , + }[status]; + + return {children}; +} diff --git a/src/components/App/MainMenu/translateAndInterpolateAppLink.tsx b/src/components/App/MainMenu/translateAndInterpolateAppLink.tsx new file mode 100644 index 000000000..ccda07e84 --- /dev/null +++ b/src/components/App/MainMenu/translateAndInterpolateAppLink.tsx @@ -0,0 +1,40 @@ +import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; +import type { IApp } from "../../../lib/model/ac/App"; +import type IAppLink from "../../../lib/model/ac/IAppLink"; +import { insertPlaceholdersToAddPlaceUrl } from "../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl"; +import type { MappingEvent } from "../../../lib/model/ac/MappingEvent"; + +export function translateAndInterpolateAppLink( + link: IAppLink, + app: IApp, + uniqueSurveyId: string, + joinedMappingEvent?: MappingEvent, +) { + const baseUrl = `https://${app._id}/`; + const localizedUrl = translatedStringFromObject(link.url); + const label = translatedStringFromObject(link.label); + const badgeLabel = translatedStringFromObject(link.badgeLabel); + const isExternal = localizedUrl?.startsWith("http"); + + /** Insert values of template variables into the link's URL */ + const url = + link.url && + insertPlaceholdersToAddPlaceUrl( + baseUrl, + localizedUrl, + joinedMappingEvent, + uniqueSurveyId, + ); + + return { + ...link, + url, + label, + badgeLabel, + isExternal, + }; +} + +export type TranslatedAppLink = ReturnType< + typeof translateAndInterpolateAppLink +>; diff --git a/src/components/App/MainMenu/useAppLinks.tsx b/src/components/App/MainMenu/useAppLinks.tsx index 31b25c25a..62303ae19 100644 --- a/src/components/App/MainMenu/useAppLinks.tsx +++ b/src/components/App/MainMenu/useAppLinks.tsx @@ -2,54 +2,69 @@ import React from "react"; import { useCurrentApp } from "../../../lib/context/AppContext"; import { useUniqueSurveyId } from "../../../lib/context/useUniqueSurveyId"; import { useCurrentMappingEvent } from "../../../lib/context/useCurrentMappingEvent"; -import { translatedStringFromObject } from "../../../lib/i18n/translatedStringFromObject"; -import { insertPlaceholdersToAddPlaceUrl } from "../../../lib/model/ac/insertPlaceholdersToAddPlaceUrl"; -import type { IApp } from "../../../lib/model/ac/App"; -import type IAppLink from '~/lib/model/ac/IAppLink'; -import type { MappingEvent } from "../../../lib/model/ac/MappingEvent"; +import { useWindowSize } from "../../../lib/util/useViewportSize"; +import { + translateAndInterpolateAppLink, + type TranslatedAppLink, +} from "./translateAndInterpolateAppLink"; +function sortByOrder(a: TranslatedAppLink, b: TranslatedAppLink) { + return (a.order || 0) - (b.order || 0); +} + +/** + * @returns An object with two arrays of {@link TranslatedAppLink}: one for the toolbar, one for + * the menu. The links are sorted by their configured order, and divided depending on importance + * and current viewport size. + */ export function useAppLinks() { + const windowSize = useWindowSize(); + const isBigViewport = windowSize.width >= 1024; + const { data: joinedMappingEvent } = useCurrentMappingEvent(); const app = useCurrentApp(); + const appLinks = app.related?.appLinks; const uniqueSurveyId = useUniqueSurveyId(); - const { - related: { appLinks } = {}, - } = app; - - const links = React.useMemo( - () => Object.values(appLinks ?? {}) - .map((link) => expandLinkMetadata(link, app, uniqueSurveyId, joinedMappingEvent) + const translatedAppLinks = React.useMemo( + () => + Object.values(appLinks ?? {}).map((link) => + translateAndInterpolateAppLink( + link, + app, + uniqueSurveyId, + joinedMappingEvent, + ), ), - [app, appLinks, joinedMappingEvent, uniqueSurveyId] + [app, appLinks, joinedMappingEvent, uniqueSurveyId], ); - return links; -} -export function expandLinkMetadata( - link: IAppLink, - app: IApp, - uniqueSurveyId: string, - joinedMappingEvent?: MappingEvent, -) { - const baseUrl = `https://${app._id}/`; - const localizedUrl = translatedStringFromObject(link.url); - const url = - link.url && - insertPlaceholdersToAddPlaceUrl( - baseUrl, - localizedUrl, - joinedMappingEvent, - uniqueSurveyId, + return React.useMemo(() => { + // We show some links outside, and some inside the menu. + // First, we sort the links into categories: + const alwaysVisible = translatedAppLinks.filter( + (l) => l.importance === "alwaysVisible", ); - const label = translatedStringFromObject(link.label); - const badgeLabel = translatedStringFromObject(link.badgeLabel); - const isExternal = localizedUrl?.startsWith("http"); - return { - ...link, - url, - label, - badgeLabel, - isExternal, - }; + const advertisedIfPossible = translatedAppLinks.filter( + (l) => !l.importance || l.importance === "advertisedIfPossible", + ); + const insignificant = translatedAppLinks.filter( + (l) => l.importance === "insignificant", + ); + + // Then, we sort the links by their configured order, and return them. + const linksInToolbar = [ + ...(isBigViewport ? advertisedIfPossible : []), + ...alwaysVisible, + ].sort(sortByOrder); + const linksInDropdownMenu = [ + ...(isBigViewport ? [] : advertisedIfPossible), + ...insignificant, + ].sort(sortByOrder); + + return { + linksInToolbar, + linksInDropdownMenu, + }; + }, [translatedAppLinks, isBigViewport]); } diff --git a/src/components/Map/filter/useCreateMapFilterContextState.ts b/src/components/Map/filter/useCreateMapFilterContextState.ts index 3d09b4461..816761abb 100644 --- a/src/components/Map/filter/useCreateMapFilterContextState.ts +++ b/src/components/Map/filter/useCreateMapFilterContextState.ts @@ -11,10 +11,6 @@ class StateHolder implements FilterContext { public readonly listeners: Set<(filter: Partial>) => void> = new Set() - constructor() { - console.log('StateHolder constructor') - } - public readonly addFilter = (filter: FilterAddition): Filter => { const entryId = (filter.id ?? crypto.randomUUID()) as HighlightId const entry = { ...filter, id: entryId } diff --git a/src/components/Onboarding/LocationFailedStep.tsx b/src/components/Onboarding/LocationFailedStep.tsx index 13997cf77..283f3e9f2 100644 --- a/src/components/Onboarding/LocationFailedStep.tsx +++ b/src/components/Onboarding/LocationFailedStep.tsx @@ -1,37 +1,25 @@ -import styled from 'styled-components' -import { AppContext } from '../../lib/context/AppContext' -import StyledMarkdown from '../shared/StyledMarkdown' -import { LocationFailedStepPrimaryText, selectProductName } from './language' -import { LocationSearch } from './components/LocationSearch' -import type { PhotonResultFeature } from '../../lib/fetchers/fetchPhotonFeatures' -import { LocationContainer } from './components/LocationContainer' -import { Flex } from '@radix-ui/themes' -import { type FC, useContext } from 'react' - -const Container = styled(LocationContainer)` - .footer { - > .input, - > .button { - flex: 1; - } - } -` +import { t } from "ttag"; +import { AppContext } from "../../lib/context/AppContext"; +import StyledMarkdown from "../shared/StyledMarkdown"; +import { LocationFailedStepPrimaryText, selectProductName } from "./language"; +import { Box, Button, Flex } from "@radix-ui/themes"; +import { type FC, useContext } from "react"; export const LocationFailedStep: FC<{ - onSubmit: (location?: PhotonResultFeature) => unknown; + onSubmit: () => unknown; }> = ({ onSubmit }) => { - const { clientSideConfiguration } = useContext(AppContext) ?? { } + const { clientSideConfiguration } = useContext(AppContext) ?? {}; return ( - + {LocationFailedStepPrimaryText( selectProductName(clientSideConfiguration), )} - + - - ) -} + + ); +}; diff --git a/src/components/Onboarding/LocationNoPermissionStep.tsx b/src/components/Onboarding/LocationNoPermissionStep.tsx index 1fd061d58..469b53974 100644 --- a/src/components/Onboarding/LocationNoPermissionStep.tsx +++ b/src/components/Onboarding/LocationNoPermissionStep.tsx @@ -1,20 +1,17 @@ -import { FC, useContext } from 'react' -import styled from 'styled-components' -import { AppContext } from '../../lib/context/AppContext' -import StyledMarkdown from '../shared/StyledMarkdown' -import { LocationNoPermissionPrimaryText, selectProductName } from './language' -import type { PhotonResultFeature } from '../../lib/fetchers/fetchPhotonFeatures' -import { getLocationSettingsUrl } from '../../lib/goToLocationSettings' -import { LocationContainer } from './components/LocationContainer' -import { Box, Button, Flex } from '@radix-ui/themes' -import { t } from 'ttag' - +import { Box, Button, Flex } from "@radix-ui/themes"; +import { type FC, useContext } from "react"; +import { t } from "ttag"; +import { AppContext } from "../../lib/context/AppContext"; +import type { PhotonResultFeature } from "../../lib/fetchers/fetchPhotonFeatures"; +import { getLocationSettingsUrl } from "../../lib/goToLocationSettings"; +import StyledMarkdown from "../shared/StyledMarkdown"; +import { LocationNoPermissionPrimaryText, selectProductName } from "./language"; export const LocationNoPermissionStep: FC<{ onSubmit: (location?: PhotonResultFeature) => unknown; }> = ({ onSubmit }) => { - const { clientSideConfiguration } = useContext(AppContext) ?? { } - const [url] = getLocationSettingsUrl() + const { clientSideConfiguration } = useContext(AppContext) ?? {}; + const [url] = getLocationSettingsUrl(); return ( @@ -27,5 +24,5 @@ export const LocationNoPermissionStep: FC<{ - ) -} + ); +}; diff --git a/src/components/Onboarding/components/LocationSearch.tsx b/src/components/Onboarding/components/LocationSearch.tsx deleted file mode 100644 index 132163b8c..000000000 --- a/src/components/Onboarding/components/LocationSearch.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { FC, startTransition, useContext, useMemo, useRef, useState } from "react"; -import useSWR from "swr"; -import type { FeatureCollection, Point } from "geojson"; -import { center as turfCenter } from "@turf/turf"; -import fetchPhotonFeatures, { - type PhotonResultFeature, -} from "../../../lib/fetchers/fetchPhotonFeatures"; -import { SearchConfirmText, SearchSkipText } from "../language"; -import CountryContext from "../../../lib/context/CountryContext"; -import fetchCountryGeometry from "../../../lib/fetchers/fetchCountryGeometry"; -import { useCurrentLanguageTagStrings } from "../../../lib/context/LanguageTagContext"; -import { Button, ChevronDownIcon, DropdownMenu } from "@radix-ui/themes"; -import { t } from "ttag"; -import { - Combobox, - ComboboxItem, - ComboboxLabel, - ComboboxList, - ComboboxProvider, -} from "@ariakit/react"; -import * as RadixSelect from "@radix-ui/react-select"; -import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; - - -export const LocationSearch: FC<{ - onUserSelection: (selection?: PhotonResultFeature) => unknown; -}> = ({ onUserSelection }) => { - const comboboxRef = useRef(null); - const listboxRef = useRef(null); - const [open, setOpen] = useState(false); - const [searchValue, setSearchValue] = useState(""); - const [value, setValue] = useState(""); - - const region = useContext(CountryContext); - const { data: regionGeometry } = useSWR( - {}, - fetchCountryGeometry, - ); - const bias = useMemo(() => { - if (!regionGeometry) { - return undefined; - } - - const location = regionGeometry.features.find( - (x) => x.properties?.["ISO3166-1"] === region, - ); - - let center: Point | undefined; - if (location) { - if ("centroid" in location) { - center = location.centroid as Point; - } else { - center = turfCenter(location).geometry; - } - } - - const computedBias = { - lon: center?.coordinates[0].toString(), - lat: center?.coordinates[1].toString(), - zoom: "5", - location_bias_scale: "1.0", - } as const; - return computedBias; - }, [regionGeometry, region]); - - const languageTag = useCurrentLanguageTagStrings()?.[0] || "en"; - const { data } = useSWR( - { - languageTag, - query: searchValue, - additionalQueryParameters: { layer: "city", ...bias }, - }, - fetchPhotonFeatures, - ); - const filteredData: ({ key: string } & PhotonResultFeature)[] = useMemo(() => { - if (!data) { - return [ - { - key: "Tokyo / Japan", - properties: { - name: "Tokyo", - country: "Japan", - }, - }, - ]; - } - const bucket: ({ key: string } & PhotonResultFeature)[] = []; - for (let i = 0; i < data.features.length; i += 1) { - const entry = data.features[i]; - if (!entry.properties) { - continue; - } - const key = `${entry.properties.city ?? entry.properties.name} / ${entry.properties.country}`; - if (bucket.some((x) => x.key === key)) { - continue; - } - bucket.push({ - key, - ...entry, - }); - } - return bucket; - }, [data]); - - return ( - <> - - { - startTransition(() => { - setSearchValue(value); - }); - }} - > - {t`Enter a location name`} - - - - - - - - - - role="dialog" - aria-label="Languages" - position="popper" - className="popover" - sideOffset={4} - alignOffset={-16} - > -
    -
    - -
    - { - event.preventDefault(); - event.stopPropagation(); - }} - /> -
    - - {filteredData.map((match) => ( - - - {match.properties.name} - - - ))} - -
    -
    -
    - - - ); -}; From 5d63509267badb9180b3245cf510252e3bc635e4 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 21:51:56 +0100 Subject: [PATCH 21/27] Fix 'location failed' wording --- .../components/LocationContainer.tsx | 30 ------------------- src/components/Onboarding/language.ts | 14 +-------- 2 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 src/components/Onboarding/components/LocationContainer.tsx diff --git a/src/components/Onboarding/components/LocationContainer.tsx b/src/components/Onboarding/components/LocationContainer.tsx deleted file mode 100644 index 0fcfc1ab3..000000000 --- a/src/components/Onboarding/components/LocationContainer.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import styled from 'styled-components' - -export const LocationContainer = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; - - h1 { - @media (min-width: 414px) { - font-size: 1.25rem; - } - @media (min-height: 414px) { - font-size: 1.25rem; - } - } - - > * { - max-width: 400px; - } - - .footer { - max-width: 400px; - display: flex; - flex-direction: row; - justify-content: center; - align-self: center; - gap: 24px; - } -` diff --git a/src/components/Onboarding/language.ts b/src/components/Onboarding/language.ts index d5ecbcaa6..1c6958e20 100644 --- a/src/components/Onboarding/language.ts +++ b/src/components/Onboarding/language.ts @@ -74,17 +74,5 @@ You can still use all features of the app. // translator: The text shows when a location permission had been granted but failed to be acquired for other reasons export const LocationFailedStepPrimaryText = (productName: string) => t` -# That did not work! - -Don't worry, you can still use all features of ${productName}. -Do you want to start in the center of a city instead? +Could not determine location – you can still use all features of ${productName}. ` - -/** - * Location Search Texts - */ - -// translator: Text in a button to skip initial location selection -export const SearchSkipText = t`Skip` -// translator: Text in a button to set the initial location selection -export const SearchConfirmText = t`Let’s go` From d903fa774a26c7ab8ccdbca51d79ebc318a4fd26 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 22:01:05 +0100 Subject: [PATCH 22/27] Fix a smol comment --- src/components/App/MainMenu/useAppLinks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/App/MainMenu/useAppLinks.tsx b/src/components/App/MainMenu/useAppLinks.tsx index 62303ae19..27d7d9e9a 100644 --- a/src/components/App/MainMenu/useAppLinks.tsx +++ b/src/components/App/MainMenu/useAppLinks.tsx @@ -40,7 +40,7 @@ export function useAppLinks() { ); return React.useMemo(() => { - // We show some links outside, and some inside the menu. + // We show some links outside of the menu, and some inside of it. // First, we sort the links into categories: const alwaysVisible = translatedAppLinks.filter( (l) => l.importance === "alwaysVisible", From 97a28e2c2cedc1a27aaaa9d545f47e0ef6ea993e Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 22:11:19 +0100 Subject: [PATCH 23/27] Fix a wrongly import()ed type --- src/components/App/useAppStateAwareHref.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/App/useAppStateAwareHref.tsx b/src/components/App/useAppStateAwareHref.tsx index 7ea9c933a..025c60c71 100644 --- a/src/components/App/useAppStateAwareHref.tsx +++ b/src/components/App/useAppStateAwareHref.tsx @@ -1,8 +1,14 @@ import { useMemo } from "react"; -import { useAppStateAwareRouter, preserveSearchParams } from "../../lib/util/useAppStateAwareRouter"; +import { + useAppStateAwareRouter, + preserveSearchParams, +} from "../../lib/util/useAppStateAwareRouter"; -export function useAppStateAwareHref(href: string | import("url").UrlObject) { +export function useAppStateAwareHref(href: string | UrlObject) { const { searchParams, query } = useAppStateAwareRouter(); - const extendedHref = useMemo(() => preserveSearchParams(href, searchParams, query), [href, searchParams, query]); + const extendedHref = useMemo( + () => preserveSearchParams(href, searchParams, query), + [href, searchParams, query], + ); return extendedHref; } From ebc9625dd265a419dd61274557871151efba606a Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Tue, 17 Dec 2024 22:11:28 +0100 Subject: [PATCH 24/27] Improve docs for useIsomorphicLayoutEffect.tsx --- .../shared/useIsomorphicLayoutEffect.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/shared/useIsomorphicLayoutEffect.tsx b/src/components/shared/useIsomorphicLayoutEffect.tsx index 9409c1a9c..4689c3f28 100644 --- a/src/components/shared/useIsomorphicLayoutEffect.tsx +++ b/src/components/shared/useIsomorphicLayoutEffect.tsx @@ -1,8 +1,11 @@ -import * as React from 'react' +import * as React from "react"; -// React currently throws a warning when using useLayoutEffect on the server. -// To get around it, we can conditionally useEffect on the server (no-op) and -// useLayoutEffect in the browser. We need useLayoutEffect because we want -// `connect` to perform sync updates to a ref to save the latest props after -// a render is actually committed to the DOM. -export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect +/** + * React throws a warning when using `useLayoutEffect()` on the server. To get around this, we can + * conditionally run `useEffect()` on the server (no-op) and `useLayoutEffect()` in the browser. + */ + +export const useIsomorphicLayoutEffect = + typeof global.window !== "undefined" + ? React.useLayoutEffect + : React.useEffect; From 05ef70f829daa8fac4fba70400f4aea2b6220022 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 30 Dec 2024 19:36:33 +0100 Subject: [PATCH 25/27] Wrap with React.forwardRef --- src/components/App/AppStateLink.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/App/AppStateLink.tsx b/src/components/App/AppStateLink.tsx index 1d6f23262..5e1ae6ac5 100644 --- a/src/components/App/AppStateLink.tsx +++ b/src/components/App/AppStateLink.tsx @@ -1,11 +1,15 @@ -import Link from 'next/link' -import type { ComponentProps } from 'react' -import { useAppStateAwareHref } from './useAppStateAwareHref' - - -export const AppStateLink = ({ href, ...props }: ComponentProps) => { - const extendedHref = useAppStateAwareHref(href) - return -} - - +import Link from "next/link"; +import type { ComponentProps, Ref } from "react"; +import { useAppStateAwareHref } from "./useAppStateAwareHref"; +import React from "react"; + +export const AppStateLink = React.forwardRef( + ( + { href, ...props }: ComponentProps, + ref: Ref, + ) => { + const extendedHref = useAppStateAwareHref(href); + + return ; + }, +); From d9874cccf13bc36137253ae501525f1bcd9c8a02 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 30 Dec 2024 19:36:56 +0100 Subject: [PATCH 26/27] Improve session menu element when logged in with OSM --- .../MainMenu/link-types/SessionElement.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/App/MainMenu/link-types/SessionElement.tsx b/src/components/App/MainMenu/link-types/SessionElement.tsx index 635e84f5d..f8d109a12 100644 --- a/src/components/App/MainMenu/link-types/SessionElement.tsx +++ b/src/components/App/MainMenu/link-types/SessionElement.tsx @@ -1,7 +1,9 @@ import { + Box, Button, DropdownMenu, Flex, + Popover, Skeleton, Text, Tooltip, @@ -10,32 +12,48 @@ import { signIn, signOut, useSession } from "next-auth/react"; import React from "react"; import { t } from "ttag"; import MenuItemOrButton from "./MenuItemOrButton"; -import { IAutoLinkProps } from "./AutoLink"; +import type { IAutoLinkProps } from "./AutoLink"; +import Markdown from "../../../shared/Markdown"; function AuthenticatedMenuContent() { const { data: session } = useSession(); const username = session?.user?.name; const handleSignOut = React.useCallback(() => signOut(), []); + const signInNotice = t` + You’re signed in with OpenStreetMap. + + Edits you make will be attributed to your OpenStreetMap account **${username}**. + `; + + const popoverContent = ( + + + {signInNotice} + + + + ); return ( {session?.user?.image && ( - - {t`${username}'s - + + + {t`Your + + + {popoverContent} + + )} - ); } From c6e4245f8583595ca53265d8bca616e7441110b1 Mon Sep 17 00:00:00 2001 From: Sebastian Felix Zappe Date: Mon, 30 Dec 2024 19:37:15 +0100 Subject: [PATCH 27/27] Use tag in correctly --- .../MainMenu/link-types/JoinedEventLink.tsx | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/components/App/MainMenu/link-types/JoinedEventLink.tsx b/src/components/App/MainMenu/link-types/JoinedEventLink.tsx index 37d14d0d4..1f8a7464c 100644 --- a/src/components/App/MainMenu/link-types/JoinedEventLink.tsx +++ b/src/components/App/MainMenu/link-types/JoinedEventLink.tsx @@ -1,11 +1,8 @@ -import { useRouter } from "next/router"; -import { useCallback } from "react"; import { useCurrentMappingEvent } from "../../../../lib/context/useCurrentMappingEvent"; import Spinner from "../../../ActivityIndicator/Spinner"; -import { useAppStateAwareHref } from "../../useAppStateAwareHref"; import type { IAutoLinkProps } from "./AutoLink"; import MenuItemOrButton from "./MenuItemOrButton"; -import { Text } from "@radix-ui/themes"; +import { AppStateLink } from "../../AppStateLink"; /** * Links to the current mapping event, or the events overview if no event is joined. @@ -18,20 +15,13 @@ function JoinedEventLink(props: IAutoLinkProps) { : "/events"; const label = joinedMappingEvent ? joinedMappingEvent.name : props.label; - const children = isValidating ? : {label}; - const router = useRouter(); - // XXX: This should be an , but this would break keyboard navigation: - // A bug in Radix UI (?) appears to not update the internal `ref` correctly. - const hrefWithParams = useAppStateAwareHref(href); - const openHref = useCallback(() => { - router.push(hrefWithParams); - }, [router, hrefWithParams]); - - return ( - - {children} - + const children = isValidating ? ( + + ) : ( + {label} ); + + return {children}; } export default JoinedEventLink;