updates trail panel view

This commit is contained in:
Christian Beutel
2025-01-27 19:38:38 +01:00
parent 5922034183
commit e837b6e04b
25 changed files with 427 additions and 182 deletions

View File

@@ -1,8 +1,6 @@
# v0.14.0 # v0.14.0
> [!IMPORTANT] > [!IMPORTANT]
> This release introduces significant updates, including the migration of the frontend to [Svelte 5](https://svelte.dev/blog/svelte-5-is-alive). While the migration has been rigorously tested, there is a possibility that some features may not function as expected. We encourage you to report any issues you encounter. > This release introduces significant updates, including the migration of the frontend to [Svelte 5](https://svelte.dev/blog/svelte-5-is-alive). While the migration has been rigorously tested, there is a possibility that some features may not function as expected. We encourage you to report any issues you encounter. Additionally, the location search functionality has been transitioned from a locally hosted meilisearch index to nominatim. This upgrade offers substantially improved location search capabilities within wanderer. As a result, the custom meilisearch docker image (`flomp/wanderer-search`) is now deprecated. You can safely replace it with the official meilisearch image (`getmeili/meilisearch:v1.11.3`) in your `docker-compose.yml`.
> Additionally, the location search functionality has been transitioned from a locally hosted meilisearch index to nominatim. This upgrade offers substantially improved location search capabilities within wanderer. As a result, the custom meilisearch docker image (`flomp/wanderer-search`) is now deprecated. You can safely replace it with the official meilisearch image (`getmeili/meilisearch:v1.11.3`) in your `docker-compose.yml`.
## Maintenance ## Maintenance
- Migrates to Svelte 5 - Migrates to Svelte 5

View File

@@ -0,0 +1,55 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// add
new_distance_from_start := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "s1prb3fx",
"name": "distance_from_start",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 0,
"max": null,
"noDecimal": false
}
}`), new_distance_from_start); err != nil {
return err
}
collection.Schema.AddField(new_distance_from_start)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("s1prb3fx")
return dao.SaveCollection(collection)
})
}

View File

@@ -27,15 +27,19 @@
} }
.tab { .tab {
@apply p-4 rounded-t-lg border border-transparent @apply mr-4 py-2 border-b-2 border-transparent rounded-t-lg
} }
.tab:hover { .tab:hover {
@apply border-menu-item-background-hover @apply border-menu-item-background-focus;
}
.tab-active {
@apply font-semibold
} }
.tab-active:hover { .tab-active:hover {
@apply bg-transparent @apply border-transparent;
} }
.tooltip { .tooltip {

View File

@@ -40,7 +40,7 @@
<div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}"> <div id="tabs" class="flex gap-2 overflow-x-auto relative {extraClasses}">
<div <div
class="absolute h-full bg-menu-item-background-hover rounded-t-lg top-0 z-0" class="tab-indicator absolute h-full rounded-t-lg top-0 z-0 border-b-2 border-content"
style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px;" style="width: {indicatorWidth.current}px; left: {indicatorPosition.current}px;"
></div> ></div>
{#each tabs as tab, i} {#each tabs as tab, i}

View File

@@ -940,12 +940,12 @@
} }
:global(.maplibregl-popup-content) { :global(.maplibregl-popup-content) {
@apply bg-background rounded-md shadow-xl pt-5; @apply bg-background rounded-md shadow-xl p-0 overflow-hidden pr-5;
} }
:global(.maplibregl-popup-close-button) { :global(.maplibregl-popup-close-button) {
top: 4px; top: 4px;
right: 8px; right: 4px;
line-height: 0; line-height: 0;
padding-bottom: 2.5px; padding-bottom: 2.5px;
@apply bg-menu-item-background-focus w-3 aspect-square rounded-full; @apply bg-menu-item-background-focus w-3 aspect-square rounded-full;

View File

@@ -43,6 +43,7 @@
import EmptyStateWaypoint from "../empty_states/empty_state_waypoint.svelte"; import EmptyStateWaypoint from "../empty_states/empty_state_waypoint.svelte";
import EmptyStateDescription from "../empty_states/empty_state_description.svelte"; import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import TrailTimeline from "./trail_timeline.svelte";
interface Props { interface Props {
trail: Trail; trail: Trail;
@@ -54,10 +55,8 @@
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props(); let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props();
const tabs = [ const tabs = [
$_("description"),
$_("waypoints", { values: { n: 2 } }),
$_("photos"),
$_("summit-book"), $_("summit-book"),
$_("photos"),
...(pb.authStore.model ? [$_("comment", { values: { n: 2 } })] : []), ...(pb.authStore.model ? [$_("comment", { values: { n: 2 } })] : []),
]; ];
@@ -77,6 +76,8 @@
let commentCreateLoading: boolean = $state(false); let commentCreateLoading: boolean = $state(false);
let commentDeleteLoading: boolean = false; let commentDeleteLoading: boolean = false;
let fullDescription: boolean = $state(false);
onMount(async () => {}); onMount(async () => {});
function openMarkerPopup(i: number) { function openMarkerPopup(i: number) {
@@ -160,7 +161,7 @@
<div <div
class="trail-info-panel mx-auto {mode == 'list' class="trail-info-panel mx-auto {mode == 'list'
? '' ? ''
: 'border border-input-border rounded-3xl'} h-full" : 'border border-input-border rounded-3xl'} h-full overflow-y-scroll"
style="max-width: min(100%, 76rem);" style="max-width: min(100%, 76rem);"
> >
<div class="trail-info-panel-header"> <div class="trail-info-panel-header">
@@ -176,7 +177,7 @@
></div> ></div>
{#if (trail.public || trailIsShared) && pb.authStore.model} {#if (trail.public || trailIsShared) && pb.authStore.model}
<div <div
class="flex absolute top-8 right-6 {trail.public && class="flex absolute top-8 left-8 {trail.public &&
trailIsShared trailIsShared
? 'w-16' ? 'w-16'
: 'w-8'} h-8 rounded-full items-center justify-center bg-white text-primary" : 'w-8'} h-8 rounded-full items-center justify-center bg-white text-primary"
@@ -256,44 +257,72 @@
{/if} {/if}
</div> </div>
</section> </section>
{#if mode == "overview"} <section
<section class="grid grid-cols-2 sm:grid-cols-5 gap-y-4 py-4 border-b border-input-border px-3"
class="grid grid-cols-2 sm:grid-cols-5 gap-y-4 justify-around py-8 border-b border-input-border" >
> <div class="flex flex-col items-center">
<span class="font-medium"
>{#if mode == "overview"}
{$_("distance")}
{:else}
<i class="fa fa-left-right"></i>
{/if}</span
>
<span class=""
>{formatDistance(trail.distance)}</span
>
</div>
<div class="flex flex-col items-center">
<span class="font-medium"
>{#if mode == "overview"}
{$_("est-duration")}
{:else}
<i class="fa fa-clock"></i>
{/if}</span
>
<span class=""
>{formatTimeHHMM(trail.duration)}</span
>
</div>
<div class="flex flex-col items-center">
<span class="font-medium"
>{#if mode == "overview"}
{$_("elevation-gain")}
{:else}
<i class="fa fa-arrow-trend-up"></i>
{/if}</span
>
<span class=""
>{formatElevation(trail.elevation_gain)}</span
>
</div>
<div class="flex flex-col items-center">
<span class="font-medium"
>{#if mode == "overview"}
{$_("elevation-loss")}
{:else}
<i class="fa fa-arrow-trend-down"></i>
{/if}</span
>
<span class=""
>{formatElevation(trail.elevation_loss)}</span
>
</div>
{#if trail.expand?.category}
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span>{$_("distance")}</span> <span class="font-medium"
<span class="font-semibold text-lg" >{#if mode == "overview"}
>{formatDistance(trail.distance)}</span {$_("category")}
{:else}
<i class="fa fa-route"></i>
{/if}</span
>
<span class=""
>{$_(trail.expand.category.name)}</span
> >
</div> </div>
<div class="flex flex-col items-center"> {/if}
<span>{$_("est-duration")}</span> </section>
<span class="font-semibold text-lg"
>{formatTimeHHMM(trail.duration)}</span
>
</div>
<div class="flex flex-col items-center">
<span>{$_("elevation-gain")}</span>
<span class="font-semibold text-lg"
>{formatElevation(trail.elevation_gain)}</span
>
</div>
<div class="flex flex-col items-center">
<span>{$_("elevation-loss")}</span>
<span class="font-semibold text-lg"
>{formatElevation(trail.elevation_loss)}</span
>
</div>
{#if trail.expand?.category}
<div class="flex flex-col items-center">
<span>{$_("category")}</span>
<span class="font-semibold text-lg"
>{$_(trail.expand.category.name)}</span
>
</div>
{/if}
</section>
{/if}
{#if trail.tags && trail.tags.length > 0} {#if trail.tags && trail.tags.length > 0}
<hr class="border-separator" /> <hr class="border-separator" />
<section class="flex p-8 gap-4 text-gray-600"> <section class="flex p-8 gap-4 text-gray-600">
@@ -304,138 +333,151 @@
<hr class="border-separator" /> <hr class="border-separator" />
{/if} {/if}
</div> </div>
<section class="trail-info-panel-tabs px-4 py-2 bg-background sticky top-0">
<Tabs {tabs} bind:activeTab></Tabs>
</section>
<section class="trail-info-panel-content px-8"> <section class="trail-info-panel-content px-8">
<div <div
class="grid grid-cols-1 my-4 gap-8" class="grid grid-cols-1 my-4 gap-8"
class:md:grid-cols-[1fr_18rem]={mode == "overview"} class:md:grid-cols-[1fr_18rem]={mode == "overview"}
> >
{#if activeTab == 0} <div>
{#if trail.description?.length} {#if trail.description?.length}
<h4 class="text-2xl font-semibold my-4">
{$_("description")}
</h4>
<article class="text-justify whitespace-pre-line text-sm"> <article class="text-justify whitespace-pre-line text-sm">
{trail.description} {!fullDescription
? trail.description?.substring(0, 300)
: trail.description}
{#if (trail.description?.length ?? 0) > 300 && !fullDescription}
<button
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
fullDescription = true;
}}
>
... <span class="underline"
>{$_("read-more")}</span
></button
>
{/if}
</article> </article>
{:else} {:else}
<EmptyStateDescription></EmptyStateDescription> <EmptyStateDescription></EmptyStateDescription>
{/if} {/if}
{/if} <h4 class="text-2xl font-semibold mb-6 mt-12">{$_('route')}</h4>
{#if activeTab == 1} <TrailTimeline
{#if trail.expand?.waypoints.length} {trail}
<ul> onmouseenter={openMarkerPopup}
{#each trail.expand?.waypoints ?? [] as waypoint, i} onmouseleave={closeMarkerPopup}
<li ></TrailTimeline>
onmouseenter={() => openMarkerPopup(i)}
onmouseleave={() => closeMarkerPopup(i)} <div class="mb-6 mt-12">
> <Tabs {tabs} bind:activeTab></Tabs>
<WaypointCard {waypoint}></WaypointCard>
</li>
{/each}
</ul>
{:else}
<EmptyStateWaypoint></EmptyStateWaypoint>
{/if}
{/if}
{#if activeTab == 2}
{#if trail.photos.length}
<div
id="photo-gallery"
class="grid grid-cols-1 {mode == 'overview'
? 'sm:grid-cols-2 md:grid-cols-3'
: ''} gap-4"
>
<PhotoGallery
photos={trail.photos.map((p) =>
getFileURL(trail, p),
)}
bind:this={gallery}
></PhotoGallery>
{#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"
onclick={() => gallery.openGallery(i)}
src={getFileURL(trail, photo)}
alt=""
/>
{/each}
</div>
{:else}
<EmptyStatePhotos></EmptyStatePhotos>
{/if}
{/if}
{#if activeTab == 3}
<div class="overflow-x-auto">
<SummitLogTable
summitLogs={trail.expand?.summit_logs}
showAuthor
showRoute
showPhotos
></SummitLogTable>
</div> </div>
{/if} {#if activeTab == 0}
{#if activeTab == 4} <div class="overflow-x-auto">
<div> <SummitLogTable
{#if $currentUser} summitLogs={trail.expand?.summit_logs}
<div class="flex items-center gap-4"> showAuthor
<img showRoute
class="rounded-full w-10 aspect-square" showPhotos
src={getFileURL( ></SummitLogTable>
$currentUser, </div>
$currentUser.avatar, {/if}
) || {#if activeTab == 1}
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`} {#if trail.photos.length}
alt="avatar" <div
/> id="photo-gallery"
<div class="basis-full"> class="grid grid-cols-1 {mode == 'overview'
<Textarea ? 'sm:grid-cols-2 md:grid-cols-3'
bind:value={newComment.text} : ''} gap-4"
rows={2} >
placeholder="Add comment..." <PhotoGallery
></Textarea> photos={trail.photos.map((p) =>
</div> getFileURL(trail, p),
</div> )}
<div class="flex justify-end mt-3"> bind:this={gallery}
<Button ></PhotoGallery>
onclick={createComment} {#each trail.photos ?? [] as photo, i}
loading={commentCreateLoading} <!-- svelte-ignore a11y_click_events_have_key_events -->
secondary={true} <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
disabled={commentCreateLoading || <img
newComment.text.length == 0}>Comment</Button class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
> onclick={() => gallery.openGallery(i)}
</div> src={getFileURL(trail, photo)}
{/if} alt=""
{#if commentsLoading} />
{#each { length: 3 } as _, index} {/each}
<SkeletonNotificationCard
></SkeletonNotificationCard>
{/each}
{:else if $comments.length == 0}
<div class="my-4">
<EmptyStateComment></EmptyStateComment>
</div> </div>
{:else} {:else}
<ul class="space-y-4"> <EmptyStatePhotos></EmptyStatePhotos>
{#each $comments ?? [] as comment}
<li>
<CommentCard
{comment}
mode={comment.author == $currentUser?.id
? "edit"
: "show"}
ondelete={deleteComment}
onedit={editComment}
></CommentCard>
</li>
{/each}
</ul>
{/if} {/if}
</div> {/if}
{/if} {#if activeTab == 2}
<div>
{#if $currentUser}
<div class="flex items-center gap-4">
<img
class="rounded-full w-10 aspect-square"
src={getFileURL(
$currentUser,
$currentUser.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<div class="basis-full">
<Textarea
bind:value={newComment.text}
rows={2}
placeholder="Add comment..."
></Textarea>
</div>
</div>
<div class="flex justify-end mt-3">
<Button
onclick={createComment}
loading={commentCreateLoading}
secondary={true}
disabled={commentCreateLoading ||
newComment.text.length == 0}
>Comment</Button
>
</div>
{/if}
{#if commentsLoading}
{#each { length: 3 } as _, index}
<SkeletonNotificationCard
></SkeletonNotificationCard>
{/each}
{:else if $comments.length == 0}
<div class="my-4">
<EmptyStateComment></EmptyStateComment>
</div>
{:else}
<ul class="space-y-4">
{#each $comments ?? [] as comment}
<li>
<CommentCard
{comment}
mode={comment.author ==
$currentUser?.id
? "edit"
: "show"}
ondelete={deleteComment}
onedit={editComment}
></CommentCard>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{#if mode == "overview"} {#if mode == "overview"}
<div class="relative h-72 rounded-xl overflow-hidden"> <div class="sticky top-4 h-72 rounded-xl overflow-hidden">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={[trail]} trails={[trail]}
activeTrail={0} activeTrail={0}

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util";
import { formatDistance } from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte";
type Props = {
trail: Trail;
onmouseenter?: (index: number) => void;
onmouseleave?: (index: number) => void;
};
let gallery: PhotoGallery;
let { trail, onmouseenter, onmouseleave }: Props = $props();
</script>
<div
class="trail-timeline relative grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-6"
>
<div class="bg-background">
<i class="fa fa-bullseye"></i>
</div>
<div class="">
<p class="font-semibold">{$_("start")}</p>
</div>
{#each trail.expand?.waypoints ?? [] as wp, i}
<div
class="bg-background cursor-pointer"
role="presentation"
onmouseenter={() => onmouseenter?.(i)}
onmouseleave={() => onmouseleave?.(i)}
>
<i class="fa fa-{wp.icon ?? 'circle'}"></i>
{#if wp.distance_from_start}
<p class="text-sm text-gray-500">
{formatDistance(wp.distance_from_start)}
</p>
{/if}
</div>
<div class="border border-input-border rounded-xl overflow-hidden">
<PhotoGallery
photos={wp.photos.map((p) => getFileURL(wp, p))}
bind:this={gallery}
></PhotoGallery>
{#if wp.photos.length}
<div
class="grid {wp.photos.length > 1
? 'grid-cols-[8fr_5fr]'
: 'grid-cols-1'} cursor-pointer"
role="presentation"
onclick={() => gallery.openGallery()}
>
{#each wp.photos as photo, i}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 && wp.photos.length > 2}
src={getFileURL(wp, photo)}
alt=""
/>
{/each}
</div>
{/if}
<div class="p-4">
<h5 class="text-xl font-semibold">{wp.name}</h5>
<span class="text-sm text-gray-500"
>{wp.lat.toFixed(5)}, {wp.lon.toFixed(5)}</span
>
<p class="whitespace-pre-line">
{wp.description}
</p>
</div>
</div>
{/each}
<div class="bg-background">
<i class="fa fa-flag-checkered"></i>
{#if trail.distance}
<p class="text-sm text-gray-500">
{formatDistance(trail.distance)}
</p>
{/if}
</div>
<div class="">
<p class="font-semibold">{$_("finish")}</p>
</div>
</div>
<style>
.trail-timeline::before {
border-right: 2px dotted rgba(var(--input-border));
bottom: 0;
content: "";
left: 7.2px;
position: absolute;
top: 0;
width: 1px;
z-index: -1;
}
</style>

View File

@@ -113,6 +113,7 @@
"file-too-big": "Datei {file} ist zu groß (max. {size})", "file-too-big": "Datei {file} ist zu groß (max. {size})",
"filter-categories": "Kategorien filtern", "filter-categories": "Kategorien filtern",
"filter-difficulty": "Schwierigkeit filtern", "filter-difficulty": "Schwierigkeit filtern",
"finish": "",
"focus-map-on": "Karte fokussieren auf", "focus-map-on": "Karte fokussieren auf",
"follow": "Folgen", "follow": "Folgen",
"followers": "Follower", "followers": "Follower",
@@ -223,6 +224,7 @@
"removed-trail-from": "Route entfernt aus", "removed-trail-from": "Route entfernt aus",
"required": "Pflichtfeld", "required": "Pflichtfeld",
"reset-password": "Passwort zurücksetzen", "reset-password": "Passwort zurücksetzen",
"route": "",
"route-point": "Routenpunkt", "route-point": "Routenpunkt",
"save": "Speichern", "save": "Speichern",
"save-list": "Liste speichern", "save-list": "Liste speichern",
@@ -261,6 +263,7 @@
"sort": "Sortieren", "sort": "Sortieren",
"spanish": "Spanisch", "spanish": "Spanisch",
"speed": "Geschwindigkeit", "speed": "Geschwindigkeit",
"start": "",
"statistics": "Statistiken", "statistics": "Statistiken",
"stop-drawing": "Zeichnen beenden", "stop-drawing": "Zeichnen beenden",
"stop-editing": "Bearbeiten beenden", "stop-editing": "Bearbeiten beenden",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories", "filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "Finish",
"focus-map-on": "Focus map on", "focus-map-on": "Focus map on",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "Removed trail from", "removed-trail-from": "Removed trail from",
"required": "Required", "required": "Required",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"route": "Route",
"route-point": "Route Point", "route-point": "Route Point",
"save": "Save", "save": "Save",
"save-list": "Save List", "save-list": "Save List",
@@ -261,6 +263,7 @@
"sort": "Sort", "sort": "Sort",
"spanish": "Spanish", "spanish": "Spanish",
"speed": "Speed", "speed": "Speed",
"start": "Start",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "Stop drawing", "stop-drawing": "Stop drawing",
"stop-editing": "Stop editing", "stop-editing": "Stop editing",

View File

@@ -113,6 +113,7 @@
"file-too-big": "Archivo {file} es demasiado grande (max. {size})", "file-too-big": "Archivo {file} es demasiado grande (max. {size})",
"filter-categories": "Filtrar categorías", "filter-categories": "Filtrar categorías",
"filter-difficulty": "Filtrar dificultad", "filter-difficulty": "Filtrar dificultad",
"finish": "",
"focus-map-on": "Centrar mapa sobre", "focus-map-on": "Centrar mapa sobre",
"follow": "Seguir", "follow": "Seguir",
"followers": "Seguidores", "followers": "Seguidores",
@@ -223,6 +224,7 @@
"removed-trail-from": "Ruta borrada de", "removed-trail-from": "Ruta borrada de",
"required": "Obligatorio", "required": "Obligatorio",
"reset-password": "Restablecer Contraseña", "reset-password": "Restablecer Contraseña",
"route": "",
"route-point": "", "route-point": "",
"save": "Guardar", "save": "Guardar",
"save-list": "Guardar Lista", "save-list": "Guardar Lista",
@@ -261,6 +263,7 @@
"sort": "Ordenar", "sort": "Ordenar",
"spanish": "Español", "spanish": "Español",
"speed": "Velocidad", "speed": "Velocidad",
"start": "",
"statistics": "Estadísticas", "statistics": "Estadísticas",
"stop-drawing": "Parar de diseñar", "stop-drawing": "Parar de diseñar",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories", "filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "",
"focus-map-on": "Centrer la carte sur", "focus-map-on": "Centrer la carte sur",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "Enlever l'itinéraire de", "removed-trail-from": "Enlever l'itinéraire de",
"required": "Requis", "required": "Requis",
"reset-password": "Réinitialiser le mot de passe", "reset-password": "Réinitialiser le mot de passe",
"route": "",
"route-point": "", "route-point": "",
"save": "Sauvegarder", "save": "Sauvegarder",
"save-list": "Sauvegarder la liste", "save-list": "Sauvegarder la liste",
@@ -261,6 +263,7 @@
"sort": "Trier", "sort": "Trier",
"spanish": "Espagnol", "spanish": "Espagnol",
"speed": "Vitesse", "speed": "Vitesse",
"start": "",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "Arrêter de tracer", "stop-drawing": "Arrêter de tracer",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories", "filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "",
"focus-map-on": "Focus map on", "focus-map-on": "Focus map on",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "Eltávolított nyomvonal a", "removed-trail-from": "Eltávolított nyomvonal a",
"required": "Kötelező", "required": "Kötelező",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"route": "",
"route-point": "", "route-point": "",
"save": "Mentés", "save": "Mentés",
"save-list": "Save List", "save-list": "Save List",
@@ -261,6 +263,7 @@
"sort": "Rendezés", "sort": "Rendezés",
"spanish": "spanyol", "spanish": "spanyol",
"speed": "Speed", "speed": "Speed",
"start": "",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "Stop drawing", "stop-drawing": "Stop drawing",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} è troppo grande (max. {size})", "file-too-big": "File {file} è troppo grande (max. {size})",
"filter-categories": "Filtrare categorie", "filter-categories": "Filtrare categorie",
"filter-difficulty": "Filtrare difficoltà", "filter-difficulty": "Filtrare difficoltà",
"finish": "",
"focus-map-on": "Focus sulla mappa", "focus-map-on": "Focus sulla mappa",
"follow": "Seguire", "follow": "Seguire",
"followers": "Seguaci", "followers": "Seguaci",
@@ -223,6 +224,7 @@
"removed-trail-from": "Percorso rimosso da", "removed-trail-from": "Percorso rimosso da",
"required": "Obbligatorio", "required": "Obbligatorio",
"reset-password": "Ripristinare Password", "reset-password": "Ripristinare Password",
"route": "",
"route-point": "", "route-point": "",
"save": "Salva", "save": "Salva",
"save-list": "Salta Lista", "save-list": "Salta Lista",
@@ -261,6 +263,7 @@
"sort": "Ordina", "sort": "Ordina",
"spanish": "spagnolo", "spanish": "spagnolo",
"speed": "Velocità", "speed": "Velocità",
"start": "",
"statistics": "Statistiche", "statistics": "Statistiche",
"stop-drawing": "Smettere di disegnare", "stop-drawing": "Smettere di disegnare",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filter categories", "filter-categories": "Filter categories",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "",
"focus-map-on": "Focus map on", "focus-map-on": "Focus map on",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "De wandelroute is verwijderd van", "removed-trail-from": "De wandelroute is verwijderd van",
"required": "Verplicht", "required": "Verplicht",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"route": "",
"route-point": "", "route-point": "",
"save": "Bewaren", "save": "Bewaren",
"save-list": "Save List", "save-list": "Save List",
@@ -261,6 +263,7 @@
"sort": "Sorteren", "sort": "Sorteren",
"spanish": "Spaans", "spanish": "Spaans",
"speed": "Speed", "speed": "Speed",
"start": "",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "Stop drawing", "stop-drawing": "Stop drawing",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "Plik {file} jest za duży (maks. {size})", "file-too-big": "Plik {file} jest za duży (maks. {size})",
"filter-categories": "Filtruj kategorie", "filter-categories": "Filtruj kategorie",
"filter-difficulty": "Filtruj poziom trudności", "filter-difficulty": "Filtruj poziom trudności",
"finish": "",
"focus-map-on": "Skoncentruj mapę na", "focus-map-on": "Skoncentruj mapę na",
"follow": "Obserwuj", "follow": "Obserwuj",
"followers": "Obserwujący", "followers": "Obserwujący",
@@ -223,6 +224,7 @@
"removed-trail-from": "Usunięto szlak z", "removed-trail-from": "Usunięto szlak z",
"required": "Wymagane", "required": "Wymagane",
"reset-password": "Resetuj hasło", "reset-password": "Resetuj hasło",
"route": "",
"route-point": "", "route-point": "",
"save": "Zapisz", "save": "Zapisz",
"save-list": "Zapisz listę", "save-list": "Zapisz listę",
@@ -261,6 +263,7 @@
"sort": "Sortowanie", "sort": "Sortowanie",
"spanish": "hiszpański", "spanish": "hiszpański",
"speed": "Prędkość", "speed": "Prędkość",
"start": "",
"statistics": "Statystyki", "statistics": "Statystyki",
"stop-drawing": "Przestań rysować", "stop-drawing": "Przestań rysować",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "Filtrar categorias", "filter-categories": "Filtrar categorias",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "",
"focus-map-on": "Centrar mapa em", "focus-map-on": "Centrar mapa em",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "Trilha removida de", "removed-trail-from": "Trilha removida de",
"required": "Obrigatório", "required": "Obrigatório",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"route": "",
"route-point": "", "route-point": "",
"save": "Guardar", "save": "Guardar",
"save-list": "Gravar lista", "save-list": "Gravar lista",
@@ -261,6 +263,7 @@
"sort": "Ordenar", "sort": "Ordenar",
"spanish": "Espanhol", "spanish": "Espanhol",
"speed": "Velocidade", "speed": "Velocidade",
"start": "",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "Parar desenho", "stop-drawing": "Parar desenho",
"stop-editing": "", "stop-editing": "",

View File

@@ -113,6 +113,7 @@
"file-too-big": "File {file} is too big (max. {size})", "file-too-big": "File {file} is too big (max. {size})",
"filter-categories": "筛选分类", "filter-categories": "筛选分类",
"filter-difficulty": "Filter difficulty", "filter-difficulty": "Filter difficulty",
"finish": "",
"focus-map-on": "地图聚焦于", "focus-map-on": "地图聚焦于",
"follow": "Follow", "follow": "Follow",
"followers": "Followers", "followers": "Followers",
@@ -223,6 +224,7 @@
"removed-trail-from": "路线已删除自", "removed-trail-from": "路线已删除自",
"required": "必填", "required": "必填",
"reset-password": "重置密码", "reset-password": "重置密码",
"route": "",
"route-point": "", "route-point": "",
"save": "保存", "save": "保存",
"save-list": "保存列表", "save-list": "保存列表",
@@ -261,6 +263,7 @@
"sort": "排序", "sort": "排序",
"spanish": "西班牙语", "spanish": "西班牙语",
"speed": "速度", "speed": "速度",
"start": "",
"statistics": "Statistics", "statistics": "Statistics",
"stop-drawing": "停止绘制", "stop-drawing": "停止绘制",
"stop-editing": "", "stop-editing": "",

View File

@@ -1,5 +1,6 @@
import { z, ZodType } from "zod"; import { z, ZodType } from "zod";
import type { Waypoint } from "../waypoint"; import type { Waypoint } from "../waypoint";
import { icons } from "$lib/util/icon_util";
const WaypointCreateSchema = z.object({ const WaypointCreateSchema = z.object({
id: z.string().length(15).optional(), id: z.string().length(15).optional(),
@@ -7,7 +8,8 @@ const WaypointCreateSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
lat: z.number({coerce: true}).min(-90).max(90), lat: z.number({coerce: true}).min(-90).max(90),
lon: z.number({coerce: true}).min(-180).max(180), lon: z.number({coerce: true}).min(-180).max(180),
icon: z.string().optional(), distance_from_start: z.number({coerce: true}).min(0).optional(),
icon: z.enum(icons).optional(),
author: z.string().length(15), author: z.string().length(15),
photos: z.array(z.string()).default([]), photos: z.array(z.string()).default([]),
}) satisfies ZodType<Partial<Waypoint>> }) satisfies ZodType<Partial<Waypoint>>
@@ -17,7 +19,8 @@ const WaypointUpdateSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
lat: z.number({coerce: true}).min(-90).max(90).optional(), lat: z.number({coerce: true}).min(-90).max(90).optional(),
lon: z.number({coerce: true}).min(-180).max(180).optional(), lon: z.number({coerce: true}).min(-180).max(180).optional(),
icon: z.string().default("circle").optional(), distance_from_start: z.number({coerce: true}).min(0).optional(),
icon: z.enum(icons).default("circle").optional(),
photos: z.array(z.string()).optional(), photos: z.array(z.string()).optional(),
"photos-": z.string().optional(), "photos-": z.string().optional(),
"photos+": z.string().optional(), "photos+": z.string().optional(),

View File

@@ -1,3 +1,4 @@
import type { icons } from "$lib/util/icon_util";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
class Waypoint { class Waypoint {
@@ -6,14 +7,15 @@ class Waypoint {
description?: string; description?: string;
lat: number; lat: number;
lon: number; lon: number;
icon?: string; distance_from_start?: number;
icon?: typeof icons[number];
marker?: M.Marker; marker?: M.Marker;
photos: string[]; photos: string[];
_photos?: File[]; _photos?: File[];
author: string; author: string;
constructor(lat: number, lon: number, params?: { constructor(lat: number, lon: number, params?: {
id?: string, name?: string, description?: string, icon?: string, marker?: M.Marker, photos?: string[]; id?: string, name?: string, description?: string, icon?: typeof icons[number], marker?: M.Marker, photos?: string[];
}) { }) {
this.id = params?.id; this.id = params?.id;
this.name = params?.name ?? ""; this.name = params?.name ?? "";

View File

@@ -38,10 +38,11 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
}) })
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "p-2"
const spanElement = document.createElement("span"); const spanElement = document.createElement("span");
const iconElement = document.createElement("i"); const iconElement = document.createElement("i");
const iconName = icons.includes(waypoint.icon ?? "") ? waypoint.icon : "circle"; const iconName = waypoint.icon && icons.includes(waypoint.icon) ? waypoint.icon : "circle";
iconElement.classList.add("fa", `fa-${iconName}`) iconElement.classList.add("fa", `fa-${iconName}`)
spanElement.appendChild(iconElement); spanElement.appendChild(iconElement);
@@ -91,6 +92,7 @@ export function createAnchorMarker(lat: number, lon: number, index: number,
const popup = new M.Popup() const popup = new M.Popup()
const popupContent = document.createElement("div"); const popupContent = document.createElement("div");
popupContent.className = "py-3 pl-3"
const anchorH = document.createElement("h5") const anchorH = document.createElement("h5")
anchorH.classList.add("text-base", "font-medium"); anchorH.classList.add("text-base", "font-medium");
anchorH.textContent = get(_)("route-point") + " #" + index; anchorH.textContent = get(_)("route-point") + " #" + index;
@@ -140,7 +142,7 @@ export function createPopupFromTrail(trail: Trail) {
: get(theme) === "light" : get(theme) === "light"
? emptyStateTrailLight ? emptyStateTrailLight
: emptyStateTrailDark; : emptyStateTrailDark;
const popup = new M.Popup({ maxWidth: "320px" }); const popup = new M.Popup({ maxWidth: "420px" });
// Create a container element for the popup content // Create a container element for the popup content
const linkElement = document.createElement("a"); const linkElement = document.createElement("a");
linkElement.href = `/map/trail/${trail.id}`; // Set href safely linkElement.href = `/map/trail/${trail.id}`; // Set href safely
@@ -148,7 +150,7 @@ export function createPopupFromTrail(trail: Trail) {
// Create a list item element // Create a list item element
const listItem = document.createElement("li"); const listItem = document.createElement("li");
listItem.className = "flex items-center gap-4 cursor-pointer text-content max-w-80"; listItem.className = "flex gap-4 cursor-pointer text-content max-w-80";
// Create the image container // Create the image container
const imageContainer = document.createElement("div"); const imageContainer = document.createElement("div");
@@ -156,13 +158,14 @@ export function createPopupFromTrail(trail: Trail) {
// Create the image element // Create the image element
const img = document.createElement("img"); const img = document.createElement("img");
img.className = "h-14 w-14 object-cover rounded-xl"; img.className = "h-full max-w-20 object-cover";
img.src = thumbnail; // Set image source safely img.src = thumbnail; // Set image source safely
img.alt = ""; // Always include a safe alt attribute img.alt = ""; // Always include a safe alt attribute
imageContainer.appendChild(img); imageContainer.appendChild(img);
// Create the text container // Create the text container
const textContainer = document.createElement("div"); const textContainer = document.createElement("div");
textContainer.className = "py-2"
// Add trail name // Add trail name
const trailName = document.createElement("h4"); const trailName = document.createElement("h4");
@@ -193,7 +196,7 @@ export function createPopupFromTrail(trail: Trail) {
// Create the grid container for additional stats // Create the grid container for additional stats
const statsContainer = document.createElement("div"); const statsContainer = document.createElement("div");
statsContainer.className = statsContainer.className =
"grid grid-cols-2 mt-2 gap-x-4 gap-y-2 text-sm text-gray-500 flex-wrap"; "grid grid-cols-2 mt-2 gap-x-4 text-gray-500 flex-wrap";
const stats = [ const stats = [
{ icon: "fa-left-right", value: formatDistance(trail.distance) }, { icon: "fa-left-right", value: formatDistance(trail.distance) },

View File

@@ -943,6 +943,7 @@ export class ElevationProfile {
if (distance < minDistances[waypointIndex]) { if (distance < minDistances[waypointIndex]) {
minDistances[waypointIndex] = distance; minDistances[waypointIndex] = distance;
this.waypointPositions[waypointIndex] = this.cumulatedDistanceAdjustedUnit[i]; this.waypointPositions[waypointIndex] = this.cumulatedDistanceAdjustedUnit[i];
waypoint.distance_from_start = this.cumulatedDistanceAdjustedUnit[i] * 1000;
} }
}); });

View File

@@ -16,6 +16,7 @@ export async function GET(event: RequestEvent) {
t.expand = {} as any t.expand = {} as any
} }
t.expand!.author = await pb.collection("users_anonymous").getOne(t.author); t.expand!.author = await pb.collection("users_anonymous").getOne(t.author);
t.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
} }
return json(r) return json(r)
} catch (e: any) { } catch (e: any) {

View File

@@ -27,6 +27,9 @@ export async function GET(event: RequestEvent) {
log.expand!.author = await pb.collection("users_anonymous").getOne(log.author); log.expand!.author = await pb.collection("users_anonymous").getOne(log.author);
} }
} }
// sort waypoints by distance
r.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(r) return json(r)
} catch (e: any) { } catch (e: any) {
throw handleError(e) throw handleError(e)

View File

@@ -73,7 +73,7 @@
const trailItems = r[0].hits.map((t: TrailSearchResult) => ({ const trailItems = r[0].hits.map((t: TrailSearchResult) => ({
text: t.name, text: t.name,
description: `Trail ${t.location.length ? ", " + t.location : ""}`, description: `Trail ${t.location.length ? ", " + t.location : ""}`,
value: t, value: t.id,
icon: "route", icon: "route",
})); }));
const listItems = r[1].hits.map((t: ListSearchResult) => ({ const listItems = r[1].hits.map((t: ListSearchResult) => ({
@@ -93,7 +93,9 @@
} }
function handleSearchClick(item: SearchItem) { function handleSearchClick(item: SearchItem) {
if (item.icon === "layer-group") { if (item.icon == "route") {
goto(`/trail/view/${item.value}`);
} else if (item.icon == "layer-group") {
goto(`/lists?list=${item.value}`); goto(`/lists?list=${item.value}`);
} else { } else {
map?.setCenter([item.value.lon, item.value.lat]); map?.setCenter([item.value.lon, item.value.lat]);
@@ -220,7 +222,7 @@
<main class="grid grid-cols-1 md:grid-cols-[400px_1fr]"> <main class="grid grid-cols-1 md:grid-cols-[400px_1fr]">
<div <div
id="trail-list" id="trail-list"
class="md:overflow-y-auto md:overflow-x-hidden flex flex-col items-stretch gap-4 px-3 md:px-8" class="flex flex-col items-stretch gap-4 px-3 md:px-8"
> >
<div class="sticky top-0 z-10 bg-background pb-4 space-y-4"> <div class="sticky top-0 z-10 bg-background pb-4 space-y-4">
<div class="flex items-center gap-2 md:gap-4"> <div class="flex items-center gap-2 md:gap-4">

View File

@@ -13,10 +13,10 @@
<title>{$trail.name} | {$_("map")} | wanderer</title> <title>{$trail.name} | {$_("map")} | wanderer</title>
</svelte:head> </svelte:head>
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4"> <main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
<div class="hidden md:block"> <div id="panel" class="hidden md:block">
<TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel> <TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel>
</div> </div>
<div id="trail-details" class="sticky top-[62px]"> <div id="trail-details">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={[$trail]} trails={[$trail]}
waypoints={$trail.expand?.waypoints} waypoints={$trail.expand?.waypoints}
@@ -28,11 +28,11 @@
</main> </main>
<style> <style>
#trail-details { #trail-details, #panel {
height: calc(100vh); height: calc(100vh);
} }
@media only screen and (min-width: 768px) { @media only screen and (min-width: 768px) {
#trail-details { #trail-details, #panel {
height: calc(100vh - 124px); height: calc(100vh - 124px);
} }
} }