initial commit

This commit is contained in:
Christian Beutel
2024-01-27 20:39:12 +01:00
parent 7ab2f1c7e6
commit b302ccded0
85 changed files with 5991 additions and 0 deletions

10
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

38
web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

3737
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "wanderer",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/leaflet": "^1.9.8",
"@types/leaflet-gpx": "^1.3.7",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3",
"vitest": "^1.2.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1",
"@types/leaflet.awesome-markers": "^2.0.28",
"leaflet": "^1.9.4",
"leaflet-gpx": "^1.7.0",
"leaflet.awesome-markers": "^2.0.5",
"photoswipe": "^5.4.3",
"pocketbase": "^0.21.0"
}
}

12
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

102
web/src/app.css Normal file
View File

@@ -0,0 +1,102 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Thin.ttf") format("truetype");
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-ThinItalic.ttf") format("truetype");
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-ExtraLight.ttf") format("truetype");
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-ExtraLight.ttf") format("truetype");
font-weight: 200;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Light.ttf") format("truetype");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-LightItalic.ttf") format("truetype");
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Italic.ttf") format("truetype");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-MediumItalic.ttf") format("truetype");
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-SemiBold.ttf") format("truetype");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-SemiBoldItalic.ttf") format("truetype");
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "IBMPlexSans";
src: url("/fonts/IBMPlexSans/IBMPlexSans-BoldItalic.ttf") format("truetype");
font-weight: 700;
font-style: italic;
}

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

23
web/src/components.css Normal file
View File

@@ -0,0 +1,23 @@
@import 'tailwindcss/utilities';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
.btn-primary {
@apply text-white rounded-lg px-4 py-3 bg-primary font-semibold
}
.tab {
@apply p-4 rounded-t-lg
}
.tab:hover {
@apply bg-gray-100
}
.tab-active {
@apply bg-primary text-white
}
.tab-active:hover {
@apply bg-primary
}

7
web/src/index.test.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -0,0 +1 @@
<menu class="rounded-full border w-12 h-12"></menu>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { Category } from "$lib/models/category";
export let category: Category;
</script>
<div class="category-card relative rounded-2xl shadow-md w-48 h-48 overflow-hidden cursor-pointer">
<img class="w-full h-full" src={category.img} alt="" />
<div class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"></div>
<h5 class="absolute text-white font-bold bottom-4 left-4 text-xl">{category.name}</h5>
</div>
<style>
.category-card img {
object-fit: cover;
transition: 0.25s ease;
}
.category-card:hover img {
scale: 1.075;
}
</style>

View File

@@ -0,0 +1,34 @@
<script>
import LogoTextLight from "./logo_text_light.svelte";
</script>
<footer class="bg-primary text-white w-full grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-y-8 p-12 mt-12 rounded-t-3xl">
<LogoTextLight></LogoTextLight>
<div>
<h5 class="font-semibold">Resources</h5>
<ul></ul>
</div>
<div>
<h5 class="font-semibold">wanderer</h5>
<ul class="mt-4">
<li>About</li>
<li>Features</li>
<li>Changelog</li>
<li>License</li>
</ul>
</div>
<div>
<h5 class="font-semibold">Community</h5>
<ul class="mt-4">
<li>GitHub</li>
<li>Issues</li>
<li>Contribute</li>
</ul>
</div>
</footer>
<style>
footer li:not(:last-child) {
margin-bottom: 4px;
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,20 @@
<script>
import Avatar from "./avatar.svelte";
import LogoText from "./logo_text.svelte";
</script>
<nav class="flex justify-between items-center p-6">
<a href="/"><LogoText></LogoText></a>
<menu class="flex gap-8">
<a class="font-semibold" href="">Trails</a>
<a class="font-semibold" href="">Map</a>
<a class="font-semibold" href="">Categories</a>
<a class="font-semibold" href="">Favorites</a>
</menu>
<div class="flex gap-6 items-center">
<button class="btn-primary"
><i class="fa fa-plus mr-2"></i>New Trail</button
>
<Avatar></Avatar>
</div>
</nav>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { SummitLog } from "$lib/models/summit_log";
import { formatISODate } from "$lib/util/format_util";
export let log: SummitLog;
</script>
<div class="p-4 my-2 border rounded-xl">
<h5 class="font-medium mr-2">{formatISODate(log.date)}</h5>
<span>{log.text}</span>
</div>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
export let tabs: string[];
export let activeTab: number;
</script>
<div class="flex gap-2 overflow-x-scroll">
{#each tabs as tab, i}
<button class="tab" class:tab-active={activeTab == i} on:click={() => activeTab = i}>{tab}</button>
{/each}
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
export let trail: Trail;
</script>
<div
class="trail-card rounded-2xl shadow-md w-72 overflow-hidden cursor-pointer"
>
<img class="w-full h-48" src={trail.img} alt="" />
<div class="p-4">
<div>
<h4 class="font-semibold text-lg">{trail.name}</h4>
<h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>
</div>
<div class="flex mt-2 gap-4">
<span><i class="fa fa-left-right mr-2"></i>{trail.distance}m</span>
<span><i class="fa fa-up-down mr-2"></i>{trail.height}m</span>
</div>
</div>
</div>
<style>
.trail-card img {
object-fit: cover;
transition: 0.25s ease;
}
.trail-card:hover img {
scale: 1.075;
}
</style>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { Waypoint } from "$lib/models/waypoint";
export let waypoint: Waypoint;
</script>
<div class="p-4 border rounded-md my-2 cursor-pointer hover:bg-gray-100">
<h5 class="mb-2"><i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}</h5>
<p>{waypoint.description}</p>
<span class="text-sm text-gray-500">{waypoint.lat}, {waypoint.lon}</span>
</div>

6
web/src/lib/constants.ts Normal file
View File

@@ -0,0 +1,6 @@
import PocketBase from 'pocketbase';
import { env } from "$env/dynamic/public";
const pb = new PocketBase(env.PUBLIC_DB_HOST);
export { pb }

View File

@@ -0,0 +1,7 @@
interface Category {
id: string;
name: string;
img: string;
}
export type {Category}

View File

@@ -0,0 +1,7 @@
interface SummitLog {
id: string;
date: string;
text: string;
}
export type {SummitLog}

View File

@@ -0,0 +1,24 @@
import type { Category } from "./category";
import type { SummitLog } from "./summit_log";
import type { Waypoint } from "./waypoint";
interface Trail {
id: string;
name: string;
location: string;
distance: number;
elevation_gain: number;
duration: number;
thumbnail: string;
photos: string[];
gpx: string;
expand: {
category: Category;
waypoints: Waypoint[]
summit_logs: SummitLog[]
}
tags?: string[];
description: string;
}
export type { Trail }

View File

@@ -0,0 +1,10 @@
interface Waypoint {
id: string;
name: string;
description: string;
lat: number;
lon: number;
icon: string;
}
export type { Waypoint }

View File

@@ -0,0 +1,35 @@
import { pb } from "$lib/constants";
import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util";
import { writable, type Writable } from "svelte/store";
export const trails: Writable<Trail[]> = writable()
export const trail: Writable<Trail> = writable();
export async function trails_index() {
const response: Trail[] = (await pb.collection('trails').getList<Trail>(1, 5, { expand: "category,waypoints,summit_logs" })).items
trails.set(response);
}
export async function trails_show(id: string, loadGPX?: boolean) {
const response: Trail = await pb.collection('trails').getOne<Trail>(id, { expand: "category,waypoints,summit_logs" })
if (loadGPX) {
const gpxData: string = await fetchGPX(response);
response.gpx = gpxData;
}
trail.set(response);
}
async function fetchGPX(trail: Trail) {
if (!trail.gpx) {
return "";
}
const gpxUrl = getFileURL(trail, trail.gpx);
const response: Response = await fetch(gpxUrl);
const gpxData = await response.text();
return gpxData
}

View File

@@ -0,0 +1,6 @@
import { pb } from "$lib/constants";
import type { FileOptions } from "pocketbase";
export function getFileURL(record: { [key: string]: any; }, filename: string, options?: FileOptions) {
return pb.files.getUrl(record, filename, options);
}

View File

@@ -0,0 +1,26 @@
export function formatTimeHHMM(minutes: number) {
const m = minutes % 60;
const h = (minutes - m) / 60;
return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m";
}
export function formatMeters(meters: number) {
if (meters % 1 === 0) {
return meters >= 1000 ? `${(meters / 1000)} km` : `${meters} m`;
} else {
return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${meters.toFixed(2)} m`
}
}
export function formatISODate(isoTimestamp: string) {
const date = new Date(isoTimestamp);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Month is zero-based
const year = date.getFullYear();
return `${day}.${month}.${year}`;
}

View File

@@ -0,0 +1,11 @@
<script>
import Footer from "$lib/components/footer.svelte";
import NavBar from "$lib/components/nav_bar.svelte";
import "../app.css";
import "../components.css"
import '@fortawesome/fontawesome-free/css/all.min.css'
</script>
<NavBar></NavBar>
<slot />
<Footer></Footer>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import CategoryCard from "$lib/components/category_card.svelte";
import TrailCard from "$lib/components/trail_card.svelte";
import type { Category } from "$lib/models/category";
import type { Trail } from "$lib/models/trail";
const categories: Category[] = [
{
name: "Hiking",
img: "https://images.unsplash.com/photo-1551632811-561732d1e306?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
name: "Walking",
img: "https://images.unsplash.com/photo-1606474226448-4aa808468efc?q=80&w=3380&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
name: "Biking",
img: "https://images.unsplash.com/photo-1629626495452-37bfe96e142b?q=80&w=3387&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
name: "Climbing",
img: "https://images.unsplash.com/photo-1605548109944-9040d0972bf5?w=900&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MTF8fGNsaW1iaW5nfGVufDB8fDB8fHww",
},
{
name: "Skiing",
img: "https://images.unsplash.com/photo-1616429553002-faf23468952d?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
];
const trails: Trail[] = [
{
name: "Breitenstein",
location: "Fischbachau",
distance: 4000,
height: 1400,
img: "https://images.unsplash.com/photo-1570641963303-92ce4845ed4c?q=80&w=3387&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
category: categories[0],
description: ""
},
];
</script>
<section class="hero flex justify-center items-center" style="height: 50vh">
<div class="relative text-gray-600">
<span class="absolute inset-y-0 left-0 flex items-center pl-4">
<i class="fa fa-search text-2xl"></i>
</span>
<input
type="search"
name="q"
class="py-4 rounded-2xl pl-14 focus:outline-none text-2xl text-primary"
placeholder="Search trails..."
autocomplete="off"
/>
</div>
</section>
<section class="max-w-7xl mx-auto mt-8 px-8 xl:px-0">
<h2 class="text-5xl md:text-6xl font-bold text-primary">Your trails</h2>
<div
id="trails"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8"
>
{#each trails as trail,i}
<a href="/trail/{i}"> <TrailCard {trail}></TrailCard></a>
{/each}
</div>
</section>
<section class="max-w-7xl mx-auto mt-8 px-8 xl:px-0">
<h2 class="text-5xl md:text-6xl font-bold text-primary">By category</h2>
<div
id="categories"
class="grid grid-cols- sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8"
>
{#each categories as category}
<CategoryCard {category}></CategoryCard>
{/each}
</div>
</section>
<style>
.hero {
background-image: url("/imgs/hero.jpg");
background-position: bottom;
background-size: cover;
background-repeat: no-repeat;
}
</style>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import SummitLogCard from "$lib/components/summit_log_card.svelte";
import Tabs from "$lib/components/tabs.svelte";
import WaypointCard from "$lib/components/waypoint_card.svelte";
import { trail } from "$lib/stores/trail_store";
import { getFileURL } from "$lib/util/file_util";
import { formatMeters, formatTimeHHMM } from "$lib/util/format_util";
import type { Icon, Marker } from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css";
import type { DataSource } from "photoswipe";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import "photoswipe/style.css";
import { onMount } from "svelte";
const tabs = ["Description", "Waypoints", "Photos", "Summit book"];
let activeTab = 0;
let markers: Marker[] = [];
let lightbox: PhotoSwipeLightbox;
let lightboxDataSource: DataSource;
onMount(async () => {
const L = (await import("leaflet")).default;
await import("leaflet-gpx");
await import("leaflet.awesome-markers");
const map = L.map("map").setView([0, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
const gpxLayer = new L.GPX($trail.gpx, {
async: true,
gpx_options: {
parseElements: ["track"] as any,
},
marker_options: {
wptIcons: {
"": L.AwesomeMarkers.icon({
icon: "circle",
prefix: "fa",
markerColor: "cadetblue",
iconColor: "white",
}) as Icon,
Summit: L.AwesomeMarkers.icon({
icon: "mountain",
prefix: "fa",
markerColor: "cadetblue",
iconColor: "white",
}) as Icon,
},
startIcon: L.AwesomeMarkers.icon({
icon: "circle-half-stroke",
prefix: "fa",
markerColor: "cadetblue",
iconColor: "white",
}) as Icon,
startIconUrl: "",
endIconUrl: "",
shadowUrl: "",
},
})
.on("loaded", function (e) {
map.fitBounds(e.target.getBounds());
})
.addTo(map);
for (const waypoint of $trail.expand.waypoints) {
const fontAwesomeIcon = L.AwesomeMarkers.icon({
icon: waypoint.icon,
prefix: "fa",
markerColor: "cadetblue",
iconColor: "white",
}) as Icon;
const marker = L.marker([waypoint.lat, waypoint.lon], {
title: waypoint.name,
icon: fontAwesomeIcon,
})
.addTo(map)
.bindPopup(
"<b>" +
waypoint.name +
"</b>" +
(waypoint.description.length > 0
? "<br>" + waypoint.description
: ""),
);
markers.push(marker);
}
lightboxDataSource = $trail.photos.map((v) => ({
src: getFileURL($trail, v),
}));
lightbox = new PhotoSwipeLightbox({
dataSource: lightboxDataSource,
pswpModule: async () => await import("photoswipe"),
});
lightbox.init();
lightbox.on("beforeOpen", () => {
const pswp = lightbox.pswp;
const ds = pswp?.options?.dataSource;
if (Array.isArray(ds)) {
for (let idx = 0, len = ds.length; idx < len; idx++) {
const item = ds[idx];
const img = new Image();
img.onload = () => {
item.width = img.naturalWidth;
item.height = img.naturalHeight;
pswp?.refreshSlideContent(idx);
};
img.src = item.src as string;
}
}
});
});
function openMarkerPopup(i: number) {
markers[i].openPopup();
}
function openGallery(idx: number) {
lightbox?.loadAndOpen(idx, lightboxDataSource);
}
</script>
<div
class="trail-panel max-w-5xl mx-auto shadow-2xl rounded-3xl overflow-hidden"
>
<section class="relative h-80">
<img
class="w-full h-80"
src={getFileURL($trail, $trail.thumbnail)}
alt=""
/>
<div
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
></div>
<div class="absolute text-white bottom-8 left-8">
<h4 class="text-4xl font-bold">
{$trail.name}
</h4>
<h3 class="text-xl mt-4">
<i class="fa fa-location-dot mr-3"></i>
{$trail.location}
</h3>
</div>
</section>
<section
class="grid grid-cols-2 sm:grid-cols-4 gap-y-4 justify-around py-8"
>
<div class="flex flex-col items-center">
<span>Distance</span>
<span class="font-semibold text-lg"
>{formatMeters($trail.distance)}</span
>
</div>
<div class="flex flex-col items-center">
<span>Elevation gain</span>
<span class="font-semibold text-lg"
>{formatMeters($trail.elevation_gain)}</span
>
</div>
<div class="flex flex-col items-center">
<span>Est. duration</span>
<span class="font-semibold text-lg"
>{formatTimeHHMM($trail.duration)}</span
>
</div>
<div class="flex flex-col items-center">
<span>Category</span>
<span class="font-semibold text-lg"
>{$trail.expand.category.name}</span
>
</div>
</section>
{#if $trail.tags && $trail.tags.length > 0}
<hr />
<section class="flex p-8 gap-4 text-gray-600">
{#each $trail.tags as tag}
<span class="py-2 px-4 border rounded-full">{tag}</span>
{/each}
</section>
<hr />
{/if}
<section class="p-8">
<Tabs {tabs} bind:activeTab></Tabs>
<div class="flex flex-wrap md:flex-nowrap mt-6 gap-8">
<div class="basis-full">
{#if activeTab == 0}
<article class="text-justify whitespace-pre-line">
{$trail.description}
</article>
{/if}
{#if activeTab == 1}
<ul>
{#each $trail.expand.waypoints as waypoint, i}
<li on:mouseenter={() => openMarkerPopup(i)}>
<WaypointCard {waypoint}></WaypointCard>
</li>
{/each}
</ul>
{/if}
{#if activeTab == 2}
<div
id="photo-gallery"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
{#each $trail.photos as photo, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<img
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
on:click={() => openGallery(i)}
src={getFileURL($trail, photo)}
alt=""
/>
{/each}
</div>
{/if}
{#if activeTab == 3}
{#each $trail.expand.summit_logs as log}
<SummitLogCard {log}></SummitLogCard>
{/each}
{/if}
</div>
<div
class="rounded-xl h-72 basis-full md:basis-72 shrink-0"
id="map"
></div>
</div>
</section>
</div>
<style>
.trail-panel img {
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,7 @@
import type { Load } from "@sveltejs/kit";
import type {Trail} from "$lib/models/trail"
import { trails_show } from "$lib/stores/trail_store";
export const load: Load = async ({ params }) => {
await trails_show(params.id!, true)
};

BIN
web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
web/static/imgs/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

18
web/svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

16
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
'primary': '#242734',
},
fontFamily: {
'sans': ['IBMPlexSans', 'sans'],
},
},
},
plugins: [],
}

6
web/tests/test.ts Normal file
View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});

18
web/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
web/vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});