aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarcin Zelent <marcin@zelent.net>2022-11-16 15:16:38 +0100
committerMarcin Zelent <marcin@zelent.net>2022-11-16 15:16:38 +0100
commitf2ecc1803f3ea294a0c6b7915b61348ed0395b26 (patch)
treee8c6fb1350ae4f659b3f9ef8d17157158b974b16 /src/components
parentefb64f24d6200a39870c0e8966ab4f87e07c93a9 (diff)
Remade and extended the app using React
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Lightbox/Lightbox.css7
-rw-r--r--src/components/Lightbox/Lightbox.ts152
-rw-r--r--src/components/LoadingScreen/LoadingScreen.module.css27
-rw-r--r--src/components/LoadingScreen/LoadingScreen.tsx10
-rw-r--r--src/components/Map/Map.module.css28
-rw-r--r--src/components/Map/Map.tsx190
-rw-r--r--src/components/ReactLeafletControl/ReactLeafletControl.tsx104
-rw-r--r--src/components/Sidebar/Sidebar.module.css158
-rw-r--r--src/components/Sidebar/Sidebar.tsx152
-rw-r--r--src/components/TileLayerControl/TileLayerControl.module.css20
-rw-r--r--src/components/TileLayerControl/TileLayerControl.tsx21
11 files changed, 869 insertions, 0 deletions
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 =
+ '<svg class="pswp__icn" width="26" height="22" aria-hidden="true" version="1.1" viewBox="-16 -8 26 22" xmlns="http://www.w3.org/2000/svg"><use class="pswp__icn-shadow" xlink:href="#pswp__icn-fullscreen-request"></use><use class="pswp__icn-shadow" xlink:href="#pswp__icn-fullscreen-exit"></use><path id="pswp__icn-fullscreen-request" d="m-12 10v-5h2v3h3v2zm0-14h5v2h-3v3h-2zm18 0v5h-2v-3h-3v-2zm0 14h-5v-2h3v-3h2z" style="stroke-width:1.0001"/> <path id="pswp__icn-fullscreen-exit" d="m-7 5v5h-2v-3h-3v-2zm0-4h-5v-2h3v-3h2zm8 0v-5h2v3h3v2zm0 4h5v2h-3v3h-2z" style="display:none"/></svg>';
+
+ 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<void>;
+ webkitFullscreenElement?: Element;
+ [key: string]: any;
+}
+
+interface WebkitHTMLElement extends HTMLElement {
+ webkitRequestFullscreen: () => Promise<void>;
+ [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 (
+ <div className={styles.container}>
+ <div className={styles.spinner}></div>
+ </div>
+ );
+}
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>(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(
+ <>
+ <img style={{ width: 36, height: 36 }} src={iconUrl} alt="" />
+ <span className={styles.markerItemCount}>{markerCluster.getChildCount()}</span>
+ </>,
+ ),
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ });
+ }
+
+ function createMarkers(media: MediaItem[]): JSX.Element {
+ if (media.length === 0) {
+ return <></>;
+ }
+
+ return (
+ <MarkerClusterGroup iconCreateFunction={getClusterGroupIcon}>
+ {media.map((mediaItem, index) => (
+ <Marker
+ key={index}
+ position={[mediaItem.latitude as number, mediaItem.longitude as number]}
+ icon={
+ new Icon({
+ iconUrl: mediaItem.thumbnail ?? '/icons/thumb-placeholder.png',
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ })
+ }
+ eventHandlers={{ click: () => handleMarkerClick(mediaItem.name) }}
+ />
+ ))}
+ </MarkerClusterGroup>
+ );
+ }
+
+ 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 <MapBounds bounds={bounds} />;
+ }, [group]);
+
+ return (
+ <MapContainer
+ bounds={bounds}
+ scrollWheelZoom
+ style={{ height: '100%', width: '100%' }}
+ keyboard
+ zoomControl={false}
+ >
+ <>
+ <ReactLeafletControl position="bottomleft">
+ <TileLayerControl
+ key="tile-layer-control"
+ tileType={tileLayer}
+ onClick={() =>
+ setTileLayer(
+ tileLayer === TileLayerType.map ? TileLayerType.satellite : TileLayerType.map,
+ )
+ }
+ />
+ </ReactLeafletControl>
+ {tileLayer === TileLayerType.map ? (
+ <TileLayer
+ key="tile-layer"
+ attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ maxNativeZoom={19}
+ maxZoom={19}
+ />
+ ) : (
+ <TileLayer
+ key="tile-layer-satellite"
+ attribution='&copy; <a href="https://google.com/">Google</a>'
+ url="https://{s}.google.com/kh/v=930?x={x}&amp;y={y}&amp;z={z}"
+ subdomains={['khms0', 'khms1', 'khms2', 'khms3']}
+ maxNativeZoom={21}
+ maxZoom={21}
+ />
+ )}
+ <ZoomControl key="zoom-control" position="bottomright" />
+ {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 (
+ <React.Fragment key={i}>
+ <GeoJSON key={`geodata-${group.id}-${i}`} data={data} />
+ <Marker
+ key={`start-marker-${group.id}-${i}`}
+ position={startCoords as [number, number]}
+ icon={new Icon({ iconUrl: '/icons/start.png', className: styles.iconShadow })}
+ />
+ <Marker
+ key={`finish-marker-${group.id}-${i}`}
+ position={finishCoords as [number, number]}
+ icon={new Icon({ iconUrl: '/icons/finish.png', className: styles.iconShadow })}
+ />
+ </React.Fragment>
+ );
+ })}
+ </>
+ </MapContainer>
+ );
+}
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<E, P extends PropsWithChildren>(
+ useElement: ElementHook<E, P>,
+): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<E>> {
+ function ContainerComponent(props: P, ref: Ref<E>): 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(<LeafletProvider value={context}>{children}</LeafletProvider>, contentNode);
+ }
+
+ return forwardRef(ContainerComponent);
+}
+
+export function createControlComponent<E extends Control, P extends ControlOptionsWithChildren>(
+ createInstance: (props: P) => E,
+): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<E>> {
+ function createElement(props: P, context: LeafletContextInterface): LeafletElement<E> {
+ return { instance: createInstance(props), context };
+ }
+ const useElement = createElementHook(createElement);
+ const useControl = createControlHook(useElement);
+ return createContainerComponent(useControl);
+}
+
+const ReactLeafletControl = createControlComponent<IDumbControl, ControlOptionsWithChildren>(
+ 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 | HTMLElement>(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 (
+ <aside ref={wrapperRef} className={asideStyle}>
+ {currentTrip.name !== undefined && (
+ <div className={styles.headline}>
+ <h2>{currentTrip.name}</h2>
+ {trips.length > 1 && (
+ <button
+ type="button"
+ title="Show list of trips"
+ onClick={() => setTripListOpen(!tripListOpen)}
+ >
+ <div></div>
+ </button>
+ )}
+ {tripListOpen && (
+ <div className={styles.tripList}>
+ <ul>
+ {trips.map((t, i) => (
+ <li key={i}>
+ <a onClick={() => tripChangeHandler(i)}>{t.name}</a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ )}
+ <ul className={styles.list}>
+ {groups.map((group, index) => {
+ const listItemStyle =
+ group.id === currentGroup?.id
+ ? `${styles.listItem} ${styles.listItemActive}`
+ : styles.listItem;
+
+ return (
+ <li key={index}>
+ <a
+ onClick={() => groupChangeHandler(index)}
+ onKeyPress={() => groupChangeHandler(index)}
+ tabIndex={0}
+ role="menuitem"
+ className={listItemStyle}
+ >
+ <div className={styles.listItemContent}>
+ <b>{group.name}</b>
+ <br />
+ <div className={styles.preformatted}>
+ {buildGroupDescription(group.description, group.metadata)}
+ </div>
+ </div>
+ <button
+ type="button"
+ title="Show gallery"
+ className={styles.listItemButton}
+ onClick={(e) => {
+ e.stopPropagation();
+ groupChangeHandler(index, true);
+ }}
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
+ <path d="M512 32H160c-35.35 0-64 28.65-64 64v224c0 35.35 28.65 64 64 64H512c35.35 0 64-28.65 64-64V96C576 60.65 547.3 32 512 32zM528 320c0 8.822-7.178 16-16 16h-16l-109.3-160.9C383.7 170.7 378.7 168 373.3 168c-5.352 0-10.35 2.672-13.31 7.125l-62.74 94.11L274.9 238.6C271.9 234.4 267.1 232 262 232c-5.109 0-9.914 2.441-12.93 6.574L176 336H160c-8.822 0-16-7.178-16-16V96c0-8.822 7.178-16 16-16H512c8.822 0 16 7.178 16 16V320zM224 112c-17.67 0-32 14.33-32 32s14.33 32 32 32c17.68 0 32-14.33 32-32S241.7 112 224 112zM456 480H120C53.83 480 0 426.2 0 360v-240C0 106.8 10.75 96 24 96S48 106.8 48 120v240c0 39.7 32.3 72 72 72h336c13.25 0 24 10.75 24 24S469.3 480 456 480z" />
+ </svg>
+ </button>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </aside>
+ );
+}
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 (
+ <div className={`${styles.tileLayerControl} leaflet-bar`}>
+ <button type="button" className={bgClass} onClick={onClick}></button>
+ </div>
+ );
+}