From f2ecc1803f3ea294a0c6b7915b61348ed0395b26 Mon Sep 17 00:00:00 2001 From: Marcin Zelent Date: Wed, 16 Nov 2022 15:16:38 +0100 Subject: Remade and extended the app using React --- src/components/Lightbox/Lightbox.css | 7 + src/components/Lightbox/Lightbox.ts | 152 +++++++++++++++++ .../LoadingScreen/LoadingScreen.module.css | 27 +++ src/components/LoadingScreen/LoadingScreen.tsx | 10 ++ src/components/Map/Map.module.css | 28 +++ src/components/Map/Map.tsx | 190 +++++++++++++++++++++ .../ReactLeafletControl/ReactLeafletControl.tsx | 104 +++++++++++ src/components/Sidebar/Sidebar.module.css | 158 +++++++++++++++++ src/components/Sidebar/Sidebar.tsx | 152 +++++++++++++++++ .../TileLayerControl/TileLayerControl.module.css | 20 +++ .../TileLayerControl/TileLayerControl.tsx | 21 +++ 11 files changed, 869 insertions(+) create mode 100644 src/components/Lightbox/Lightbox.css create mode 100644 src/components/Lightbox/Lightbox.ts create mode 100644 src/components/LoadingScreen/LoadingScreen.module.css create mode 100644 src/components/LoadingScreen/LoadingScreen.tsx create mode 100644 src/components/Map/Map.module.css create mode 100644 src/components/Map/Map.tsx create mode 100644 src/components/ReactLeafletControl/ReactLeafletControl.tsx create mode 100644 src/components/Sidebar/Sidebar.module.css create mode 100644 src/components/Sidebar/Sidebar.tsx create mode 100644 src/components/TileLayerControl/TileLayerControl.module.css create mode 100644 src/components/TileLayerControl/TileLayerControl.tsx (limited to 'src/components') diff --git a/src/components/Lightbox/Lightbox.css b/src/components/Lightbox/Lightbox.css new file mode 100644 index 0000000..4873419 --- /dev/null +++ b/src/components/Lightbox/Lightbox.css @@ -0,0 +1,7 @@ +.pswp__dynamic-caption { + max-width: unset !important; + padding: 10px !important; + text-align: center; + font-size: 0.9rem; + color: #ccc !important; +} diff --git a/src/components/Lightbox/Lightbox.ts b/src/components/Lightbox/Lightbox.ts new file mode 100644 index 0000000..4eadba9 --- /dev/null +++ b/src/components/Lightbox/Lightbox.ts @@ -0,0 +1,152 @@ +import { DataSource } from 'photoswipe'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import { Slide } from 'photoswipe/dist/types/slide/content'; +import PhotoSwipeDynamicCaption from 'photoswipe-dynamic-caption-plugin'; +import PhotoSwipeVideoPlugin from 'photoswipe-video-plugin'; + +import './Lightbox.css'; +import 'photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css'; + +export function createLightbox(dataSource: DataSource): PhotoSwipeLightbox { + const lb = new PhotoSwipeLightbox({ + dataSource, + pswpModule: async () => await import('photoswipe'), + }); + + const fullscreenSVG = + ''; + + lb.on('uiRegister', () => { + lb.pswp?.ui.registerElement({ + name: 'fullscreen-button', + title: 'Toggle fullscreen', + order: 9, + isButton: true, + html: fullscreenSVG, + onClick: () => { + toggleFullscreen(); + }, + }); + }); + + // eslint-disable-next-line no-new + new PhotoSwipeDynamicCaption(lb, { + type: 'below', + captionContent: (slide: Slide) => slide.data.alt, + }); + + // eslint-disable-next-line no-new + new PhotoSwipeVideoPlugin(lb, { + autoplay: false, + }); + + return lb; +} + +interface WebkitDocument extends Document { + webkitExitFullscreen?: () => Promise; + webkitFullscreenElement?: Element; + [key: string]: any; +} + +interface WebkitHTMLElement extends HTMLElement { + webkitRequestFullscreen: () => Promise; + [key: string]: any; +} + +interface FullscreenAPI { + request: (element: WebkitHTMLElement) => void; + exit: () => void; + isFullscreen: () => boolean; + change: string; + error: string; +} + +function getFullscreenAPI(): FullscreenAPI | undefined { + let api; + let enterFS = ''; + let exitFS = ''; + let elementFS = ''; + let changeEvent = ''; + let errorEvent = ''; + + if (document.documentElement.requestFullscreen !== undefined) { + enterFS = 'requestFullscreen'; + exitFS = 'exitFullscreen'; + elementFS = 'fullscreenElement'; + changeEvent = 'fullscreenchange'; + errorEvent = 'fullscreenerror'; + } else if ( + (document.documentElement as WebkitHTMLElement).webkitRequestFullscreen !== undefined + ) { + enterFS = 'webkitRequestFullscreen'; + exitFS = 'webkitExitFullscreen'; + elementFS = 'webkitFullscreenElement'; + changeEvent = 'webkitfullscreenchange'; + errorEvent = 'webkitfullscreenerror'; + } + + if (enterFS !== '') { + api = { + request: (element: WebkitHTMLElement) => { + if (enterFS === 'webkitRequestFullscreen') { + void element.webkitRequestFullscreen(); + } else { + void element.requestFullscreen(); + } + }, + exit: (): void => { + return (document as WebkitDocument)[exitFS](); + }, + isFullscreen: (): boolean => { + return (document as WebkitDocument)[elementFS]; + }, + change: changeEvent, + error: errorEvent, + }; + } + + return api; +} + +// Toggle full-screen mode function +function toggleFullscreen(): void { + const fullscreenAPI = getFullscreenAPI(); + + if (fullscreenAPI !== undefined) { + if (fullscreenAPI.isFullscreen()) { + // Exit full-screen mode + fullscreenAPI.exit(); + // Toggle "Exit" and "Enter" full-screen SVG icon display + setTimeout(function () { + const exitIcon = document.getElementById('pswp__icn-fullscreen-exit'); + if (exitIcon !== null) { + exitIcon.style.display = 'none'; + } + + const requestIcon = document.getElementById('pswp__icn-fullscreen-request'); + if (requestIcon !== null) { + requestIcon.style.display = 'inline'; + } + }, 300); + } else { + // Enter full-screen mode + const pswp = document.querySelector(`.pswp`); + if (pswp != null) { + fullscreenAPI.request(pswp as WebkitHTMLElement); + } + // Toggle "Exit" and "Enter" full-screen SVG icon display + setTimeout(function () { + const exitIcon = document.getElementById('pswp__icn-fullscreen-exit'); + if (exitIcon !== null) { + exitIcon.style.display = 'inline'; + } + + const requestIcon = document.getElementById('pswp__icn-fullscreen-request'); + if (requestIcon !== null) { + requestIcon.style.display = 'none'; + } + }, 300); + } + } +} diff --git a/src/components/LoadingScreen/LoadingScreen.module.css b/src/components/LoadingScreen/LoadingScreen.module.css new file mode 100644 index 0000000..1f690bd --- /dev/null +++ b/src/components/LoadingScreen/LoadingScreen.module.css @@ -0,0 +1,27 @@ +.container { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + display: flex; + z-index: 99999; + background: rgb(255 255 255 / 75%); +} + +.spinner { + width: 50px; + height: 50px; + margin: auto; + display: inline-block; + border: 3px solid #c9c9c9; + border-radius: 50%; + border-top-color: #25a070; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/LoadingScreen/LoadingScreen.tsx b/src/components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000..22509ee --- /dev/null +++ b/src/components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './LoadingScreen.module.css'; + +export default function LoadingScreen(): JSX.Element { + return ( +
+
+
+ ); +} diff --git a/src/components/Map/Map.module.css b/src/components/Map/Map.module.css new file mode 100644 index 0000000..a265138 --- /dev/null +++ b/src/components/Map/Map.module.css @@ -0,0 +1,28 @@ +.popup { + width: auto; + min-height: 90%; +} + +.popup .leaflet-popup-content { + margin: 0; +} + +.markerIcon { + border: 2px solid #fff; + border-radius: 2px; + overflow: hidden; +} + +.markerItemCount { + width: 18px; + height: 18px; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 10px; + background: #fff; + text-align: center; + font-weight: bold; +} diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx new file mode 100644 index 0000000..6f2f469 --- /dev/null +++ b/src/components/Map/Map.tsx @@ -0,0 +1,190 @@ +import { LatLngBounds, Icon, MarkerCluster, DivIcon } from 'leaflet'; +import React, { useMemo, useState } from 'react'; +import ReactDom from 'react-dom/server'; +import { GeoJSON, MapContainer, Marker, TileLayer, ZoomControl, useMap } from 'react-leaflet'; + +import { Group, MediaItem } from '../../models'; +import MarkerClusterGroup from '../../lib/MarkerClusterGroup'; + +import 'leaflet/dist/leaflet.css'; +import styles from './Map.module.css'; +import TileLayerControl, { TileLayerType } from '../TileLayerControl/TileLayerControl'; +import ReactLeafletControl from '../ReactLeafletControl/ReactLeafletControl'; +import { LineString } from 'geojson'; + +interface Props { + group: Group | undefined; + handleMarkerClick: (photo: string) => void; +} + +export default function Map({ group, handleMarkerClick }: Props): JSX.Element { + const [tileLayer, setTileLayer] = useState(TileLayerType.map); + + const displayedMedia = useMemo(() => { + if (group?.media !== undefined) { + return group.media.filter( + (p) => + p.latitude !== undefined && + p.latitude !== null && + !isNaN(p.latitude) && + p.latitude !== 0 && + p.longitude !== undefined && + p.longitude !== null && + !isNaN(p.longitude) && + p.longitude !== 0 && + p.longitude !== 0, + ); + } + + return []; + }, [group]); + + const bounds = useMemo(() => { + if (group === undefined || (group.geoData === undefined && displayedMedia.length === 0)) { + return new LatLngBounds([75, -145], [-52, 145]); + } + + const latitudes = displayedMedia.map((p) => p.latitude) as number[]; + const longitudes = displayedMedia.map((p) => p.longitude) as number[]; + + group.geoData?.forEach((data) => { + if (data.bbox !== undefined) { + latitudes.push(data.bbox[1]); + latitudes.push(data.bbox[3]); + longitudes.push(data.bbox[0]); + longitudes.push(data.bbox[2]); + } + }); + + const minLatitude = Math.min(...latitudes); + const minLongitude = Math.min(...longitudes); + const maxLatitude = Math.max(...latitudes); + const maxLongitude = Math.max(...longitudes); + + return new LatLngBounds([minLatitude, minLongitude], [maxLatitude, maxLongitude]); + }, [group]); + + function getClusterGroupIcon(markerCluster: MarkerCluster): DivIcon { + const iconUrl = markerCluster + .getAllChildMarkers() + .filter((m) => m.getIcon().options.iconUrl !== '/icons/thumb-placeholder.png')[0] + .getIcon().options.iconUrl; + + return new DivIcon({ + html: ReactDom.renderToString( + <> + + {markerCluster.getChildCount()} + , + ), + iconSize: [36, 36], + className: styles.markerIcon, + }); + } + + function createMarkers(media: MediaItem[]): JSX.Element { + if (media.length === 0) { + return <>; + } + + return ( + + {media.map((mediaItem, index) => ( + handleMarkerClick(mediaItem.name) }} + /> + ))} + + ); + } + + const mapBounds = useMemo(() => { + function MapBounds({ bounds }: { bounds: LatLngBounds }): JSX.Element { + const map = useMap(); + + if (map !== undefined) { + map.setView(bounds.getCenter()); + map.fitBounds(bounds, { padding: [40, 40] }); + } + + return <>; + } + + return ; + }, [group]); + + return ( + + <> + + + setTileLayer( + tileLayer === TileLayerType.map ? TileLayerType.satellite : TileLayerType.map, + ) + } + /> + + {tileLayer === TileLayerType.map ? ( + + ) : ( + + )} + + {mapBounds} + {createMarkers(displayedMedia)} + {group?.geoData?.map((data, i) => { + const coordinates = (data.features?.[0]?.geometry as LineString).coordinates; + const startCoords = coordinates[0].slice(0, 2).reverse(); + const finishCoords = coordinates[coordinates.length - 1].slice(0, 2).reverse(); + + return ( + + + + + + ); + })} + + + ); +} diff --git a/src/components/ReactLeafletControl/ReactLeafletControl.tsx b/src/components/ReactLeafletControl/ReactLeafletControl.tsx new file mode 100644 index 0000000..2592b25 --- /dev/null +++ b/src/components/ReactLeafletControl/ReactLeafletControl.tsx @@ -0,0 +1,104 @@ +import React, { + useState, + useEffect, + forwardRef, + useImperativeHandle, + Ref, + ReactNode, + ReactPortal, +} from 'react'; +import { createPortal } from 'react-dom'; +import { Control, ControlOptions, DomUtil, DomEvent, Map } from 'leaflet'; +import { + ElementHook, + LeafletProvider, + LeafletElement, + createElementHook, + LeafletContextInterface, + createControlHook, +} from '@react-leaflet/core'; +import { useMap } from 'react-leaflet'; + +interface IDumbControl extends Control {} +interface PropsWithChildren { + children?: ReactNode; +} +interface ControlOptionsWithChildren extends ControlOptions { + children?: ReactNode; +} + +const DumbControl = Control.extend({ + options: { + className: '', + onOff: '', + handleOff: function noop() {}, + }, + + onAdd(/* map */) { + const _controlDiv = DomUtil.create('div', this.options.className); + + DomEvent.on(_controlDiv, 'click', (event) => { + DomEvent.stopPropagation(event); + }); + DomEvent.disableScrollPropagation(_controlDiv); + DomEvent.disableClickPropagation(_controlDiv); + + return _controlDiv; + }, + + onRemove(map: Map) { + if (this.options.onOff !== '') { + map.off(this.options.onOff, this.options.handleOff, this); + } + + return this; + }, +}); + +const useForceUpdate: () => () => void = () => { + const [, setValue] = useState(0); // integer state + return () => setValue((value) => value + 1); // update the state to force render +}; + +export function createContainerComponent( + useElement: ElementHook, +): React.ForwardRefExoticComponent & React.RefAttributes> { + function ContainerComponent(props: P, ref: Ref): ReactPortal | null { + const forceUpdate = useForceUpdate(); + const map = useMap(); + const ctx = { __version: 0, map }; + const { instance, context } = useElement(props, ctx).current; + const children = props.children; + const contentNode = (instance as any).getContainer(); + + useImperativeHandle(ref, () => instance); + useEffect(() => { + forceUpdate(); + }, [contentNode]); + + if (children === undefined || contentNode === undefined) return null; + + return createPortal({children}, contentNode); + } + + return forwardRef(ContainerComponent); +} + +export function createControlComponent( + createInstance: (props: P) => E, +): React.ForwardRefExoticComponent & React.RefAttributes> { + function createElement(props: P, context: LeafletContextInterface): LeafletElement { + return { instance: createInstance(props), context }; + } + const useElement = createElementHook(createElement); + const useControl = createControlHook(useElement); + return createContainerComponent(useControl); +} + +const ReactLeafletControl = createControlComponent( + function createControlWithChildren(props) { + return new DumbControl(props); + }, +); + +export default ReactLeafletControl; diff --git a/src/components/Sidebar/Sidebar.module.css b/src/components/Sidebar/Sidebar.module.css new file mode 100644 index 0000000..8081492 --- /dev/null +++ b/src/components/Sidebar/Sidebar.module.css @@ -0,0 +1,158 @@ +.aside { + min-width: 272px; + width: 20%; + height: 100%; + padding: 5px; + display: flex; + flex-direction: column; + border-right: 1px solid #e5e5e5; + box-shadow: -2px 0 10px #000; + z-index: 9999; + background-color: #fff; +} + +.headline { + padding-left: 16px; + position: relative; +} + +.headline h2 { + display: inline-block; + line-height: 40px; + margin-bottom: 0; +} + +.headline button { + width: 40px; + height: 40px; + padding: 0; + margin-left: 2px; + display: inline-block; + border: none; + border-radius: 100%; + background: none; + cursor: pointer; +} + +.headline button:hover { + background: #e9f3ff; +} + +.headline button div { + width: 8px; + height: 8px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #000; + display: inline-block; +} + +.tripList { + min-width: 80%; + padding: 5px 0; + position: absolute; + top: 100%; + left: 16px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 5px; + box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1); +} + +.tripList ul { + margin: 0; + padding: 0; + list-style-type: none; +} + +.tripList li { + padding: 5px; + user-select: none; +} + +.tripList li a { + padding: 10px 20px 10px 10px; + display: block; + border-radius: 5px; + font-weight: bold; + cursor: pointer; +} + +.tripList li a:hover { + background-color: #f5f5f5; +} + +.list { + margin: 24px 0 0 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.listItem { + min-height: 56px; + margin-bottom: 5px; + padding: 10px 16px; + display: flex; + border-radius: 10px; + cursor: pointer; + user-select: none; + outline: currentcolor none medium; +} + +.listItem:hover { + background-color: #f5f5f5; +} + +.listItem:hover > .listItemButton{ + display: flex; +} + +.listItemActive { + background-color: #e9f3ff !important; +} + +.listItemContent { + max-width: calc(100% - 36px); + margin: auto 0; + flex-grow: 1; +} + +.listItemButton { + width: 36px; + height: 36px; + padding: 9px; + margin: auto; + display: none; + border: none; + border-radius: 36px; + box-shadow: 0 0 0 1px rgb(0 0 0 / 10%); + background: #fff; + cursor: pointer; +} + +.listItemButton:hover { + background: #fafafa; +} + +.listItemButton svg { + margin: auto; +} + +.preformatted { + white-space: pre; +} + +@media only screen and (max-width: 500px) { + .aside { + position: absolute; + top: 0; + bottom: 0; + left: -100vw; + transition: left 0.3s ease-in; + } + + .asideOpen { + left: 0; + } +} diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..b4ddaa9 --- /dev/null +++ b/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { Group, Trip } from '../../models'; + +import styles from './Sidebar.module.css'; + +interface Props { + trips: Trip[]; + currentTrip: Trip; + groups: Group[]; + currentGroup: Group | undefined; + headline?: string; + asideOpen: boolean; + handleClose: () => void; + handleGroupChange: (groupIndex: number, openGallery?: boolean) => void; + handleTripChange: (tripIndex: number) => void; +} + +export default function Sidebar({ + trips, + currentTrip, + groups, + currentGroup, + asideOpen, + handleClose, + handleGroupChange, + handleTripChange, +}: Props): JSX.Element { + const [tripListOpen, setTripListOpen] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: Event): void { + if ( + wrapperRef.current !== null && + e.target !== null && + !wrapperRef.current.contains(e.target as Node) + ) { + handleClose(); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [wrapperRef]); + + function groupChangeHandler(groupIndex: number, openGallery?: boolean): void { + handleGroupChange(groupIndex, openGallery); + handleClose(); + } + + function tripChangeHandler(tripIndex: number): void { + handleTripChange(tripIndex); + setTripListOpen(false); + } + + function buildGroupDescription( + template: string | undefined, + tokens: { [key: string]: string | number } | undefined, + ): string { + if (template === undefined) { + return ''; + } + + let description = template; + + if (tokens === undefined) { + return description; + } + + Object.keys(tokens).forEach((key) => { + description = description.replaceAll(`{${key}}`, `${tokens[key]}`); + }); + + return description; + } + + const asideStyle = asideOpen ? `${styles.aside} ${styles.asideOpen}` : styles.aside; + + return ( + + ); +} diff --git a/src/components/TileLayerControl/TileLayerControl.module.css b/src/components/TileLayerControl/TileLayerControl.module.css new file mode 100644 index 0000000..ffff063 --- /dev/null +++ b/src/components/TileLayerControl/TileLayerControl.module.css @@ -0,0 +1,20 @@ +.tileLayerControl { + width: 64px; + height: 64px; +} + +.map { + background-image: url('/public/icons/map.png'); +} + +.satellite { + background-image: url('/public/icons/satellite.png'); +} + +.tileLayerControl button { + width: 100%; + height: 100%; + border: none; + cursor: pointer; +} + diff --git a/src/components/TileLayerControl/TileLayerControl.tsx b/src/components/TileLayerControl/TileLayerControl.tsx new file mode 100644 index 0000000..026ade1 --- /dev/null +++ b/src/components/TileLayerControl/TileLayerControl.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './TileLayerControl.module.css'; + +export enum TileLayerType { + map, + satellite, +} + +interface Props { + tileType: TileLayerType; + onClick: () => void; +} + +export default function TileLayerControl({ tileType, onClick }: Props): JSX.Element { + const bgClass = tileType === TileLayerType.map ? styles.satellite : styles.map; + return ( +
+ +
+ ); +} -- cgit v1.2.3