aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json51
-rw-r--r--.gitignore34
-rw-r--r--.prettierrc.json7
-rw-r--r--.vscode/launch.json12
-rw-r--r--.vscode/settings.json3
-rw-r--r--README.md73
-rw-r--r--components/Gallery/Gallery.module.css63
-rw-r--r--components/Gallery/Gallery.tsx80
-rw-r--r--components/Map/Map.module.css28
-rw-r--r--components/Map/Map.tsx121
-rw-r--r--components/Sidebar/Sidebar.module.css54
-rw-r--r--components/Sidebar/Sidebar.tsx71
-rw-r--r--lib/trips.ts115
-rw-r--r--lib/useEvent.ts20
-rw-r--r--lib/util.ts30
-rw-r--r--models/Photo.ts31
-rw-r--r--models/Trip.ts46
-rw-r--r--models/index.ts4
-rw-r--r--next-env.d.ts2
-rw-r--r--next.config.js34
-rw-r--r--package-lock.json9283
-rw-r--r--package.json47
-rw-r--r--pages/_app.tsx7
-rw-r--r--pages/index.tsx80
-rw-r--r--public/favicon.icobin0 -> 9662 bytes
-rw-r--r--screenshots/desktop.pngbin0 -> 190852 bytes
-rw-r--r--screenshots/mobile.pngbin0 -> 73468 bytes
-rw-r--r--styles/Home.module.css49
-rw-r--r--styles/globals.css19
-rw-r--r--trips/.gitignore2
-rw-r--r--tsconfig.json20
31 files changed, 10386 insertions, 0 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..b8d8c34
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,51 @@
+{
+ "extends": [
+ "airbnb",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:@typescript-eslint/eslint-recommended",
+ "prettier/react",
+ "plugin:prettier/recommended",
+ "prettier/@typescript-eslint",
+ "plugin:import/errors",
+ "plugin:import/warnings",
+ "plugin:import/typescript"
+ ],
+ "plugins": ["@typescript-eslint"],
+ "parser": "@typescript-eslint/parser",
+ "env": { "browser": true, "node": true, "es6": true },
+ "parserOptions": {
+ "sourceType": "module"
+ },
+ "rules": {
+ "prettier/prettier": "error",
+ "react/prop-types": "off",
+ "jsx-a11y/anchor-is-valid": "off",
+ "react/react-in-jsx-scope": "off",
+ "react/jsx-filename-extension": [
+ "error",
+ {
+ "extensions": [".js", ".jsx", ".ts", ".tsx"]
+ }
+ ],
+ "import/extensions": [
+ "error",
+ "ignorePackages",
+ {
+ "js": "never",
+ "jsx": "never",
+ "ts": "never",
+ "tsx": "never",
+ "d.ts": "never"
+ }
+ ]
+ },
+ "settings": {
+ "import/extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
+ "import/parsers": {
+ "@typescript-eslint/parser": [".ts", ".tsx", ".d.ts"]
+ },
+ "import/resolver": {
+ "typescript": {}
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1437c53
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# vercel
+.vercel
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..98b83ae
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,7 @@
+{
+ "semi": true,
+ "printWidth": 100,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "arrowParens": "always"
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..72cb4c6
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,12 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "node",
+ "request": "attach",
+ "name": "Launch Program",
+ "skipFiles": ["<node_internals>/**"],
+ "port": 9229
+ }
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..55712c1
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "typescript.tsdk": "node_modules/typescript/lib"
+} \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..053af27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,73 @@
+# Trip Share
+
+A web application for sharing trips recorded in the GPX format along with
+photos.
+
+## Preview
+
+| ![Desktop](./screenshots/desktop.png) | ![Mobile](./screenshots/mobile.png) |
+| ------------------------------------- | ----------------------------------- |
+
+You can also see a demo at https://zelent.net/trips.
+
+## Description
+
+The idea of the application came when I wanted to share my bike trips with my
+family and friends. I needed an easy way to display the route on a map and the
+photos I've taken on the trip. I record my bike trips using
+[OSMAnd](https://osmand.net/) navigation app and take photos with the location
+saved in the image metadata.
+
+The application displays the route recorded with GPS in the GPX format on an
+interactive map. The photos are displayed as markers in places they have been
+taken, and when clicked on, they are opened in a slideshow view. It is usable
+both on desktop and mobile.
+
+The application was developed using [Node.js](https://nodejs.org/),
+[TypeScript](https://www.typescriptlang.org/), and
+[Next.js](https://nextjs.org/) framework. The pages and components are built
+with [React](https://reactjs.org/). The map was created with
+[Leaflet](https://leafletjs.com/).
+
+The app is statically generated. GPX files and photos inside "trips" folder are
+processed during build time. The data is converted to HTML and JSON, photos are
+stripped of EXIF metadata, resized, and optimized. No requests are made to
+external APIs, only map tiles are being downloaded.
+
+## Running
+
+To run the application, first install the dependencies:
+
+```
+npm install
+```
+
+Then, you can run it in development mode:
+
+```
+npm run dev
+```
+
+To build the application use:
+
+```
+npm run build
+```
+
+The built files will be in "out" directory.
+
+### Trips data
+
+To display the trips, place them in the "trips" folder with the following
+structure:
+
+```
+trips/
+├── trip-1/
+│   ├── trip-1.gpx
+│   ├── photo-1.jpg
+│   ├── photo-2.jpg
+│   ├── photo-3.jpg
+├── trip-2/
+│   ├── trip-2.gpx
+```
diff --git a/components/Gallery/Gallery.module.css b/components/Gallery/Gallery.module.css
new file mode 100644
index 0000000..01e8037
--- /dev/null
+++ b/components/Gallery/Gallery.module.css
@@ -0,0 +1,63 @@
+.galleryContainer {
+ padding: 20px;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 99999;
+ background: rgba(0, 0, 0, 0.8);
+}
+
+.photoContainer {
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ position: relative;
+}
+
+.photo {
+ max-width: 100%;
+ max-height: 100%;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.btn {
+ position: absolute;
+ z-index: 99999;
+ font-size: 2rem;
+ color: #eee;
+ background: none;
+ border: none;
+ cursor: pointer;
+ outline: currentcolor none medium;
+}
+
+.btn:hover {
+ color: #fff;
+}
+
+.arrowLeft {
+ top: 50%;
+ left: 0;
+ transform: translateY(-50%);
+}
+
+.arrowRight {
+ top: 50%;
+ right: 0;
+ transform: translateY(-50%);
+}
+
+.closeBtn {
+ width: 64px;
+ height: 64px;
+ top: 0;
+ right: 0;
+ font-size: 4rem;
+ line-height: 4rem;
+ font-weight: 300;
+}
diff --git a/components/Gallery/Gallery.tsx b/components/Gallery/Gallery.tsx
new file mode 100644
index 0000000..dfadbc0
--- /dev/null
+++ b/components/Gallery/Gallery.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useRef } from 'react';
+
+import { Photo } from 'models';
+import useEvent from 'lib/useEvent';
+import styles from './Gallery.module.css';
+
+interface Props {
+ currentPhoto: Photo;
+ handleClose: () => void;
+ handlePhotoChange: (direction: boolean) => void;
+}
+
+export default function Gallery({
+ currentPhoto,
+ handleClose,
+ handlePhotoChange,
+}: Props): JSX.Element {
+ const wrapperRef = useRef(null);
+
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (
+ wrapperRef.current &&
+ !wrapperRef.current.contains(e.target) &&
+ (e.target as Node).nodeName !== 'BUTTON'
+ ) {
+ handleClose();
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [wrapperRef]);
+
+ useEvent('keydown', (e: KeyboardEvent) => {
+ if (e.key === 'ArrowLeft') {
+ handlePhotoChange(false);
+ } else if (e.key === 'ArrowRight') {
+ handlePhotoChange(true);
+ } else if (e.key === 'Escape') {
+ handleClose();
+ }
+ });
+
+ return (
+ <div className={styles.galleryContainer} role="region">
+ <button
+ type="button"
+ className={`${styles.arrowLeft} ${styles.btn}`}
+ onClick={() => handlePhotoChange(false)}
+ >
+ 〈
+ </button>
+ <div className={styles.photoContainer}>
+ <img
+ ref={wrapperRef}
+ src={currentPhoto.src}
+ alt={currentPhoto.name}
+ className={styles.photo}
+ />
+ </div>
+ <button
+ type="button"
+ className={`${styles.arrowRight} ${styles.btn}`}
+ onClick={() => handlePhotoChange(true)}
+ >
+ 〉
+ </button>
+ <button
+ type="button"
+ className={`${styles.closeBtn} ${styles.btn}`}
+ onClick={() => handleClose()}
+ >
+ ×
+ </button>
+ </div>
+ );
+}
diff --git a/components/Map/Map.module.css b/components/Map/Map.module.css
new file mode 100644
index 0000000..b13f5d2
--- /dev/null
+++ b/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/components/Map/Map.tsx b/components/Map/Map.tsx
new file mode 100644
index 0000000..44f8ea6
--- /dev/null
+++ b/components/Map/Map.tsx
@@ -0,0 +1,121 @@
+import { LatLngBounds, DivIcon, Icon } from 'leaflet';
+import { useEffect, useState } from 'react';
+import ReactDom from 'react-dom/server';
+import { MapContainer, Marker, TileLayer, GeoJSON, ZoomControl } from 'react-leaflet';
+import MarkerClusterGroup from 'react-leaflet-markercluster';
+
+import { Trip, Photo } from 'models';
+import { distanceBetween } from 'lib/util';
+
+import 'leaflet/dist/leaflet.css';
+import styles from './Map.module.css';
+
+interface Props {
+ trip: Trip;
+ handleMarkerClick: (photo: string) => void;
+}
+
+export default function Map({ trip, handleMarkerClick }: Props): JSX.Element {
+ const [map, setMap] = useState(null);
+
+ const bounds = trip
+ ? new LatLngBounds(
+ [trip.track.bbox[1], trip.track.bbox[0]],
+ [trip.track.bbox[3], trip.track.bbox[2]],
+ )
+ : new LatLngBounds([75, -145], [-52, 145]);
+
+ function createMarkers(photos: Photo[]): JSX.Element[] {
+ // cluster photos that are close to each other
+ const clusters: Photo[][] = [];
+ for (let i = 0; i < photos.length; i += 1) {
+ if (clusters.filter((c) => c.includes(photos[i])).length === 0) {
+ const cluster = [photos[i]];
+ for (let j = 0; j < photos.length; j += 1) {
+ if (photos[i] !== photos[j]) {
+ const a = [photos[i].latitude, photos[i].longitude];
+ const b = [photos[j].latitude, photos[j].longitude];
+ const distance = distanceBetween(a, b);
+ if (distance < 10) cluster.push(photos[j]);
+ }
+ }
+ clusters.push(cluster);
+ }
+ }
+
+ // create React elements based on the clusters
+ const markers = clusters.map((cluster) => {
+ let Wrapper = ({ children }) => <>{children}</>;
+ if (cluster.length > 1) {
+ Wrapper = ({ children }) => (
+ <MarkerClusterGroup
+ iconCreateFunction={(markerCluster) =>
+ new DivIcon({
+ html: ReactDom.renderToString(
+ <>
+ <img
+ style={{ width: 36, height: 36 }}
+ src={`data:image/jpg;base64,${cluster[0].thumbnail}`}
+ alt=""
+ />
+ <span className={styles.markerItemCount}>{markerCluster.getChildCount()}</span>
+ </>,
+ ),
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ })
+ }
+ >
+ {children}
+ </MarkerClusterGroup>
+ );
+ }
+
+ const children = cluster.map((photo) => (
+ <Marker
+ key={photo.name}
+ position={[photo.latitude, photo.longitude]}
+ icon={
+ new Icon({
+ iconUrl: `data:image/jpg;base64,${photo.thumbnail}`,
+ iconSize: [36, 36],
+ className: styles.markerIcon,
+ })
+ }
+ eventHandlers={{ click: () => handleMarkerClick(photo.name) }}
+ />
+ ));
+
+ return <Wrapper key={cluster[0].name}>{children}</Wrapper>;
+ });
+
+ return markers;
+ }
+
+ useEffect(() => {
+ if (map) {
+ map.setView(bounds.getCenter());
+ map.fitBounds(bounds);
+ }
+ }, [trip]);
+
+ return (
+ <MapContainer
+ center={bounds.getCenter()}
+ bounds={bounds}
+ scrollWheelZoom
+ whenCreated={setMap}
+ style={{ height: '100%', width: '100%' }}
+ keyboard
+ zoomControl={false}
+ >
+ <ZoomControl position="bottomright" />
+ <TileLayer
+ attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
+ />
+ {trip && <GeoJSON key={trip.name} data={trip.track} />}
+ {trip && createMarkers(trip.photos)}
+ </MapContainer>
+ );
+}
diff --git a/components/Sidebar/Sidebar.module.css b/components/Sidebar/Sidebar.module.css
new file mode 100644
index 0000000..a3c8a1a
--- /dev/null
+++ b/components/Sidebar/Sidebar.module.css
@@ -0,0 +1,54 @@
+.aside {
+ min-width: 272px;
+ 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;
+}
+
+.aside h2 {
+ padding-left: 1.5rem;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-y: auto;
+}
+
+.listItem {
+ margin-bottom: 5px;
+ padding: 1rem 2rem 1rem 1.5rem;
+ display: block;
+ border-radius: 10px;
+ cursor: pointer;
+ user-select: none;
+ outline: currentcolor none medium;
+}
+
+.listItem:hover {
+ background-color: #f5f5f5;
+}
+
+.listItemActive {
+ background-color: #e9f3ff !important;
+}
+
+@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/components/Sidebar/Sidebar.tsx b/components/Sidebar/Sidebar.tsx
new file mode 100644
index 0000000..6b0473a
--- /dev/null
+++ b/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useRef } from 'react';
+
+import { Trip } from 'models';
+import { secondsToTimeString } from 'lib/util';
+
+import styles from './Sidebar.module.css';
+
+interface Props {
+ trips: Trip[];
+ currentTrip: Trip;
+ asideOpen: boolean;
+ handleClose: () => void;
+ setCurrentTrip: (trip: Trip) => void;
+}
+
+export default function Sidebar({
+ trips,
+ currentTrip,
+ asideOpen,
+ handleClose,
+ setCurrentTrip,
+}: Props): JSX.Element {
+ const wrapperRef = useRef(null);
+
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
+ handleClose();
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [wrapperRef]);
+
+ function handleTripChange(trip: Trip): void {
+ setCurrentTrip(trip);
+ handleClose();
+ }
+
+ return (
+ <aside ref={wrapperRef} className={`${styles.aside} ${asideOpen && styles.asideOpen}`}>
+ <h2>Trips</h2>
+ <ul className={styles.list}>
+ {trips.map((t) => (
+ <li key={t.name}>
+ <a
+ onClick={() => handleTripChange(t)}
+ onKeyPress={() => handleTripChange(t)}
+ tabIndex={0}
+ role="menuitem"
+ className={`${styles.listItem} ${
+ t.name === currentTrip.name && styles.listItemActive
+ }`}
+ >
+ <b>{new Date(t.start).toDateString()}</b>
+ <br />
+ Total distance: {t.distance} km
+ <br />
+ Duration: {secondsToTimeString(t.duration)}
+ <br />
+ Average speed: {t.speed} km/h
+ </a>
+ </li>
+ ))}
+ </ul>
+ </aside>
+ );
+}
diff --git a/lib/trips.ts b/lib/trips.ts
new file mode 100644
index 0000000..6a58d34
--- /dev/null
+++ b/lib/trips.ts
@@ -0,0 +1,115 @@
+import path from 'path';
+import fs from 'fs';
+import { DOMParser } from 'xmldom';
+import { FeatureCollection, LineString } from 'geojson';
+import { gpx } from '@tmcw/togeojson';
+import gb from 'geojson-bounds';
+import * as ExifReader from 'exifreader';
+
+import { Trip, Photo } from 'models';
+import { distanceBetween } from 'lib/util';
+
+const tripsDirectory = path.join(process.cwd(), 'trips');
+
+// extract photo metadata from EXIF
+function getPhotoMetadata(filePath: string) {
+ const buffer = fs.readFileSync(filePath);
+ const tags = ExifReader.load(buffer, { expanded: true });
+
+ return {
+ latitude: tags.gps.Latitude,
+ longitude: tags.gps.Longitude,
+ time: tags.exif.DateTime.description.replace(':', '-').replace(':', '-'),
+ thumbnail: tags.Thumbnail.base64,
+ };
+}
+
+/**
+ * Reads GPX files and photos from trips folder.
+ */
+export default function getTripsData(): Trip[] {
+ // get folder names under /trips
+ const dirs = fs
+ .readdirSync(tripsDirectory, { withFileTypes: true })
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name);
+ const allTripsData = dirs.map((dir: string) => {
+ const dirPath = path.join(tripsDirectory, dir);
+ const files = fs.readdirSync(dirPath);
+
+ const gpxFiles = files.filter((f) => f.endsWith('.gpx'));
+
+ // read GPX file as string
+ const fullPath = path.join(dirPath, gpxFiles[0]);
+ const fileContents = fs.readFileSync(fullPath, 'utf8');
+ const contentsWithoutNS = fileContents.replace(/\sxmlns[^"]+"[^"]+"/g, '');
+
+ // create DOM from string
+ const doc = new DOMParser().parseFromString(contentsWithoutNS);
+
+ // 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)];
+
+ const { coordTimes } = track.features[0].properties;
+
+ // 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;
+
+ // distance and speed
+ let totalDistance = 0;
+ const speeds = [];
+ const coords = (track.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;
+
+ // photos
+ const photoFiles = files.filter((f) => f.endsWith('.jpg'));
+ const photos: Photo[] = photoFiles.map((p) => {
+ // eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires
+ const { src } = require(`trips/${dir}/${p}`);
+ return {
+ name: p,
+ src,
+ ...getPhotoMetadata(path.join(dirPath, p)),
+ };
+ });
+
+ const trip: Trip = { name: dir, track, distance, start, end, duration, speed, photos };
+
+ return trip;
+ });
+
+ // sort trips by name
+ return allTripsData.sort((a, b) => {
+ if (a.name < b.name) {
+ return 1;
+ }
+ return -1;
+ });
+}
diff --git a/lib/useEvent.ts b/lib/useEvent.ts
new file mode 100644
index 0000000..70d51b1
--- /dev/null
+++ b/lib/useEvent.ts
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+
+/**
+ * A hook for creating event handlers.
+ */
+export default function useEvent(
+ event: string,
+ handler: (e: KeyboardEvent | MouseEvent) => void,
+ passive = false,
+): void {
+ useEffect(() => {
+ // initiate the event handler
+ window.addEventListener(event, handler, passive);
+
+ // this will clean up the event every time the component is re-rendered
+ return function cleanup() {
+ window.removeEventListener(event, handler);
+ };
+ });
+}
diff --git a/lib/util.ts b/lib/util.ts
new file mode 100644
index 0000000..5a00aed
--- /dev/null
+++ b/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/models/Photo.ts b/models/Photo.ts
new file mode 100644
index 0000000..f40d492
--- /dev/null
+++ b/models/Photo.ts
@@ -0,0 +1,31 @@
+export default interface Photo {
+ /**
+ * Name of the photo or the file.
+ */
+ name: string;
+
+ /**
+ * Path to the photo.
+ */
+ src: string;
+
+ /**
+ * Latitude where the photo was taken.
+ */
+ latitude: number;
+
+ /**
+ * Logitude where the photo was taken.
+ */
+ longitude: number;
+
+ /**
+ * Date and time when the photo was taken.
+ */
+ time: string;
+
+ /**
+ * Photo thumnnail.
+ */
+ thumbnail: string;
+}
diff --git a/models/Trip.ts b/models/Trip.ts
new file mode 100644
index 0000000..cc01cda
--- /dev/null
+++ b/models/Trip.ts
@@ -0,0 +1,46 @@
+import { GeoJsonObject } from 'geojson';
+import Photo from './Photo';
+
+export default interface Trip {
+ /**
+ * Name of the trip.
+ */
+ name: string;
+
+ /**
+ * Total distance made during the trip.
+ */
+ distance: number;
+
+ /**
+ * Date and time of the beginning of the trip.
+ * This property is a string because Date is not serializable in Next.js.
+ */
+ start: string;
+
+ /**
+ * Date and time of the end of the trip.
+ * This property is a string because Date is not serializable in Next.js.
+ */
+ end: string;
+
+ /**
+ * Total duration of the trip in seconds.
+ */
+ duration: number;
+
+ /**
+ * Average speed in km/h.
+ */
+ speed: number;
+
+ /**
+ * GeoJSON object representing waypoints of the trip
+ */
+ track: GeoJsonObject;
+
+ /**
+ * Photos taken during the trip.
+ */
+ photos: Photo[];
+}
diff --git a/models/index.ts b/models/index.ts
new file mode 100644
index 0000000..764b342
--- /dev/null
+++ b/models/index.ts
@@ -0,0 +1,4 @@
+import Trip from './Trip';
+import Photo from './Photo';
+
+export type { Trip, Photo };
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..7b7aa2c
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,2 @@
+/// <reference types="next" />
+/// <reference types="next/types/global" />
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..edadc14
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,34 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const withPlugins = require('next-compose-plugins');
+const optimizedImages = require('next-optimized-images');
+// eslint-disable-next-line import/no-extraneous-dependencies
+const sharp = require('responsive-loader/sharp');
+
+module.exports = withPlugins(
+ [
+ [
+ optimizedImages,
+ {
+ defaultImageLoader: 'responsive-loader',
+ handleImages: ['jpeg', 'png', 'svg', 'webp', 'gif'],
+ optimizeImages: true,
+ optimizeImagesInDev: true,
+ responsive: {
+ adapter: sharp,
+ quality: 75,
+ size: 1920,
+ },
+ },
+ ],
+ ],
+ {
+ webpack: (config) => {
+ config.module.rules.push({
+ test: /\.gpx/,
+ use: 'raw-loader',
+ });
+
+ return config;
+ },
+ },
+);