adds new trail page

This commit is contained in:
Christian Beutel
2024-01-29 00:41:54 +01:00
parent b302ccded0
commit 2d4c673060
28 changed files with 783 additions and 115 deletions

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let items: { text: string; value: any }[] = [];
const dispatch = createEventDispatcher();
let isOpen = false;
function toggleMenu() {
isOpen = !isOpen;
}
function closeMenu() {
isOpen = false;
}
function handleItemClick(item: { text: string, value: any }) {
dispatch("change", item);
closeMenu();
}
</script>
<svelte:window on:mouseup={() => (isOpen = false)} />
<div class="dropdown relative">
<button
class="hover:bg-gray-100 w-8 h-8 rounded-full"
on:click={toggleMenu}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{#if isOpen}
<ul
class="menu absolute bg-white border rounded-l-xl rounded-b-xl shadow-md z-10 right-0 overflow-hidden"
class:none={isOpen}
>
{#each items as item}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<li
class="menu-item p-4 cursor-pointer hover:bg-gray-100 focus:bg-gray-200 transition-colors"
tabindex="0"
on:mouseup={() => handleItemClick(item)}
on:keydown={() => handleItemClick(item)}
>
{item.text}
</li>
{/each}
</ul>
{/if}
</div>
<style>
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
export let id: string;
export let title: string;
export function openModal() {
(document.getElementById(id) as HTMLDialogElement).showModal();
}
export function closeModal() {
(document.getElementById(id) as HTMLDialogElement).close();
}
</script>
<slot {openModal} />
<dialog
{id}
tabindex="-1"
aria-hidden="true"
class="relative w-full max-w-2xl max-h-full rounded-xl"
>
<!-- Modal content -->
<div class="relative bg-white shadow dark:bg-gray-700">
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<button
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
on:click={closeModal}
>
<i class="fa fa-close"></i>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5 space-y-4">
<slot name="content" />
</div>
<!-- Modal footer -->
<div
class="p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600"
>
<slot name="footer" {closeModal} />
</div>
</div>
</dialog>
<style>
</style>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
export let value: any;
export let items: { text: string; value: any }[] = [];
export let label: string = "";
</script>
<div>
{#if label.length}
<p class="text-sm font-medium pb-1">
{label}
</p>
{/if}
<select
class="bg-gray-50 h-10 w-full px-4 border-r-8 border-transparent outline outline-1 outline-gray-200 rounded-md focus:outline-primary transition-colors"
bind:value
>
{#each items as item}
<option value={item.value}>{item.text}</option>
{/each}
</select>
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
export let value: string | number = "";
export let placeholder: string = "";
export let label: string = "";
export let error: string = "";
export let icon: string = "";
</script>
<div>
{#if label.length}
<p class="text-sm font-medium pb-1">
{label}
</p>
{/if}
<div class="flex items-center gap-2">
{#if icon.length > 0}
<i class="fa fa-{icon}"></i>
{/if}
<input
class="bg-gray-50 border rounded-md p-3 transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full"
class:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0}
type="text"
bind:value
on:change
{placeholder}
/>
</div>
<span class="textfield-error text-xs text-red-400">
{error}
</span>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
export let value: string | number = "";
export let placeholder: string = "";
export let rows: number = 3;
export let label: string = "";
export let error: string = "";
</script>
<div>
{#if label.length}
<p class="text-sm font-medium mb-1">
{label}
</p>
{/if}
<textarea
class="bg-gray-50 border rounded-md p-3 resize-none transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full"
{rows}
{placeholder}
class:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0}
bind:value
on:change
/>
<span class="textfield-error text-xs text-red-400">
{error}
</span>
</div>

View File

@@ -1,13 +1,24 @@
<script lang="ts">
import type { Category } from "$lib/models/category";
import { getFileURL } from "$lib/util/file_util";
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
class="category-card relative rounded-2xl shadow-md w-48 h-48 overflow-hidden cursor-pointer"
>
<img
class="w-full h-full"
src={getFileURL(category, 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>

View File

@@ -12,9 +12,7 @@
<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
>
<a class="btn-primary btn-large" href="/new"><i class="fa fa-plus mr-2"></i>New Trail</a>
<Avatar></Avatar>
</div>
</nav>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util";
import { formatMeters, formatTimeHHMM } from "$lib/util/format_util";
export let trail: Trail;
</script>
@@ -7,15 +9,28 @@
<div
class="trail-card rounded-2xl shadow-md w-72 overflow-hidden cursor-pointer"
>
<img class="w-full h-48" src={trail.img} alt="" />
<img class="w-full h-48" src={getFileURL(trail, trail.thumbnail)} 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 class="flex mt-2 gap-4 text-sm text-gray-500">
<span
><i class="fa fa-left-right mr-2"></i>{formatMeters(
trail.distance,
)}</span
>
<span
><i class="fa fa-up-down mr-2"></i>{formatMeters(
trail.elevation_gain,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
trail.duration,
)}</span
>
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { Waypoint } from "$lib/models/waypoint";
import Dropdown from "../base/dropdown.svelte";
export let waypoint: Waypoint;
export let mode: "show" | "edit" = "show";
const dropdownItems = [
{ text: "Edit", value: "edit" },
{ text: "Delete", value: "delete" },
];
</script>
<div class="p-4 border rounded-md my-2 hover:bg-gray-50">
<div class="flex justify-between items-center mb-2">
<h5>
<i class="fa fa-{waypoint.icon} mr-2"></i>{waypoint.name}
</h5>
{#if mode == "edit"}
<Dropdown items={dropdownItems} on:change></Dropdown>
{/if}
</div>
{#if waypoint.description}
<p>{waypoint.description}</p>
{/if}
<span class="text-sm text-gray-500">{waypoint.lat}, {waypoint.lon}</span>
</div>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { Waypoint, waypointSchema } from "$lib/models/waypoint";
import { createEventDispatcher } from "svelte";
import { createForm } from "svelte-forms-lib";
import Modal from "../base/modal.svelte";
import TextField from "../base/text_field.svelte";
import Textarea from "../base/textarea.svelte";
import { waypoint } from "$lib/stores/waypoint_store";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<Waypoint>({
initialValues: $waypoint,
validationSchema: waypointSchema,
onSubmit: async (submittedWaypoint) => {
dispatch("save", submittedWaypoint);
closeModal!();
},
});
$: form.set($waypoint);
</script>
<Modal
id="waypoint-modal"
title="Add Waypoint"
let:openModal
bind:openModal
bind:closeModal
>
<slot {openModal} />
<form
id="waypoint-form"
slot="content"
class="modal-content space-y-4"
on:submit={handleSubmit}
>
<div class="flex gap-4">
<div class="basis-full">
<TextField
label="Name"
bind:value={$form.name}
error={$errors.name}
on:change={handleChange}
></TextField>
</div>
<TextField
label="Icon"
bind:value={$form.icon}
icon={$form.icon}
error={$errors.icon}
on:change={handleChange}
></TextField>
</div>
<Textarea
label="Description"
bind:value={$form.description}
error={$errors.description}
on:change={handleChange}
></Textarea>
<div class="flex gap-4">
<TextField
label="Latitude"
bind:value={$form.lat}
error={$errors.lat}
on:change={handleChange}
></TextField>
<TextField
label="Longitude"
bind:value={$form.lon}
error={$errors.lat}
on:change={handleChange}
></TextField>
</div>
</form>
<div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal}>Cancel</button>
<button class="btn-primary" type="submit" form="waypoint-form"
>Save</button
>
</div>
</Modal>

View File

@@ -1,11 +0,0 @@
<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>

View File

@@ -2,23 +2,56 @@ import type { Category } from "./category";
import type { SummitLog } from "./summit_log";
import type { Waypoint } from "./waypoint";
interface Trail {
id: string;
class Trail {
id?: string;
name: string;
location: string;
distance: number;
elevation_gain: number;
duration: number;
thumbnail: string;
location?: string;
distance?: number;
elevation_gain?: number;
duration?: number;
thumbnail?: string;
photos: string[];
gpx: string;
gpx?: string;
expand: {
category: Category;
category?: Category;
waypoints: Waypoint[]
summit_logs: SummitLog[]
}
tags?: string[];
description: string;
description?: string;
constructor(name: string,
id?: string,
location?: string,
distance?: number,
elevation_gain?: number,
duration?: number,
thumbnail?: string,
photos?: string[],
gpx?: string,
category?: Category,
waypoints?: Waypoint[],
summit_logs?: SummitLog[],
tags?: string[],
description?: string
) {
this.id = id;
this.name = name;
this.location = location;
this.distance = distance;
this.elevation_gain = elevation_gain;
this.duration = duration;
this.thumbnail = thumbnail;
this.photos = photos ?? [];
this.gpx = gpx;
this.expand = {
category: category,
waypoints: waypoints ?? [],
summit_logs: summit_logs ?? []
}
this.tags = tags ?? []
this.description = description ?? "";
}
}
export type { Trail }
export { Trail };

View File

@@ -1,10 +1,33 @@
interface Waypoint {
id: string;
name: string;
description: string;
import type { Marker } from "leaflet";
import { date, number, object, string } from "yup";
class Waypoint {
id?: string;
name?: string;
description?: string;
lat: number;
lon: number;
icon: string;
icon?: string;
marker?: Marker;
constructor(lat: number, lon: number, params?: {id?: string, name?: string, description?: string, icon?: string, marker?: Marker}) {
this.id = params?.id;
this.name = params?.name ?? "";
this.description = params?.description ?? "";
this.lat = lat;
this.lon = lon;
this.icon = params?.icon ?? "circle";
this.marker = params?.marker;
}
}
export type { Waypoint }
const waypointSchema = object<Waypoint>({
id: string().optional(),
name: string().optional(),
description: string().optional(),
lat: number().min(0).required('Required').typeError('Invalid latitude'),
lon: number().min(0).required('Required').typeError('Invalid longitude'),
icon: string().optional()
});
export { Waypoint, waypointSchema }

View File

@@ -0,0 +1,11 @@
import { pb } from "$lib/constants";
import type { Category } from "$lib/models/category";
import { writable, type Writable } from "svelte/store";
export const categories: Writable<Category[]> = writable([])
export async function categories_index() {
const response: Category[] = (await pb.collection('categories').getFullList<Category>())
categories.set(response);
}

View File

@@ -1,10 +1,10 @@
import { pb } from "$lib/constants";
import type { Trail } from "$lib/models/trail";
import { 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 const trails: Writable<Trail[]> = writable([])
export const trail: Writable<Trail> = writable(new Trail(""));
export async function trails_index() {
const response: Trail[] = (await pb.collection('trails').getList<Trail>(1, 5, { expand: "category,waypoints,summit_logs" })).items

View File

@@ -0,0 +1,4 @@
import { Waypoint } from "$lib/models/waypoint";
import { writable, type Writable } from "svelte/store";
export const waypoint: Writable<Waypoint> = writable(new Waypoint(0, 0));

View File

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

View File

@@ -1,4 +1,9 @@
export function formatTimeHHMM(minutes: number) {
import type { Waypoint } from "$lib/models/waypoint";
export function formatTimeHHMM(minutes?: number) {
if(!minutes) {
return "-";
}
const m = minutes % 60;
const h = (minutes - m) / 60;
@@ -6,7 +11,10 @@ export function formatTimeHHMM(minutes: number) {
return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m";
}
export function formatMeters(meters: number) {
export function formatMeters(meters?: number) {
if(!meters) {
return "-";
}
if (meters % 1 === 0) {
return meters >= 1000 ? `${(meters / 1000)} km` : `${meters} m`;
} else {
@@ -15,7 +23,10 @@ export function formatMeters(meters: number) {
}
export function formatISODate(isoTimestamp: string) {
export function formatISODate(isoTimestamp?: string) {
if(!isoTimestamp) {
return "-"
}
const date = new Date(isoTimestamp);
const day = date.getDate().toString().padStart(2, '0');

View File

@@ -0,0 +1,26 @@
import type { Waypoint } from "$lib/models/waypoint";
import type { Icon, Marker } from "leaflet";
export function createMarkerFromWaypoint(L: any, waypoint: Waypoint): Marker {
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,
})
.bindPopup(
"<b>" +
waypoint.name +
"</b>" +
(waypoint.description && waypoint.description.length > 0
? "<br>" + waypoint.description
: ""),
);
return marker;
}