Improve trail planning: option to re-order route anchors (#1007)

* trail anchor list added

* add search location card to extend route endpoint in drawing mode

* add POI popup endpoint action in route drawing mode

* fix anchor stats, improve anchor list entries and refine route marker behavior

* fix map marker spinner

* docs updated

* fix spinner when delete an anchor

* further improvements

* fix adding first anchor from POI

* optimize drag handle area

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-06-06 11:56:12 +02:00
committed by GitHub
parent 14ebaa0d04
commit 44cd092065
32 changed files with 1792 additions and 192 deletions

View File

@@ -4,6 +4,7 @@
value: any;
icon?: string;
separator?: boolean;
danger?: boolean;
};
</script>
@@ -138,6 +139,8 @@
{:else}
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
class:hover:text-red-500={item.danger}
class:focus:text-red-500={item.danger}
role="presentation"
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
>

View File

@@ -19,6 +19,7 @@
import { ClusterLayer } from "$lib/vendor/maplibre-layer-manager/cluster-layer";
import { baseMapStyles } from "$lib/vendor/maplibre-layer-manager/layers";
import { LayerManager } from "$lib/vendor/maplibre-layer-manager/maplibre-layer-manager";
import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer";
import { PreviewLayer } from "$lib/vendor/maplibre-layer-manager/preview-layer";
import { TerrainLayer } from "$lib/vendor/maplibre-layer-manager/terrain-layer";
import { TrailLayer } from "$lib/vendor/maplibre-layer-manager/trail-layer";
@@ -69,6 +70,7 @@
) => void;
oninit?: (map: M.Map) => void;
autoGeolocateOnDrawing?: boolean;
buildPoiAnchorAction?: OverpassPopupActionFactory;
}
let {
@@ -100,6 +102,7 @@
onUnclusteredClick,
oninit,
autoGeolocateOnDrawing = false,
buildPoiAnchorAction = undefined,
}: Props = $props();
let mapContainer: HTMLDivElement;
@@ -809,7 +812,7 @@
};
map = new M.Map(finalMapOptions);
layerManager = new LayerManager(map);
layerManager = new LayerManager(map, { overpassActionFactory: buildPoiAnchorAction });
elevationMarker = new FontawesomeMarker(
{

View File

@@ -23,6 +23,8 @@
onRecalculateElevationData: () => void;
onUndo: () => void;
onRedo: () => void;
resetLabel?: string;
resetAriaLabel?: string;
}
let {
@@ -35,6 +37,8 @@
onRecalculateElevationData,
onUndo,
onRedo,
resetLabel = "reset",
resetAriaLabel = "reset-route",
}: Props = $props();
const modesOfTransport: SelectItem[] = [
@@ -145,6 +149,13 @@
aria-label="recalculate elevation data"
onclick={async () => await togglePanels(false, false, !recalculateElevationData)}><i class="fa fa-mountain text-sm"></i></button
>
<button
class="btn-icon tooltip hover:text-red-500"
type="button"
onclick={() => onReset()}
aria-label={$_(resetAriaLabel)}
data-title={$_(resetLabel)}><i class="fa fa-trash text-sm"></i></button
>
<button
class="btn-icon"
class:text-gray-500={valhallaStore.undoStack.length == 0}
@@ -182,13 +193,6 @@
data-title={$_("reverse-direction")}
><i class="fa fa-arrow-right-arrow-left"></i></button
>
<button
class="btn-icon tooltip"
type="button"
onclick={() => onReset()}
aria-label="Reset route"
data-title={$_("reset")}><i class="fa fa-trash"></i></button
>
<button
class="btn-icon tooltip"
type="button"

View File

@@ -0,0 +1,702 @@
<script lang="ts">
import { tick } from "svelte";
import { _ } from "svelte-i18n";
import GpxMetricsComputation from "$lib/models/gpx/gpx-metrics-computation";
import type TrackSegment from "$lib/models/gpx/track-segment";
import type { ValhallaAnchor } from "$lib/models/valhalla";
import {
searchLocationReverseStructured,
type ReverseLocationResult,
} from "$lib/stores/search_store";
import {
formatDistance,
formatElevation,
} from "$lib/util/format_util";
import { valhallaAnchorDisplay, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
interface Props {
anchors: ValhallaAnchor[];
segments?: TrackSegment[];
disabled?: boolean;
onMove: (fromIndex: number, toIndex: number) => void | Promise<void>;
onDelete: (index: number) => void;
onHover?: (index: number | null) => void;
}
let {
anchors,
segments = [],
disabled = false,
onMove,
onDelete,
onHover,
}: Props = $props();
const anchorCoordinates = (anchor: ValhallaAnchor) =>
`${anchor.lat.toFixed(4)}, ${anchor.lon.toFixed(4)}`;
function fallbackAnchorTitle(index: number) {
return valhallaAnchorTitle(index, anchors.length, $_);
}
const locationCache = new Map<string, ReverseLocationResult>();
const pendingLocationRequests = new Set<string>();
let locations = $state<Record<string, ReverseLocationResult>>({});
let locationAbortController: AbortController | null = null;
const commonAnchorCountry = $derived.by(() => {
const countries = anchors
.map((anchor) => locations[locationCacheKey(anchor)]?.country)
.filter((country): country is string => Boolean(country));
if (countries.length < 2) {
return null;
}
const [country] = countries;
return countries.every((nextCountry) => nextCountry === country) ? country : null;
});
function locationCacheKey(anchor: ValhallaAnchor) {
return `${anchor.lat.toFixed(5)},${anchor.lon.toFixed(5)}`;
}
async function loadAnchorLocation(anchor: ValhallaAnchor, signal: AbortSignal) {
const key = locationCacheKey(anchor);
if (locations[key] || pendingLocationRequests.has(key)) {
return;
}
const cached = locationCache.get(key);
if (cached) {
locations = { ...locations, [key]: cached };
return;
}
pendingLocationRequests.add(key);
try {
const location = await searchLocationReverseStructured(anchor.lat, anchor.lon, {
includeRoad: true,
signal,
});
if (location) {
locationCache.set(key, location);
locations = { ...locations, [key]: location };
}
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
console.error("Failed to resolve anchor location", error);
}
} finally {
pendingLocationRequests.delete(key);
}
}
async function loadAnchorLocations(nextAnchors: ValhallaAnchor[]) {
locationAbortController?.abort();
const controller = new AbortController();
locationAbortController = controller;
for (const anchor of nextAnchors) {
if (controller.signal.aborted) return;
await loadAnchorLocation(anchor, controller.signal);
}
}
$effect(() => {
void loadAnchorLocations(anchors);
return () => locationAbortController?.abort();
});
function anchorTitle(anchor: ValhallaAnchor, index: number) {
const location = locations[locationCacheKey(anchor)];
if (!location) {
return fallbackAnchorTitle(index);
}
return commonAnchorCountry && location.country === commonAnchorCountry
? location.label
: location.fullLabel;
}
function updateOverflowingStats(element: HTMLElement) {
requestAnimationFrame(() => {
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
for (const stat of stats) {
const content = stat.querySelector<HTMLElement>(".stats-content");
if (!content) {
continue;
}
const overflow = Math.max(0, content.scrollWidth - stat.clientWidth);
if (overflow <= 0) {
stat.classList.remove("is-overflowing");
stat.style.removeProperty("--stats-scroll-distance");
stat.style.removeProperty("--stats-scroll-duration");
continue;
}
const duration = Math.min(5, Math.max(1.6, overflow / 28));
stat.style.setProperty("--stats-scroll-distance", `-${overflow}px`);
stat.style.setProperty("--stats-scroll-duration", `${duration}s`);
stat.classList.add("is-overflowing");
}
});
}
function handleItemMouseEnter(e: MouseEvent, index: number) {
onHover?.(index);
const element = e.currentTarget as HTMLElement;
const title = element.querySelector<HTMLElement>(".anchor-title-viewport");
const text = element.querySelector<HTMLElement>(".anchor-title-text");
if (!title || !text) {
updateOverflowingStats(element);
return;
}
text.style.maxWidth = "none";
text.style.overflow = "visible";
text.style.textOverflow = "clip";
const overflow = Math.max(0, text.scrollWidth - title.clientWidth);
text.style.removeProperty("max-width");
text.style.removeProperty("overflow");
text.style.removeProperty("text-overflow");
if (overflow <= 0) {
title.classList.remove("is-overflowing");
title.style.removeProperty("--title-scroll-distance");
title.style.removeProperty("--title-scroll-duration");
updateOverflowingStats(element);
return;
}
const duration = Math.min(7, Math.max(1.8, overflow / 24));
title.style.setProperty("--title-scroll-distance", `-${overflow}px`);
title.style.setProperty("--title-scroll-duration", `${duration}s`);
title.classList.add("is-overflowing");
updateOverflowingStats(element);
}
function clearItemHoverState(e: Event) {
onHover?.(null);
const element = e.currentTarget as HTMLElement;
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
for (const stat of stats) {
stat.classList.remove("is-overflowing");
stat.style.removeProperty("--stats-scroll-distance");
stat.style.removeProperty("--stats-scroll-duration");
}
const title = element.querySelector<HTMLElement>(
".anchor-title-viewport",
);
if (!title) {
return;
}
title.classList.remove("is-overflowing");
title.style.removeProperty("--title-scroll-distance");
title.style.removeProperty("--title-scroll-duration");
}
function handleItemMouseLeave(e: MouseEvent) {
clearItemHoverState(e);
}
function anchorIcon(index: number) {
return valhallaAnchorDisplay(index, anchors.length).icon;
}
interface SegmentMetrics {
distance: number;
elevationGain: number;
elevationLoss: number;
}
function snapshotMetrics(metrics: GpxMetricsComputation): SegmentMetrics {
return {
distance: metrics.totalDistance,
elevationGain: metrics.totalElevationGainSmoothed,
elevationLoss: metrics.totalElevationLossSmoothed,
};
}
function subtractMetrics(metrics: SegmentMetrics, previous: SegmentMetrics): SegmentMetrics {
return {
distance: metrics.distance - previous.distance,
elevationGain: metrics.elevationGain - previous.elevationGain,
elevationLoss: metrics.elevationLoss - previous.elevationLoss,
};
}
const routeMetrics = $derived.by(() => {
const metrics = new GpxMetricsComputation(5, 5);
const segmentMetrics: SegmentMetrics[] = [];
const cumulativeMetrics: SegmentMetrics[] = [];
let previous = snapshotMetrics(metrics);
for (const segment of segments) {
const points = segment.trkpt ?? [];
for (let i = 1; i < points.length; i++) {
metrics.addAndFilter(points[i]);
}
const cumulative = snapshotMetrics(metrics);
cumulativeMetrics.push(cumulative);
segmentMetrics.push(subtractMetrics(cumulative, previous));
previous = cumulative;
}
return {
segmentMetrics,
cumulativeMetrics,
};
});
const allSegmentMetrics = $derived(routeMetrics.segmentMetrics);
const allCumulativeMetrics = $derived(routeMetrics.cumulativeMetrics);
function segmentMetrics(index: number) {
if (index === 0) return null;
return allSegmentMetrics[index - 1] ?? null;
}
function cumulativeMetrics(index: number) {
if (index === 0) return null;
return allCumulativeMetrics[index - 1] ?? null;
}
let listElement: HTMLOListElement;
let hasVerticalOverflow = $state(false);
let dragIndex = $state<number | null>(null);
let insertBefore = $state<number | null>(null);
let pointerId: number | null = null;
function updateListOverflow() {
if (!listElement) {
hasVerticalOverflow = false;
return;
}
hasVerticalOverflow = listElement.scrollHeight > listElement.clientHeight + 1;
}
$effect(() => {
anchors.length;
segments.length;
void tick().then(updateListOverflow);
});
$effect(() => {
const element = listElement;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(updateListOverflow);
observer.observe(element);
return () => observer.disconnect();
});
function isValidInsert(pos: number): boolean {
return (
dragIndex !== null &&
insertBefore === pos &&
pos !== dragIndex &&
pos !== dragIndex + 1
);
}
function getInsertPosition(clientY: number): number {
const items = Array.from(
listElement.querySelectorAll<HTMLElement>("li[data-anchor-index]"),
);
for (const item of items) {
const itemIndex = Number(item.dataset.anchorIndex);
const rect = item.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
return itemIndex;
}
}
return anchors.length;
}
function clearDragState() {
dragIndex = null;
insertBefore = null;
pointerId = null;
}
async function handleKeyDown(e: KeyboardEvent, index: number) {
if (disabled) return;
let toIndex: number | null = null;
if (e.key === "ArrowUp" && index > 0) {
e.preventDefault();
toIndex = index - 1;
} else if (e.key === "ArrowDown" && index < anchors.length - 1) {
e.preventDefault();
toIndex = index + 1;
}
if (toIndex === null) return;
await onMove(index, toIndex);
await tick();
const handles = listElement.querySelectorAll<HTMLElement>("li[data-anchor-index] button.drag-handle");
handles[toIndex]?.focus();
}
function handlePointerDown(e: PointerEvent, index: number) {
if (disabled || e.button !== 0) {
return;
}
e.preventDefault();
dragIndex = index;
insertBefore = index;
pointerId = e.pointerId;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (pointerId !== e.pointerId || dragIndex === null) {
return;
}
e.preventDefault();
insertBefore = getInsertPosition(e.clientY);
}
function handlePointerUp(e: PointerEvent) {
if (pointerId !== e.pointerId) {
return;
}
e.preventDefault();
if (dragIndex !== null && insertBefore !== null) {
const toIndex =
dragIndex < insertBefore ? insertBefore - 1 : insertBefore;
if (toIndex !== dragIndex) {
onMove(dragIndex, toIndex);
}
}
clearDragState();
}
</script>
<ol
bind:this={listElement}
class="anchor-list flex max-h-96 shrink-0 flex-col gap-2 overflow-y-auto py-2"
class:has-scrollbar={hasVerticalOverflow}
class:pr-3={hasVerticalOverflow}
>
{#each anchors as anchor, i (anchor.id)}
{@const metrics = segmentMetrics(i)}
{@const cumulative = cumulativeMetrics(i)}
{@const showCumulative = i > 1 && cumulative != null}
<li
data-anchor-index={i}
class="rounded-lg border border-input-border transition-colors hover:bg-secondary-hover"
class:p-3={i !== 0}
class:px-3={i === 0}
class:pt-2={i === 0}
class:pb-2={i === 0}
class:has-cumulative={showCumulative}
class:opacity-50={dragIndex === i}
class:drop-above={isValidInsert(i)}
class:drop-below={i === anchors.length - 1 && isValidInsert(anchors.length)}
onmouseenter={(e) => handleItemMouseEnter(e, i)}
onmouseleave={handleItemMouseLeave}
onfocusin={(e) => {
onHover?.(i);
updateOverflowingStats(e.currentTarget as HTMLElement);
}}
onfocusout={clearItemHoverState}
>
<div class="grid grid-cols-[2rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<button
class="drag-handle absolute inset-y-0 left-0 w-12 rounded-md p-0 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
disabled={disabled}
aria-label={$_("move-route-point")}
aria-keyshortcuts="ArrowUp ArrowDown"
onkeydown={(e) => handleKeyDown(e, i)}
onpointerdown={(e) => handlePointerDown(e, i)}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={clearDragState}
onlostpointercapture={clearDragState}
></button>
<span
class="anchor-icon pointer-events-none relative col-start-1 row-start-1 flex h-8 w-8 -translate-x-0.5 items-center justify-center text-xl text-content"
>
<i class="fa {anchorIcon(i)}"></i>
{#if i > 0 && i < anchors.length - 1}
<span
class="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-background px-1 text-[0.65rem] font-semibold leading-none text-gray-500"
>
{i}
</span>
{/if}
</span>
<div class="anchor-title col-start-2 min-w-0">
<div
class="anchor-title-viewport"
role="presentation"
title={anchorTitle(anchor, i)}
>
<span class="anchor-title-text font-medium leading-5">
{anchorTitle(anchor, i)}
</span>
</div>
<p class="truncate text-xs leading-4 text-gray-500">
{anchorCoordinates(anchor)}
</p>
</div>
{#if metrics}
{#if showCumulative}
<span
class="cumulative-indicator pointer-events-none relative z-10 col-start-1 row-start-2 flex h-5 w-5 items-center justify-center justify-self-center rounded-full bg-gray-500 text-xs font-semibold leading-none text-background"
title={$_("cumulative")}
aria-label={$_("cumulative")}
>
Σ
</span>
{/if}
<div class="col-start-2 row-start-2 min-w-0">
<div
class="segment-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
>
<span class="stats-content flex w-max items-center gap-x-3">
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(metrics.distance, { compact: true })}
</span>
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationGain)}
</span>
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationLoss)}
</span>
</span>
</div>
{#if showCumulative}
<div
class="cumulative-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
>
<span class="stats-content flex w-max items-center gap-x-3">
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(cumulative.distance, { compact: true })}
</span>
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationGain)}
</span>
<span class="flex shrink-0 items-center whitespace-nowrap">
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationLoss)}
</span>
</span>
</div>
{/if}
</div>
{/if}
</div>
<button
class="delete-button btn-icon text-xs text-gray-400 hover:text-red-500"
type="button"
disabled={disabled}
title={$_("delete")}
aria-label={$_("delete-route-point")}
onclick={() => onDelete(i)}
>
<i class="fa fa-trash"></i>
</button>
</li>
{/each}
</ol>
<style>
.anchor-list.has-scrollbar {
scrollbar-gutter: stable;
}
li {
position: relative;
}
.delete-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
height: 1.75rem;
font-size: 0.75rem;
}
.anchor-title {
--delete-button-space: 2rem;
--title-end-space: var(--delete-button-space);
}
.anchor-title-viewport {
width: calc(100% - var(--title-end-space));
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.anchor-title-text {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.drag-handle {
background-image:
radial-gradient(
circle at 0.25rem 0.4375rem,
rgba(var(--content), 0.5) 1.35px,
transparent 1.65px
),
radial-gradient(
circle at 0.75rem 0.125rem,
rgba(var(--content), 0.38) 1.35px,
transparent 1.65px
),
radial-gradient(
circle at 1.25rem 0.4375rem,
rgba(var(--content), 0.27) 1.35px,
transparent 1.65px
),
radial-gradient(
circle at 1.75rem 0.125rem,
rgba(var(--content), 0.17) 1.35px,
transparent 1.65px
),
radial-gradient(
circle at 2.25rem 0.4375rem,
rgba(var(--content), 0.1) 1.35px,
transparent 1.65px
),
radial-gradient(
circle at 2.75rem 0.125rem,
rgba(var(--content), 0.05) 1.35px,
transparent 1.65px
);
background-repeat: repeat-y;
background-size: 3rem 0.75rem;
cursor: grab;
opacity: 0.82;
touch-action: none;
transition: opacity 0.15s ease-in-out;
}
.drag-handle:active {
cursor: grabbing;
}
.cumulative-indicator,
.cumulative-stats {
display: none;
}
li.drop-above::before,
li.drop-below::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 9999px;
background: rgba(var(--content));
}
li.drop-above::before {
top: -6px;
}
li.drop-below::after {
bottom: -6px;
}
@media (hover: hover) and (pointer: fine) {
.anchor-title {
--title-end-space: 0rem;
}
li:hover .anchor-title,
li:focus-within .anchor-title {
--title-end-space: var(--delete-button-space);
}
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text {
max-width: none;
overflow: visible;
text-overflow: clip;
animation: anchor-title-marquee var(--title-scroll-duration, 3s)
ease-in-out 0.15s infinite alternate;
}
.delete-button {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
li:hover .delete-button,
li:focus-within .delete-button {
opacity: 1;
pointer-events: auto;
}
li:hover .drag-handle,
li:focus-within .drag-handle {
opacity: 1;
}
li.has-cumulative:hover .segment-stats,
li.has-cumulative:focus-within .segment-stats {
display: none;
}
li.has-cumulative:hover .cumulative-indicator,
li.has-cumulative:focus-within .cumulative-indicator,
li.has-cumulative:hover .cumulative-stats,
li.has-cumulative:focus-within .cumulative-stats {
display: block;
}
li.has-cumulative:hover .cumulative-indicator,
li.has-cumulative:focus-within .cumulative-indicator {
display: flex;
}
:global(.stats-viewport.is-overflowing) .stats-content {
animation: anchor-stats-marquee var(--stats-scroll-duration, 2.5s)
ease-in-out 0.15s infinite alternate;
}
}
@keyframes anchor-title-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(var(--title-scroll-distance, 0));
}
}
@keyframes anchor-stats-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(var(--stats-scroll-distance, 0));
}
}
@media (prefers-reduced-motion: reduce) {
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text,
:global(.stats-viewport.is-overflowing) .stats-content {
animation: none;
}
}
</style>

View File

@@ -289,6 +289,7 @@
text: $_("delete"),
value: "delete",
icon: "trash",
danger: true,
},
]
: []),
@@ -413,6 +414,7 @@
text: $_("delete"),
value: "delete",
icon: "trash",
danger: true,
},
]
: []),

View File

@@ -4,6 +4,7 @@
import Tabs from "$lib/components/base/tabs.svelte";
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
import { Comment } from "$lib/models/comment";
import { Tag } from "$lib/models/tag";
import type { Trail } from "$lib/models/trail";
import {
@@ -57,7 +58,12 @@
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import LikeButton from "./like_button.svelte";
import Editor from "../base/editor.svelte";
import { trails_update } from "$lib/stores/trail_store";
import {
trails_update,
trails_update_metadata,
} from "$lib/stores/trail_store";
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
import { tags_index } from "$lib/stores/tag_store";
interface Props {
initTrail: Trail;
@@ -87,15 +93,16 @@
...($currentUser ? [$_("comment", { values: { n: 2 } })] : []),
];
const trailIsShared =
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
const trailIsShared = $derived(
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
);
let gallery: PhotoGallery;
let newComment: Comment = $state({
text: "",
author: "",
trail: untrack(() => handle) + "/" + (trail.id ?? ""),
trail: untrack(() => `${handle}/${trail.id ?? ""}`),
});
let commentsLoading: boolean = $state(untrack(() => activeTab == 2));
@@ -106,6 +113,26 @@
let summitLogCreateLoading: boolean = $state(false);
let fullDescription: boolean = $state(false);
let metadataSaving: boolean = $state(false);
let editingName: boolean = $state(false);
let editingDescription: boolean = $state(false);
let editingTags: boolean = $state(false);
let nameDraft: string = $state("");
let descriptionDraft: string = $state("");
let tagDraftItems: ComboboxItem[] = $state([]);
let tagItems: ComboboxItem[] = $state([]);
const canEditTrail = $derived(
Boolean(
$currentUser &&
(trail.author === $currentUser.actor ||
trail.expand?.trail_share_via_trail?.some(
(share) =>
share.permission === "edit" &&
share.actor === $currentUser.actor,
)),
),
);
onMount(async () => {});
@@ -289,6 +316,133 @@
const updatedTrail: Trail = { ...trail };
await trails_update(trail, updatedTrail);
}
function cloneTrail(value: Trail): Trail {
return JSON.parse(JSON.stringify(value));
}
function mergeTrailUpdate(previousTrail: Trail, updatedTrail: Trail): Trail {
return {
...previousTrail,
...updatedTrail,
expand: {
...previousTrail.expand,
...updatedTrail.expand,
author: previousTrail.expand?.author,
trail_like_via_trail:
previousTrail.expand?.trail_like_via_trail,
},
};
}
async function saveTrailMetadata(update: (nextTrail: Trail) => void) {
if (!canEditTrail || metadataSaving) {
return false;
}
metadataSaving = true;
const oldTrail = cloneTrail(trail);
const nextTrail = cloneTrail(trail);
nextTrail.expand ??= {};
nextTrail.tags = [...(trail.tags ?? [])];
update(nextTrail);
const tagsChanged =
JSON.stringify(nextTrail.expand?.tags ?? []) !==
JSON.stringify(oldTrail.expand?.tags ?? []);
try {
const updatedTrail = await trails_update_metadata(oldTrail, {
name:
nextTrail.name !== oldTrail.name
? nextTrail.name
: undefined,
description:
nextTrail.description !== oldTrail.description
? nextTrail.description
: undefined,
expand: tagsChanged ? { tags: nextTrail.expand?.tags } : undefined,
});
trail = mergeTrailUpdate(trail, updatedTrail);
show_toast({
icon: "check",
type: "success",
text: $_("trail-saved-successfully"),
});
return true;
} catch (e) {
console.error(e);
show_toast({
icon: "close",
type: "error",
text: $_("error-saving-trail"),
});
return false;
} finally {
metadataSaving = false;
}
}
function startNameEdit() {
nameDraft = trail.name;
editingName = true;
}
async function saveNameEdit() {
const name = nameDraft.trim();
if (!name) {
return;
}
const saved = await saveTrailMetadata((nextTrail) => {
nextTrail.name = name;
});
editingName = !saved;
}
function startDescriptionEdit() {
descriptionDraft = trail.description ?? "";
editingDescription = true;
}
async function saveDescriptionEdit() {
const saved = await saveTrailMetadata((nextTrail) => {
nextTrail.description = descriptionDraft;
});
editingDescription = !saved;
if (saved) {
fullDescription = true;
}
}
function getTrailTagItems() {
return (
trail.expand?.tags?.map((tag) => ({
text: tag.name,
value: tag,
})) ?? []
);
}
function startTagsEdit() {
tagDraftItems = getTrailTagItems();
editingTags = true;
}
async function searchTags(q: string) {
const result = await tags_index(q);
tagItems = result.items.map((tag) => ({
text: tag.name,
value: tag,
}));
}
async function saveTagsEdit() {
const saved = await saveTrailMetadata((nextTrail) => {
nextTrail.expand!.tags = tagDraftItems.map((item) =>
item.value ? item.value : new Tag(item.text),
);
});
editingTags = !saved;
}
</script>
<div
@@ -348,12 +502,57 @@
</section>
<section class="border-b border-input-border p-8">
<div class="flex justify-between items-center gap-x-4">
{#if trail.expand?.tags && trail.expand.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#if editingTags}
<div class="flex-1">
<Combobox
bind:value={tagDraftItems}
onupdate={searchTags}
items={tagItems}
placeholder={`${$_("tags")}...`}
multiple
chips
></Combobox>
<div class="flex gap-2 mt-3">
<button
class="btn-secondary"
type="button"
disabled={metadataSaving}
onclick={() => (editingTags = false)}
>{$_("cancel")}</button
>
<Button
primary
type="button"
loading={metadataSaving}
onclick={saveTagsEdit}>{$_("save")}</Button
>
</div>
</div>
{:else if trail.expand?.tags && trail.expand.tags.length > 0}
<div class="group flex flex-wrap items-center gap-2">
{#each trail.expand.tags as tag}
<Chip text={tag.name} primary={false}></Chip>
{/each}
{#if canEditTrail}
<button
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
type="button"
aria-label={$_("tags")}
data-title={$_("tags")}
onclick={startTagsEdit}
><i class="fa fa-pen text-sm"></i></button
>
{/if}
</div>
{:else if canEditTrail}
<button
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
type="button"
aria-label={$_("tags")}
data-title={$_("tags")}
onclick={startTagsEdit}
><i class="fa fa-tags text-sm"></i></button
>
{/if}
{#if (trail.public || trailIsShared) && $currentUser}
<div
@@ -378,15 +577,63 @@
</div>
<div class="flex justify-between items-end w-full gap-y-4">
<div class=" overflow-hidden">
<h4
title={trail.name}
class="{mode == 'map'
? 'text-4xl'
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
style="line-height: 1.18"
>
{trail.name}
</h4>
{#if editingName}
<div class="flex flex-col gap-3 mb-3">
<input
class="{mode == 'map'
? 'text-4xl'
: 'text-5xl'} font-bold bg-input-background border border-input-border rounded-md px-3 py-2 focus:outline-none focus:border-input-border-focus"
bind:value={nameDraft}
disabled={metadataSaving}
aria-label={$_("name")}
onkeydown={(e) => {
if (e.key === "Enter") {
void saveNameEdit();
} else if (e.key === "Escape") {
editingName = false;
}
}}
/>
<div class="flex gap-2">
<button
class="btn-secondary"
type="button"
disabled={metadataSaving}
onclick={() => (editingName = false)}
>{$_("cancel")}</button
>
<Button
primary
type="button"
loading={metadataSaving}
disabled={!nameDraft.trim()}
onclick={saveNameEdit}>{$_("save")}</Button
>
</div>
</div>
{:else}
<div class="group flex items-end gap-2">
<h4
title={trail.name}
class="{mode == 'map'
? 'text-4xl'
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
style="line-height: 1.18"
>
{trail.name}
</h4>
{#if canEditTrail}
<button
class="btn-icon tooltip shrink-0 mb-2 opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
type="button"
aria-label={$_("name")}
data-title={$_("name")}
onclick={startNameEdit}
><i class="fa fa-pen text-sm"></i></button
>
{/if}
</div>
{/if}
{#if trail.date}
<h5 class="text-sm text-gray-500">
{new Date(trail.date).toLocaleDateString(
@@ -514,10 +761,44 @@
class:xl:grid-cols-[1fr_18rem]={mode == "overview"}
>
<div class="order-1 xl:-order-1">
<h4 class="text-2xl font-semibold my-4">
{$_("description")}
</h4>
{#if trail.description?.length}
<div class="group flex items-center gap-2 my-4">
<h4 class="text-2xl font-semibold">
{$_("description")}
</h4>
{#if canEditTrail && !editingDescription}
<button
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
type="button"
aria-label={$_("description")}
data-title={$_("description")}
onclick={startDescriptionEdit}
><i class="fa fa-pen text-sm"></i></button
>
{/if}
</div>
{#if editingDescription}
<div class="mb-6">
<Editor
extraClasses="min-h-24"
bind:value={descriptionDraft}
></Editor>
<div class="flex gap-2 mt-3">
<button
class="btn-secondary"
type="button"
disabled={metadataSaving}
onclick={() => (editingDescription = false)}
>{$_("cancel")}</button
>
<Button
primary
type="button"
loading={metadataSaving}
onclick={saveDescriptionEdit}>{$_("save")}</Button
>
</div>
</div>
{:else if trail.description?.length}
<article
class="text-justify whitespace-pre-line text-sm prose dark:prose-invert"
>