aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.module.css43
-rw-r--r--src/App.test.tsx9
-rw-r--r--src/App.tsx303
-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
-rw-r--r--src/generate-data.ts563
-rw-r--r--src/index.css19
-rw-r--r--src/index.tsx19
-rw-r--r--src/lib/MarkerClusterGroup.js32
-rw-r--r--src/lib/util.ts30
-rw-r--r--src/models/Group.ts35
-rw-r--r--src/models/MediaItem.ts53
-rw-r--r--src/models/MediaType.ts6
-rw-r--r--src/models/Trip.ts28
-rw-r--r--src/models/index.guard.ts58
-rw-r--r--src/models/index.ts7
-rw-r--r--src/react-app-env.d.ts1
-rw-r--r--src/reportWebVitals.ts17
-rw-r--r--src/setupTests.ts5
-rw-r--r--src/types/geojson-bounds.d.ts33
-rw-r--r--src/types/photoswipe-lightbox.d.ts104
-rw-r--r--src/types/types.d.ts14
31 files changed, 2248 insertions, 0 deletions
diff --git a/src/App.module.css b/src/App.module.css
new file mode 100644
index 0000000..075f53c
--- /dev/null
+++ b/src/App.module.css
@@ -0,0 +1,43 @@
+.container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ overflow: hidden;
+}
+
+.main {
+ height: 100%;
+ flex: 1 1 auto;
+}
+
+.asideToggle {
+ display: none;
+}
+
+@media only screen and (max-width: 500px) {
+ .asideToggle {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 2px solid rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+ display: block;
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 999;
+ background-color: #fff;
+ background-clip: padding-box;
+ cursor: pointer;
+ user-select: none;
+ outline: currentcolor none medium;
+ }
+
+ .asideToggle:hover {
+ background-color: #f4f4f4;
+ }
+
+ .asideOpen {
+ left: 0;
+ }
+}
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..585a39e
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+ render(<App />);
+ const linkElement = screen.getByText(/learn react/i);
+ expect(linkElement).toBeInTheDocument();
+});
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..8dece4e
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,303 @@
+import React, { useEffect, useState } from 'react';
+import { SlideData } from 'photoswipe';
+import PhotoSwipeLightbox from 'photoswipe/lightbox';
+import Sidebar from './components/Sidebar/Sidebar';
+import Map from './components/Map/Map';
+import { createLightbox } from './components/Lightbox/Lightbox';
+import { MediaType, Trip } from './models';
+import { isTrip } from './models/index.guard';
+
+import styles from './App.module.css';
+import 'photoswipe/style.css';
+import LoadingScreen from './components/LoadingScreen/LoadingScreen';
+
+function App(): JSX.Element {
+ const [allTrips, setAllTrips] = useState<Trip[]>([]);
+ const [currentTripIndex, setCurrentTripIndex] = useState<number>(0);
+ const [currentGroupIndex, setCurrentGroupIndex] = useState<number>(1);
+ const [asideOpen, setAsideOpen] = useState<boolean>(false);
+ const [lightbox, setLightbox] = useState<PhotoSwipeLightbox>();
+ const [openLightbox, setOpenLightbox] = useState<boolean>(false);
+ const [isLoading, setIsLoading] = useState<boolean>(true);
+ const [error, setError] = useState<string>();
+
+ const currentTrip = allTrips[currentTripIndex];
+ const currentGroup = currentTrip?.groups?.[currentGroupIndex];
+
+ async function getAllTrips(): Promise<Trip[]> {
+ try {
+ const url = process.env.REACT_APP_DATA_URL ?? '/data/index.json';
+
+ const res = await fetch(url);
+ const data = await res.json();
+
+ if (!Array.isArray(data) || data.length === 0) {
+ return [];
+ }
+
+ data.forEach((obj) => {
+ if (!isTrip(obj)) {
+ throw new Error('The requested data file has incorrect structure or types.');
+ }
+ });
+
+ return data;
+ } catch (err) {
+ setError(`An error occurred while retrieving data: "${err as string}"`);
+ console.error(err);
+ return [];
+ }
+ }
+
+ async function getTrip(url: string): Promise<Trip | undefined> {
+ try {
+ if (url === undefined) {
+ return;
+ }
+
+ const res = await fetch(url);
+ const data: Trip = await res.json();
+
+ if (!isTrip(data)) {
+ throw new Error('The requested data file has incorrect structure or types.');
+ }
+
+ const trip = {
+ ...data,
+ url,
+ downloaded: true,
+ };
+
+ if (data.groups !== undefined) {
+
+ trip.groups = [
+ {
+ id: 'all',
+ name: 'Show all',
+ media: data.groups?.flatMap((g) => g.media),
+ geoData: data.groups?.flatMap((g) => g.geoData !== undefined ? g.geoData : [])
+ },
+ ...data.groups,
+ ];
+ }
+
+ return trip;
+ } catch (err) {
+ setError(`An error occurred while retrieving data: "${err as string}"`);
+ console.error(err);
+ }
+ }
+
+ function updateAllTrips(trips: Trip[], trip: Trip): void {
+ const updatedTrips = (trips).map((t) => {
+ if (t.id === trip.id) {
+ t = trip;
+ }
+
+ return t;
+ });
+
+ setAllTrips(updatedTrips);
+ }
+
+ function updateUrlHash(trip: Trip): void {
+ const groupId = trip.groups?.[1] !== undefined ? `/${trip.groups[1].id}` : '';
+ window.location.hash = `${trip.id}${groupId}`;
+ }
+
+ async function getFirstTrip(trips: Trip[]): Promise<void> {
+ const trip = await getTrip(trips[0].url);
+
+ if (trip !== undefined) {
+ updateAllTrips(trips, trip);
+ updateUrlHash(trip);
+ }
+ }
+
+ async function getTripFromUrlHash(trips: Trip[]): Promise<void> {
+ if (window.location.hash.length === 0) {
+ return await getFirstTrip(trips);
+ }
+
+ const hash = window.location.hash.endsWith('/')
+ ? window.location.hash.slice(0, window.location.hash.length - 1)
+ : window.location.hash;
+
+ const [tripId, groupId] = hash.substring(1).split('/');
+
+ const tripIndex = trips.findIndex((t) => t.id === tripId);
+
+ if (tripIndex < 0) {
+ return await getFirstTrip(trips);
+ }
+
+ const trip = await getTrip(trips[tripIndex].url);
+
+ if (trip === undefined) {
+ return await getFirstTrip(trips);
+ }
+
+ updateAllTrips(trips, trip);
+ setCurrentTripIndex(tripIndex);
+
+ const groupIndex = trip.groups?.findIndex((g) => g.id === groupId);
+ if (groupIndex !== undefined && groupIndex > -1) {
+ setCurrentGroupIndex(groupIndex);
+ } else {
+ updateUrlHash(trip);
+ }
+ }
+
+ useEffect(() => {
+ void getAllTrips().then(async (trips) => {
+ setAllTrips(trips);
+ await getTripFromUrlHash(trips);
+ setIsLoading(false);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (currentTrip !== undefined && currentGroup !== undefined) {
+ window.location.hash = `${currentTrip.id}/${currentGroup.id}`;
+ }
+ }, [currentTripIndex, currentGroupIndex]);
+
+ useEffect(() => {
+ if (lightbox !== undefined) {
+ lightbox.destroy();
+ }
+
+ if (currentGroup?.media === undefined || currentGroup?.media.length === 0) {
+ return;
+ }
+
+ const dataSource = currentGroup?.media.map<SlideData>((mediaItem) => {
+ const slideData: SlideData = {
+ width: mediaItem.width,
+ height: mediaItem.height,
+ alt: mediaItem.caption,
+ };
+
+ if (mediaItem.type === MediaType.Photo) {
+ slideData.type = 'image';
+ slideData.src = mediaItem.src;
+ } else if (mediaItem.type === MediaType.Video) {
+ slideData.type = 'video';
+ slideData.videoSrc = mediaItem.src;
+ }
+
+ return slideData;
+ });
+
+ const lb = createLightbox(dataSource);
+
+ lb.on('beforeOpen', () => setIsLoading(false));
+
+ lb.init();
+
+ if (openLightbox) {
+ lb.loadAndOpen(0);
+ setOpenLightbox(false);
+ }
+
+ setLightbox(lb);
+ }, [currentGroup]);
+
+ function handleMarkerClick(mediaName: string): void {
+ if (currentGroup === undefined) {
+ return;
+ }
+
+ const index = currentGroup.media.findIndex((p) => p.name === mediaName);
+ if (index < 0) {
+ lightbox?.loadAndOpen(0);
+ } else {
+ lightbox?.loadAndOpen(index);
+ }
+ }
+
+ function handleGroupChange(groupIndex: number, openGallery?: boolean): void {
+ setCurrentGroupIndex(groupIndex);
+
+ if (openGallery ?? false) {
+ setOpenLightbox(true);
+ }
+ }
+
+ function handleTripChange(tripIndex: number): void {
+ if (allTrips[tripIndex] === undefined) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ if (!allTrips[tripIndex].downloaded) {
+ void getTrip(allTrips[tripIndex].url).then((trip) => {
+ if (trip === undefined) {
+ return;
+ }
+
+ updateAllTrips(allTrips, trip);
+
+ setCurrentTripIndex(tripIndex);
+ setCurrentGroupIndex(1);
+ setIsLoading(false);
+ });
+ } else {
+ setCurrentTripIndex(tripIndex);
+ setCurrentGroupIndex(1);
+ setIsLoading(false);
+ }
+ }
+
+ if (error !== undefined) {
+ return <>{error}</>;
+ }
+
+ if (currentTrip === undefined || currentTrip.groups === undefined) {
+ return <LoadingScreen />;
+ }
+
+ return (
+ <div className={styles.container}>
+ <Sidebar
+ trips={allTrips}
+ currentTrip={currentTrip}
+ groups={currentTrip.groups}
+ currentGroup={currentGroup}
+ asideOpen={asideOpen}
+ handleClose={() => setAsideOpen(false)}
+ handleGroupChange={handleGroupChange}
+ handleTripChange={handleTripChange}
+ />
+
+ <button
+ type="button"
+ title="Toggle sidebar menu"
+ className={styles.asideToggle}
+ onClick={() => setAsideOpen(true)}
+ >
+ <svg
+ width="32"
+ height="32"
+ version="1.1"
+ viewBox="0 0 64 64"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <g strokeMiterlimit="10" strokeWidth=".4165" fill="#666">
+ <rect x="8" y="16" width="48" height="6" />
+ <rect x="8" y="30" width="48" height="6" />
+ <rect x="8" y="44" width="48" height="6" />
+ </g>
+ </svg>
+ </button>
+
+ <main className={styles.main}>
+ <Map group={currentGroup} handleMarkerClick={handleMarkerClick} />
+ </main>
+ {isLoading && <LoadingScreen />}
+ </div>
+ );
+}
+
+export default App;
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>
+ );
+}
diff --git a/src/generate-data.ts b/src/generate-data.ts
new file mode 100644
index 0000000..a67c620
--- /dev/null
+++ b/src/generate-data.ts
@@ -0,0 +1,563 @@
+import { constants as fsConstants, Dirent } from 'fs';
+import { access, mkdir, readdir, readFile, writeFile } from 'fs/promises';
+import { promisify } from 'util';
+import path from 'path';
+import exifReader from 'exif-reader';
+import sizeOf from 'image-size';
+import sharp from 'sharp';
+import ffmpegPath from 'ffmpeg-static';
+import ffprobePath from 'ffprobe-static';
+import FfmpegCommand, { FfprobeData } from 'fluent-ffmpeg';
+import { DOMParser } from 'xmldom';
+import { FeatureCollection, LineString } from 'geojson';
+import { gpx } from '@tmcw/togeojson';
+import gb from 'geojson-bounds';
+import { MediaItem, Group, MediaType } from './models';
+import { distanceBetween, secondsToTimeString } from './lib/util';
+
+const promisifiedSizeOf = promisify(sizeOf);
+
+interface GroupedFiles {
+ photos: Dirent[];
+ videos: Dirent[];
+ geoData: Dirent[];
+}
+
+interface DimensionsTuple {
+ width: number;
+ height: number;
+}
+
+interface FileError {
+ file: string;
+ error: any;
+}
+
+const failedFiles: FileError[] = [];
+
+function setupFfmpeg(): void {
+ if (ffmpegPath === null) {
+ console.warn(
+ 'Could not use built-in FFmpeg. Make sure you have FFmpeg installed, if you want to process videos.',
+ );
+ return;
+ }
+
+ FfmpegCommand.setFfmpegPath(ffmpegPath);
+ FfmpegCommand.setFfprobePath(ffprobePath.path);
+}
+
+function hasExtension(filename: string, acceptedExtensions: string[]): boolean {
+ const index = filename.lastIndexOf('.');
+ if (index < 0) {
+ return false;
+ }
+
+ const extension = filename.substring(index + 1);
+
+ return acceptedExtensions.includes(extension);
+}
+
+function hasImageExtension(filename: string): boolean {
+ const acceptedExtensions = [
+ 'apng',
+ 'avif',
+ 'bmp',
+ 'dib',
+ 'gif',
+ 'jfi',
+ 'jfif',
+ 'jif',
+ 'jpe',
+ 'jpeg',
+ 'jpg',
+ 'png',
+ 'tif',
+ 'tiff',
+ 'webp',
+ ];
+
+ return hasExtension(filename, acceptedExtensions);
+}
+
+function hasVideoExtension(filename: string): boolean {
+ const acceptedExtensions = ['avi', 'mkv', 'mov', 'mp4', 'webm'];
+
+ return hasExtension(filename, acceptedExtensions);
+}
+
+function convertCoordinate(coordinate: number[], coordinateRef: string): number | undefined {
+ if (coordinate !== undefined && coordinateRef !== undefined) {
+ const ref = coordinateRef === 'N' || coordinateRef === 'E' ? 1 : -1;
+ return ref * (coordinate[0] + coordinate[1] / 60 + coordinate[2] / 3600);
+ }
+}
+
+async function checkIfFileExists(filepath: string): Promise<boolean> {
+ try {
+ await access(filepath, fsConstants.F_OK);
+ return true;
+ } catch (err: any) {
+ if (err.code === 'ENOENT') {
+ return false;
+ }
+
+ throw err;
+ }
+}
+
+function getNormalImageSize({
+ width,
+ height,
+ orientation,
+}: {
+ width: number;
+ height: number;
+ orientation: number;
+}): DimensionsTuple {
+ return orientation >= 5 ? { width: height, height: width } : { width, height };
+}
+
+async function resizePhoto(
+ photo: sharp.Sharp,
+ filepath: string,
+ orientation: number,
+): Promise<DimensionsTuple> {
+ const outputInfo = await photo
+ .withMetadata({ orientation })
+ .resize({
+ width: orientation >= 5 ? 1080 : undefined,
+ height: 1080,
+ fit: sharp.fit.inside,
+ withoutEnlargement: true,
+ })
+ .toFile(filepath);
+
+ return getNormalImageSize({ ...outputInfo, orientation });
+}
+
+async function resizePhotoOrGetSize(
+ photo: sharp.Sharp,
+ filepath: string,
+ orientation: number,
+): Promise<DimensionsTuple> {
+ const absolutePath = path.resolve(filepath);
+ const fileExists = await checkIfFileExists(absolutePath);
+
+ if (!fileExists) {
+ console.log(`File "${absolutePath}" doesn't exist, resizing the image...`);
+ return await resizePhoto(photo, absolutePath, orientation);
+ }
+
+ console.log(`File ${absolutePath} already exists, trying to get dimensions of the image...`);
+
+ try {
+ const dimensions = await promisifiedSizeOf(absolutePath);
+ if (dimensions?.width !== undefined || dimensions?.height !== undefined) {
+ return getNormalImageSize({
+ width: dimensions.width as number,
+ height: dimensions.height as number,
+ orientation,
+ });
+ }
+ } catch (err) {
+ console.error(`Could not get the dimensions of the image ${absolutePath}.`);
+ console.error(err);
+ }
+
+ console.log(`Trying to resize the image ${absolutePath}...`);
+ return await resizePhoto(photo, absolutePath, orientation);
+}
+
+async function generateThumbnail(
+ photo: sharp.Sharp,
+ filepath: string,
+ orientation?: number,
+): Promise<void> {
+ const fileExists = await checkIfFileExists(filepath);
+ if (!fileExists) {
+ await photo
+ .withMetadata({ orientation })
+ .resize({ width: 36, height: 36, withoutEnlargement: true })
+ .toFile(filepath);
+ }
+}
+
+async function getPhotos(
+ inputDir: string,
+ outputPath: string,
+ serverPath: string,
+ relativePath: string,
+ files: Dirent[],
+): Promise<MediaItem[]> {
+ const photos: MediaItem[] = [];
+
+ for (const file of files) {
+ const inputFile = `${inputDir}/${file.name}`;
+ const outputDir = `${outputPath}/${relativePath}`;
+
+ try {
+ // load photo using sharp
+ const photo = sharp(inputFile, { failOnError: false });
+
+ // read photo metadata
+ const metadata = await photo.metadata();
+ const exifData = exifReader(metadata.exif);
+
+ // reformat EXIF coordinates to usable ones
+ const latitude = convertCoordinate(exifData.gps?.GPSLatitude, exifData.gps?.GPSLatitudeRef);
+ const longitude = convertCoordinate(
+ exifData.gps?.GPSLongitude,
+ exifData.gps?.GPSLongitudeRef,
+ );
+
+ await mkdir(outputDir, { recursive: true });
+
+ // resize the photo or get size of existing
+ const outputInfo = await resizePhotoOrGetSize(
+ photo,
+ `${outputDir}/${file.name}`,
+ metadata.orientation ?? 1,
+ );
+
+ await generateThumbnail(photo, `${outputDir}/thumb-${file.name}`, metadata.orientation);
+
+ photos.push({
+ name: file.name,
+ src: `${serverPath}/${relativePath}/${file.name}`,
+ type: MediaType.Photo,
+ width: outputInfo.width,
+ height: outputInfo.height,
+ latitude,
+ longitude,
+ time: exifData.exif?.DateTimeOriginal,
+ thumbnail: `${serverPath}/${relativePath}/thumb-${file.name}`,
+ caption: exifData.image?.ImageDescription,
+ });
+ } catch (err) {
+ console.error(`Could not process the file ${inputFile}.`);
+ console.error(err);
+ failedFiles.push({ file: inputFile, error: err });
+ }
+ }
+
+ return photos;
+}
+
+async function getVideoInfo(file: string): Promise<FfprobeData> {
+ return await new Promise((resolve, reject) => {
+ FfmpegCommand.ffprobe(file, (err, data) => {
+ if (err !== undefined && err !== null) {
+ return reject(err);
+ }
+
+ return resolve(data);
+ });
+ });
+}
+
+async function optimizeVideo(inputFile: string, outputFile: string): Promise<FfprobeData> {
+ const command = FfmpegCommand(inputFile);
+
+ return await new Promise((resolve, reject) => {
+ command
+ .complexFilter(["scale=-2:min'(1080,ih)'"])
+ .on('progress', (progress) => {
+ console.log(`Processing file ${inputFile}: ${progress.percent as number}% done`);
+ })
+ .on('error', (err) => reject(err))
+ .on('end', () => resolve(getVideoInfo(outputFile)))
+ .save(outputFile);
+ });
+}
+
+async function generateVideoThumbnail(
+ inputFile: string,
+ outputDir: string,
+ outputFileName: string,
+): Promise<void> {
+ const command = FfmpegCommand(inputFile);
+
+ return await new Promise((resolve, reject) => {
+ command
+ .screenshots({ size: '36x36', count: 1, folder: outputDir, filename: outputFileName })
+ .on('error', (err) => reject(err))
+ .on('end', () => resolve());
+ });
+}
+
+async function optimizeVideoOrGetInfo(inputFile: string, outputFile: string): Promise<FfprobeData> {
+ const fileExists = await checkIfFileExists(outputFile);
+
+ if (!fileExists) {
+ console.log(`File "${outputFile}" doesn't exist, optimizing the video...`);
+ return await optimizeVideo(inputFile, outputFile);
+ }
+
+ console.log(`File ${outputFile} already exists, trying to get info about the video...`);
+
+ try {
+ return await getVideoInfo(outputFile);
+ } catch (err) {
+ console.error(`Could not get the info about video ${outputFile}.`);
+ console.error(err);
+ }
+
+ return await optimizeVideo(inputFile, outputFile);
+}
+
+async function processVideo(
+ inputDir: string,
+ outputPath: string,
+ serverPath: string,
+ relativePath: string,
+ file: Dirent,
+): Promise<MediaItem> {
+ const inputFile = `${inputDir}/${file.name}`;
+ const outputDir = `${outputPath}/${relativePath}`;
+ const outputFileName = `${file.name.substring(0, file.name.lastIndexOf('.'))}.webm`;
+ const outputFile = path.resolve(`${outputDir}/${outputFileName}`);
+
+ try {
+ await mkdir(outputDir, { recursive: true });
+
+ // re-encode video or get info about the existing one
+ const info = await optimizeVideoOrGetInfo(inputFile, outputFile);
+ const videoStream = info.streams.find((s) => s.codec_type === 'video');
+
+ // generate thumbnail
+ const thumbnailFileName = `thumb-${file.name.substring(0, file.name.lastIndexOf('.'))}.jpg`;
+ const thumbnailExists = await checkIfFileExists(`${outputDir}/${thumbnailFileName}`);
+ if (!thumbnailExists) {
+ await generateVideoThumbnail(inputFile, outputDir, thumbnailFileName);
+ }
+
+ return {
+ name: outputFileName,
+ src: `${serverPath}/${relativePath}/${outputFileName}`,
+ type: MediaType.Video,
+ width: videoStream?.width ?? 0,
+ height: videoStream?.height ?? 0,
+ thumbnail: `${serverPath}/${relativePath}/${thumbnailFileName}`,
+ };
+ } catch (err) {
+ console.error(`Could not process the file ${inputFile}.`);
+ console.error(err);
+ failedFiles.push({ file: inputFile, error: err });
+ throw err;
+ }
+}
+
+async function getVideos(
+ inputDir: string,
+ outputPath: string,
+ serverPath: string,
+ relativePath: string,
+ files: Dirent[],
+): Promise<MediaItem[]> {
+ const videos: MediaItem[] = [];
+
+ for (const file of files) {
+ const video = await processVideo(inputDir, outputPath, serverPath, relativePath, file);
+ videos.push(video);
+ }
+
+ return videos;
+}
+
+async function getMedia(
+ inputDir: string,
+ outputPath: string,
+ serverPath: string,
+ relativePath: string,
+ files: GroupedFiles,
+): Promise<MediaItem[]> {
+ const photos = await getPhotos(inputDir, outputPath, serverPath, relativePath, files.photos);
+ const videos = await getVideos(inputDir, outputPath, serverPath, relativePath, files.videos);
+ const media = photos.concat(videos);
+
+ return media;
+}
+
+async function getFiles(inputDir: string): Promise<GroupedFiles> {
+ console.log(`Processing files in ${inputDir} directory...`);
+
+ const dirents = await readdir(inputDir, { withFileTypes: true });
+ const files = dirents.reduce<GroupedFiles>(
+ (prev, dirent) => {
+ if (!dirent.isFile()) {
+ return prev;
+ }
+
+ if (hasImageExtension(dirent.name)) {
+ prev.photos.push(dirent);
+ } else if (hasVideoExtension(dirent.name)) {
+ prev.videos.push(dirent);
+ } else if (hasExtension(dirent.name, ['gpx'])) {
+ prev.geoData.push(dirent);
+ }
+
+ return prev;
+ },
+ { photos: [], videos: [], geoData: [] },
+ );
+
+ return files;
+}
+
+async function processGeoData(inputDir: string, files: Dirent[]): Promise<FeatureCollection[]> {
+ const geoData: FeatureCollection[] = [];
+
+ for (const file of files) {
+ // read file contents
+ const fileContents = await readFile(`${inputDir}/${file.name}`, 'utf8');
+ const contentsWithoutNS = fileContents.replace(/\sxmlns[^"]+"[^"]+"/g, '');
+
+ // create DOM from string
+ const doc = new DOMParser().parseFromString(contentsWithoutNS, 'text/xml');
+
+ // convert GPX to GeoJSON
+ const track: FeatureCollection = gpx(doc);
+
+ // add bounding box
+ track.bbox = [gb.xMin(track), gb.yMin(track), gb.xMax(track), gb.yMax(track)];
+
+ geoData.push(track);
+ }
+
+ return geoData;
+}
+
+function createMetadataFromGeoJSON(
+ geoData: FeatureCollection,
+): { [key: string]: string | number } | undefined {
+ if (geoData === undefined) {
+ return;
+ }
+
+ const coordTimes = geoData.features[0].properties?.coordinateProperties?.times;
+
+ if (coordTimes === undefined || coordTimes?.length === 0) {
+ return;
+ }
+
+ // time of the first point
+ const start = coordTimes[0];
+
+ // time of the last point
+ const end = coordTimes[coordTimes.length - 1];
+
+ // total duration in seconds
+ const duration = (new Date(end).getTime() - new Date(start).getTime()) / 1000;
+ const durationString = secondsToTimeString(duration);
+
+ // distance and speed
+ let totalDistance = 0;
+ const speeds = [];
+ const coords = (geoData.features[0].geometry as LineString).coordinates;
+
+ for (let i = 0; i < coords.length - 1; i += 1) {
+ const a = [coords[i][1], coords[i][0]];
+ const b = [coords[i + 1][1], coords[i + 1][0]];
+ const distance = distanceBetween(a, b);
+ if (distance > 0) {
+ totalDistance += distance;
+ const timeBetween = new Date(coordTimes[i + 1]).getTime() - new Date(coordTimes[i]).getTime();
+ speeds.push(distance / timeBetween);
+ }
+ }
+
+ // total distance in km
+ const distance = Math.floor(totalDistance / 10) / 100;
+
+ // average speed in km/h
+ const speedMps = speeds.reduce((acc, val) => acc + val) / speeds.length;
+ const speed = Math.floor(speedMps * 3600 * 100) / 100;
+
+ return { duration: durationString, distance, speed };
+}
+
+async function getGroups(
+ inputPath: string,
+ outputPath: string,
+ serverPath: string,
+ relativePath: string,
+): Promise<Group[]> {
+ const dirs = (await readdir(inputPath, { withFileTypes: true })).filter((dirent) =>
+ dirent.isDirectory(),
+ );
+
+ const groups: Group[] = [];
+
+ for (const dir of dirs) {
+ const name = dir.name;
+ const id = name.replaceAll(' ', '-').toLowerCase();
+
+ const inputDir = `${inputPath}/${name}`;
+ const relativeGroupPath = `${relativePath}/${id}`;
+
+ const files = await getFiles(inputDir);
+
+ const media = await getMedia(inputDir, outputPath, serverPath, relativeGroupPath, files);
+ media.sort((a, b) => (a.name > b.name ? 1 : -1));
+
+ const geoData = await processGeoData(inputDir, files.geoData);
+
+ const metadata = createMetadataFromGeoJSON(geoData[0]);
+
+ const group: Group = { id, name, media, geoData, metadata };
+
+ if (metadata !== undefined) {
+ group.description =
+ 'Total distance: {distance} km\nDuration: {duration}\nAverage speed: {speed} km/h';
+ }
+
+ groups.push(group);
+ }
+
+ return groups;
+}
+
+async function main(): Promise<void> {
+ if (process.argv[2] === undefined || process.argv[3] === undefined) {
+ console.log(
+ 'Usage: npx ts-node ./src/generate-data.ts [INPUT_PATH] [OUTPUT_PATH] [SERVER_PATH]',
+ );
+ return;
+ }
+
+ setupFfmpeg();
+
+ const inputPath = process.argv[2];
+ const outputPath = process.argv[3];
+ const serverPath = process.argv[4] ?? '';
+
+ const name = path.basename(inputPath);
+ const id = name.replaceAll(' ', '-').toLowerCase();
+ const groups = await getGroups(inputPath, outputPath, serverPath, id);
+
+ const trip = { id, name, groups };
+ const tripJson = JSON.stringify(trip);
+
+ const outputDir = `${outputPath}/${id}`;
+ const outputFile = `${outputDir}/index.json`;
+
+ console.log(`Writing output to ${outputFile}...`);
+
+ try {
+ await mkdir(outputDir, { recursive: true });
+ await writeFile(outputFile, tripJson);
+ } catch (err) {
+ console.error(err);
+ }
+
+ if (failedFiles.length > 0) {
+ console.error(
+ `Failed to process:\n${failedFiles
+ .map((f) => `- ${f.file}\n ${f.error as string}`)
+ .join('\n')}`,
+ );
+ }
+}
+
+void main();
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..1b274d3
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,19 @@
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,
+ Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..48aff58
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import './index.css';
+import App from './App';
+import reportWebVitals from './reportWebVitals';
+
+const root = ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement
+);
+root.render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+);
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();
diff --git a/src/lib/MarkerClusterGroup.js b/src/lib/MarkerClusterGroup.js
new file mode 100644
index 0000000..70e2134
--- /dev/null
+++ b/src/lib/MarkerClusterGroup.js
@@ -0,0 +1,32 @@
+import L from 'leaflet'
+import { createPathComponent } from '@react-leaflet/core'
+import 'leaflet.markercluster'
+
+function createMarkerCluster({ children: _c, ...props }, context) {
+ const clusterProps = {}
+ const clusterEvents = {}
+ // Splitting props and events to different objects
+ Object.entries(props).forEach(([propName, prop]) =>
+ propName.startsWith('on')
+ ? (clusterEvents[propName] = prop)
+ : (clusterProps[propName] = prop)
+ )
+ const instance = new L.MarkerClusterGroup(clusterProps)
+
+ // Initializing event listeners
+ Object.entries(clusterEvents).forEach(([eventAsProp, callback]) => {
+ const clusterEvent = `cluster${eventAsProp.substring(2).toLowerCase()}`
+ instance.on(clusterEvent, callback)
+ })
+ return {
+ instance,
+ context: {
+ ...context,
+ layerContainer: instance,
+ },
+ }
+}
+
+const MarkerClusterGroup = createPathComponent(createMarkerCluster)
+
+export default MarkerClusterGroup \ No newline at end of file
diff --git a/src/lib/util.ts b/src/lib/util.ts
new file mode 100644
index 0000000..1b000c0
--- /dev/null
+++ b/src/lib/util.ts
@@ -0,0 +1,30 @@
+/**
+ * Converts time in seconds to HH:mm format.
+ * @param time Time to convert in seconds.
+ */
+export function secondsToTimeString(time: number): string {
+ const h = Math.floor(time / 3600);
+ const m = Math.floor((time % 3600) / 60);
+
+ return `${h < 10 ? `0${h}` : h}:${m < 10 ? `0${m}` : m}`;
+}
+
+/**
+ * Calculates distance between two geographical points.
+ * @param latlng1 Coordinates of the first point.
+ * @param latlng2 Coordinates of the second point.
+ */
+export function distanceBetween(latlng1: number[], latlng2: number[]): number {
+ const R = 6371000;
+ const rad = Math.PI / 180;
+ const lat1 = latlng1[0] * rad;
+ const lat2 = latlng2[0] * rad;
+ const sinDLat = Math.sin(((latlng2[0] - latlng1[0]) * rad) / 2);
+ const sinDLon = Math.sin(((latlng2[1] - latlng1[1]) * rad) / 2);
+ const a = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+
+ return R * c;
+}
+
+export default { secondsToTimeString, distanceBetween };
diff --git a/src/models/Group.ts b/src/models/Group.ts
new file mode 100644
index 0000000..9b3aa6d
--- /dev/null
+++ b/src/models/Group.ts
@@ -0,0 +1,35 @@
+import { FeatureCollection } from 'geojson';
+import MediaItem from './MediaItem';
+
+export default interface Group {
+ /**
+ * ID of the group used in URLs.
+ */
+ id: string;
+
+ /**
+ * Name of the group.
+ */
+ name?: string;
+
+ /**
+ * Description of the group.
+ */
+
+ description?: string;
+
+ /**
+ * Medias in the group.
+ */
+ media: MediaItem[];
+
+ /**
+ * Geo data track info in GeoJSON format.
+ */
+ geoData?: FeatureCollection[];
+
+ /**
+ * Metadata that can be displayed in the description.
+ */
+ metadata?: { [key: string]: string | number };
+}
diff --git a/src/models/MediaItem.ts b/src/models/MediaItem.ts
new file mode 100644
index 0000000..de1410a
--- /dev/null
+++ b/src/models/MediaItem.ts
@@ -0,0 +1,53 @@
+import MediaType from "./MediaType";
+
+export default interface MediaItem {
+ /**
+ * Name of the media item or the file.
+ */
+ name: string;
+
+ /**
+ * Path to the media item.
+ */
+ src: string;
+
+ /**
+ * Type of the media item. Can be a "photo" or a "video".
+ */
+ type: MediaType;
+
+ /**
+ * Width of the media item in pixels.
+ */
+ width: number;
+
+ /**
+ * Height of the media item in pixels.
+ */
+ height: number;
+
+ /**
+ * Latitude where the media item was taken.
+ */
+ latitude?: number;
+
+ /**
+ * Logitude where the media item was taken.
+ */
+ longitude?: number;
+
+ /**
+ * Date and time when the media item was taken.
+ */
+ time?: string;
+
+ /**
+ * Media item thumbnail.
+ */
+ thumbnail?: string;
+
+ /**
+ * Caption on the media item.
+ */
+ caption?: string;
+}
diff --git a/src/models/MediaType.ts b/src/models/MediaType.ts
new file mode 100644
index 0000000..74bb933
--- /dev/null
+++ b/src/models/MediaType.ts
@@ -0,0 +1,6 @@
+enum MediaType {
+ Photo = "photo",
+ Video = "video",
+}
+
+export default MediaType;
diff --git a/src/models/Trip.ts b/src/models/Trip.ts
new file mode 100644
index 0000000..7f92669
--- /dev/null
+++ b/src/models/Trip.ts
@@ -0,0 +1,28 @@
+import Group from './Group';
+
+export default interface Trip {
+ /**
+ * ID of the trip, used internally within the application.
+ */
+ id: string;
+
+ /**
+ * Name of the trip, displayed to the user.
+ */
+ name: string;
+
+ /**
+ * Groups the trip is split into.
+ */
+ groups?: Group[];
+
+ /**
+ * URL to a JSON file containing data for the trip.
+ */
+ url: string;
+
+ /**
+ * Property indicating if the trip data has been already downloaded.
+ */
+ downloaded: boolean;
+}
diff --git a/src/models/index.guard.ts b/src/models/index.guard.ts
new file mode 100644
index 0000000..d564ff9
--- /dev/null
+++ b/src/models/index.guard.ts
@@ -0,0 +1,58 @@
+import { Trip, Group, MediaItem } from './index';
+
+export function isGroup(obj: unknown): obj is Group {
+ const typedObj = obj as Group;
+ const isValid =
+ ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') &&
+ typeof typedObj.id === 'string' &&
+ (typeof typedObj.name === 'undefined' || typeof typedObj.name === 'string') &&
+ (typeof typedObj.description === 'undefined' || typeof typedObj.description === 'string') &&
+ Array.isArray(typedObj.media) &&
+ typedObj.media.every((e: any) => isMediaItem(e));
+
+ if (!isValid) {
+ throw new Error(`Invalid object: ${JSON.stringify(obj)}`);
+ }
+
+ return isValid;
+}
+
+export function isMediaItem(obj: unknown): obj is MediaItem {
+ const typedObj = obj as MediaItem;
+ const isValid =
+ ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') &&
+ typeof typedObj.name === 'string' &&
+ typeof typedObj.src === 'string' &&
+ typeof typedObj.type === 'string' &&
+ (typedObj.type === 'photo' || typedObj.type === 'video') &&
+ typeof typedObj.width === 'number' &&
+ typeof typedObj.height === 'number' &&
+ (typeof typedObj.latitude === 'number' || typeof typedObj.latitude === 'undefined') &&
+ (typeof typedObj.longitude === 'number' || typeof typedObj.longitude === 'undefined') &&
+ (typeof typedObj.time === 'undefined' || typeof typedObj.time === 'string') &&
+ (typeof typedObj.thumbnail === 'string' || typeof typedObj.thumbnail === 'undefined') &&
+ (typeof typedObj.caption === 'undefined' || typeof typedObj.caption === 'string');
+
+ if (!isValid) {
+ throw new Error(`Invalid object: ${JSON.stringify(obj)}`);
+ }
+
+ return isValid;
+}
+
+export function isTrip(obj: unknown): obj is Trip {
+ const typedObj = obj as Trip;
+ const isValid =
+ ((typedObj !== null && typeof typedObj === 'object') || typeof typedObj === 'function') &&
+ typeof typedObj.id === 'string' &&
+ typeof typedObj.name === 'string' &&
+ (typeof typedObj.groups === 'undefined' ||
+ (Array.isArray(typedObj.groups) && typedObj.groups.every((e: any) => isGroup(e)))
+ );
+
+ if (!isValid) {
+ throw new Error(`Invalid object: ${JSON.stringify(obj)}`);
+ }
+
+ return isValid;
+}
diff --git a/src/models/index.ts b/src/models/index.ts
new file mode 100644
index 0000000..506de25
--- /dev/null
+++ b/src/models/index.ts
@@ -0,0 +1,7 @@
+import Group from './Group';
+import MediaItem from './MediaItem';
+import MediaType from './MediaType';
+import Trip from './Trip';
+
+export type { Group, MediaItem, Trip };
+export { MediaType };
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
new file mode 100644
index 0000000..9f9bcb2
--- /dev/null
+++ b/src/react-app-env.d.ts
@@ -0,0 +1 @@
+import 'react-scripts';
diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts
new file mode 100644
index 0000000..73d7c3e
--- /dev/null
+++ b/src/reportWebVitals.ts
@@ -0,0 +1,17 @@
+import { ReportHandler } from 'web-vitals';
+
+type FuncType = (onPerfEntry?: ReportHandler) => void;
+
+const reportWebVitals: FuncType = (onPerfEntry?: ReportHandler) => {
+ if (onPerfEntry !== null && onPerfEntry instanceof Function) {
+ void import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/src/setupTests.ts b/src/setupTests.ts
new file mode 100644
index 0000000..141e479
--- /dev/null
+++ b/src/setupTests.ts
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/src/types/geojson-bounds.d.ts b/src/types/geojson-bounds.d.ts
new file mode 100644
index 0000000..99eb4a0
--- /dev/null
+++ b/src/types/geojson-bounds.d.ts
@@ -0,0 +1,33 @@
+declare module 'geojson-bounds' {
+ import geojson from 'geojson';
+
+ /**
+ * Returns an array of coordinates in the order [West, South, East, North] that represents the extent of the provided feature or geometry
+ */
+ export function extent(geojson: geojson.GeoJSON): [number, number, number, number];
+
+ /**
+ * Returns a GeoJSON Feature polygon that repesents the bounding box of the provided feature or geometry
+ */
+ export function envelope(geojson: geojson.GeoJSON): geojson.Feature<geojson.Polygon>;
+
+ /**
+ * Returns the western-most longitude of the provided feature or geometry
+ */
+ export function xMin(geojson: geojson.GeoJSON): number;
+
+ /**
+ * Returns the eastern-most longitude of the provided feature or geometry
+ */
+ export function xMax(geojson: geojson.GeoJSON): number;
+
+ /**
+ * Returns the southern-most latitude of the provided feature or geometry
+ */
+ export function yMin(geojson: geojson.GeoJSON): number;
+
+ /**
+ * Returns the northern-most latitude of the provided feature or geometry
+ */
+ export function yMax(geojson: geojson.GeoJSON): number;
+}
diff --git a/src/types/photoswipe-lightbox.d.ts b/src/types/photoswipe-lightbox.d.ts
new file mode 100644
index 0000000..d8847cf
--- /dev/null
+++ b/src/types/photoswipe-lightbox.d.ts
@@ -0,0 +1,104 @@
+declare module 'photoswipe/lightbox' {
+ import PhotoSwipeBase from 'photoswipe/dist/types/core/base';
+
+ export default PhotoSwipeLightbox;
+ /**
+ * <T>
+ */
+ export type Type<T> = import('photoswipe/dist/types/types').Type<T>;
+ export type PhotoSwipe = import('photoswipe/dist/types/photoswipe').default;
+ export type PhotoSwipeOptions = import('photoswipe/dist/types/photoswipe').PhotoSwipeOptions;
+ export type DataSource = import('photoswipe/dist/types/photoswipe').DataSource;
+ export type Content = import('photoswipe/dist/types/slide/content').default;
+ export type PhotoSwipeEventsMap = import('photoswipe/dist/types/core/eventable').PhotoSwipeEventsMap;
+ export type PhotoSwipeFiltersMap = import('photoswipe/dist/types/core/eventable').PhotoSwipeFiltersMap;
+ /**
+ * <T>
+ */
+ export type EventCallback<T> = import('photoswipe/dist/types/core/eventable').EventCallback<T>;
+ /**
+ * @template T
+ * @typedef {import('../types.js').Type<T>} Type<T>
+ */
+ /** @typedef {import('photoswipe/dist/types/photoswipe').default} PhotoSwipe */
+ /** @typedef {import('photoswipe/dist/types/photoswipe').PhotoSwipeOptions} PhotoSwipeOptions */
+ /** @typedef {import('photoswipe/dist/types/photoswipe').DataSource} DataSource */
+ /** @typedef {import('photoswipe/dist/types/slide/content').default} Content */
+ /** @typedef {import('photoswipe/dist/types/core/eventable').PhotoSwipeEventsMap} PhotoSwipeEventsMap */
+ /** @typedef {import('photoswipe/dist/types/core/eventable').PhotoSwipeFiltersMap} PhotoSwipeFiltersMap */
+ /**
+ * @template T
+ * @typedef {import('photoswipe/dist/types/core/eventable').EventCallback<T>} EventCallback<T>
+ */
+ /**
+ * PhotoSwipe Lightbox
+ *
+ * - If user has unsupported browser it falls back to default browser action (just opens URL)
+ * - Binds click event to links that should open PhotoSwipe
+ * - parses DOM strcture for PhotoSwipe (retrieves large image URLs and sizes)
+ * - Initializes PhotoSwipe
+ *
+ *
+ * Loader options use the same object as PhotoSwipe, and supports such options:
+ *
+ * gallery - Element | Element[] | NodeList | string selector for the gallery element
+ * children - Element | Element[] | NodeList | string selector for the gallery children
+ *
+ */
+ declare class PhotoSwipeLightbox extends PhotoSwipeBase {
+ /**
+ * @param {PhotoSwipeOptions} options
+ */
+ constructor(options: PhotoSwipeOptions);
+ _uid: number;
+ /**
+ * Initialize lightbox, should be called only once.
+ * It's not included in the main constructor, so you may bind events before it.
+ */
+ init(): void;
+ /**
+ * @param {MouseEvent} e
+ */
+ onThumbnailsClick(e: MouseEvent): void;
+ /**
+ * Get index of gallery item that was clicked.
+ *
+ * @param {MouseEvent} e click event
+ */
+ getClickedIndex(e: MouseEvent): number;
+ /**
+ * Load and open PhotoSwipe
+ *
+ * @param {number} index
+ * @param {DataSource=} dataSource
+ * @param {{ x?: number; y?: number }} [initialPoint]
+ */
+ loadAndOpen(
+ index: number,
+ dataSource?: DataSource | undefined,
+ initialPoint?: {
+ x?: number;
+ y?: number;
+ },
+ ): boolean;
+ shouldOpen: boolean;
+ /**
+ * Load the main module and the slide content by index
+ *
+ * @param {number} index
+ * @param {DataSource=} dataSource
+ */
+ preload(index: number, dataSource?: DataSource | undefined): void;
+ _preloadedContent: import('photoswipe/dist/types/slide/content').default;
+ /**
+ * @private
+ * @param {Type<PhotoSwipe> | { default: Type<PhotoSwipe> }} module
+ * @param {number} uid
+ */
+ private readonly _openPhotoswipe;
+ /**
+ * Unbinds all events, closes PhotoSwipe if it's open.
+ */
+ destroy(): void;
+ }
+}
diff --git a/src/types/types.d.ts b/src/types/types.d.ts
new file mode 100644
index 0000000..ebf6fd5
--- /dev/null
+++ b/src/types/types.d.ts
@@ -0,0 +1,14 @@
+declare global {
+ interface Document {
+ webkitExitFullscreen?: () => Promise<void>;
+ webkitFullscreenElement?: Element;
+ }
+
+ interface HTMLElement {
+ webkitRequestFullscreen?: () => Promise<void>;
+ }
+}
+
+declare module 'exif-reader';
+declare module 'photoswipe-dynamic-caption-plugin';
+declare module 'photoswipe-video-plugin';