Files
wanderer/web/src/routes/trail/edit/[id]/+page.svelte
Flomp 9276690d60 Federation (#327)
* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
2025-06-21 10:09:22 +02:00

1321 lines
44 KiB
Svelte

<script lang="ts">
import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte";
import Datepicker from "$lib/components/base/datepicker.svelte";
import Select from "$lib/components/base/select.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Textarea from "$lib/components/base/textarea.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import ListSelectModal from "$lib/components/list/list_select_modal.svelte";
import SummitLogCard from "$lib/components/summit_log/summit_log_card.svelte";
import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import PhotoPicker from "$lib/components/trail/photo_picker.svelte";
import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte";
import WaypointModal from "$lib/components/waypoint/waypoint_modal.svelte";
import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema.js";
import { TrailCreateSchema } from "$lib/models/api/trail_schema.js";
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema.js";
import GPX from "$lib/models/gpx/gpx";
import type { List } from "$lib/models/list";
import { SummitLog } from "$lib/models/summit_log";
import { Trail } from "$lib/models/trail";
import type { RoutingOptions, ValhallaAnchor } from "$lib/models/valhalla";
import { Waypoint } from "$lib/models/waypoint";
import { categories } from "$lib/stores/category_store";
import {
lists_add_trail,
lists_remove_trail,
} from "$lib/stores/list_store";
import { summitLog } from "$lib/stores/summit_log_store";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import {
trail,
trails_create,
trails_update,
} from "$lib/stores/trail_store.js";
import {
anchors,
calculateRouteBetween,
clearRoute,
deleteFromRoute,
editRoute,
insertIntoRoute,
normalizeRouteTime,
route,
setRoute,
} from "$lib/stores/valhalla_store";
import { waypoint } from "$lib/stores/waypoint_store";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
import { page } from "$app/state";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import Combobox, {
type ComboboxItem,
} from "$lib/components/base/combobox.svelte";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import Search, {
type SearchItem,
} from "$lib/components/base/search.svelte";
import RoutingOptionsPopup from "$lib/components/trail/routing_options_popup.svelte";
import { TagCreateSchema } from "$lib/models/api/tag_schema.js";
import { convertDMSToDD } from "$lib/models/gpx/utils.js";
import { Tag } from "$lib/models/tag.js";
import {
searchLocationReverse,
searchLocations,
} from "$lib/stores/search_store.js";
import { tags_index } from "$lib/stores/tag_store.js";
import { theme } from "$lib/stores/theme_store.js";
import { getIconForLocation } from "$lib/util/icon_util.js";
import {
createAnchorMarker,
createEditTrailMapPopup,
} from "$lib/util/maplibre_util";
import EXIF from "$lib/vendor/exif-js/exif.js";
import { validator } from "@felte/validator-zod";
import cryptoRandomString from "crypto-random-string";
import { createForm } from "felte";
import * as M from "maplibre-gl";
import { onMount, untrack } from "svelte";
import { _ } from "svelte-i18n";
import { backInOut } from "svelte/easing";
import { scale } from "svelte/transition";
import { z } from "zod";
import { currentUser } from "$lib/stores/user_store.js";
import Editor from "$lib/components/base/editor.svelte";
let { data } = $props();
let map: M.Map | undefined = $state();
let mapPopup: M.Popup | undefined;
let mapTrail: Trail[] = $state([]);
let lists = $state(data.lists);
let waypointModal: WaypointModal;
let summitLogModal: SummitLogModal;
let listSelectModal: ListSelectModal;
let loading = $state(false);
let editingBasicInfo: boolean = $state(false);
let photoFiles: File[] = $state([]);
let gpxFile: File | Blob | null = null;
let drawingActive = $state(false);
let overwriteGPX = false;
let draggingMarker = false;
let searchDropdownItems: SearchItem[] = $state([]);
const ClientTrailCreateSchema = TrailCreateSchema.extend({
expand: z
.object({
gpx_data: z.string().optional(),
summit_logs_via_trail: z
.array(SummitLogCreateSchema)
.optional(),
waypoints: z
.array(
WaypointCreateSchema.extend({
marker: z.any().optional(),
}),
)
.optional(),
tags: z.array(TagCreateSchema).optional(),
})
.optional(),
});
let routingOptions: RoutingOptions = $state({
autoRouting: true,
modeOfTransport: "pedestrian",
});
let savedAtLeastOnce = $state(false);
let tagItems: ComboboxItem[] = $state([]);
const {
form,
errors,
data: formData,
setFields,
} = createForm<z.infer<typeof ClientTrailCreateSchema>>({
initialValues: {
...data.trail,
public: data.trail.id
? data.trail.public
: page.data.settings?.privacy?.trails === "public",
category:
data.trail.category ||
page.data.settings?.category ||
$categories[0].id,
},
extend: validator({
schema: ClientTrailCreateSchema,
}),
onSubmit: async (form) => {
loading = true;
try {
const htmlForm = document.getElementById(
"trail-form",
) as HTMLFormElement;
const formData = new FormData(htmlForm);
if (!formData.get("public")) {
form.public = false;
}
form.photos = form.photos.filter(
(p) => !p.startsWith("data:image/svg+xml;base64"),
);
if (!form.photos?.length && !photoFiles.length) {
const canvas = document.querySelector(
"#map .maplibregl-canvas",
) as HTMLCanvasElement;
const dataURL = canvas.toDataURL("image/webp", 0.3);
const response = await fetch(dataURL);
const blob = await response.blob();
photoFiles = [new File([blob], "route")];
}
if (form.expand!.gpx_data && overwriteGPX) {
gpxFile = new Blob([form.expand!.gpx_data], {
type: "text/xml",
});
}
if (
(!form.lat || !form.lon) &&
route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)
) {
form.lat = route.trk
?.at(0)
?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lat;
form.lon = route.trk
?.at(0)
?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lon;
}
if (page.params.id === "new" && !savedAtLeastOnce) {
const createdTrail = await trails_create(
form as Trail,
photoFiles,
gpxFile,
);
setFields(createdTrail);
trail.set(createdTrail);
} else {
const updatedTrail = await trails_update(
$trail,
form as Trail,
photoFiles,
gpxFile,
);
setFields(updatedTrail);
}
photoFiles = [];
savedAtLeastOnce = true;
show_toast({
type: "success",
icon: "check",
text: $_("trail-saved-successfully"),
});
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-saving-trail"),
});
} finally {
loading = false;
}
},
});
onMount(async () => {
clearAnchorMarker();
clearRoute();
if ($formData.expand!.gpx_data) {
const gpx = await GPX.parse($formData.expand!.gpx_data);
if (!(gpx instanceof Error)) {
if (gpx.rte && !gpx.trk) {
gpx.trk = [
{
trkseg: [
{
trkpt: gpx.rte?.at(0)?.rtept,
},
],
},
];
gpx.rte = undefined;
}
setRoute(gpx);
initRouteAnchors(gpx);
}
}
});
function openFileBrowser() {
document.getElementById("fileInput")!.click();
}
async function handleFileSelection() {
const selectedFile = (
document.getElementById("fileInput") as HTMLInputElement
).files?.[0];
if (!selectedFile) {
return;
}
clearWaypoints();
clearAnchorMarker();
clearRoute();
drawingActive = false;
overwriteGPX = false;
const { gpxData, gpxFile: file } = await fromFile(selectedFile);
gpxFile = file;
try {
const prevId = $formData.id;
const parseResult = await gpx2trail(gpxData, selectedFile.name);
setFields(parseResult.trail);
$formData.id = prevId ?? cryptoRandomString({ length: 15 });
$formData.expand!.gpx_data = gpxData;
setFields(
"category",
page.data.settings.category || $categories[0].id,
);
setFields(
"public",
page.data.settings?.privacy?.trails === "public",
);
// const log = new SummitLog(parseResult.trail.date as string, {
// distance: $formData.distance,
// elevation_gain: $formData.elevation_gain,
// elevation_loss: $formData.elevation_loss,
// duration: $formData.duration
// ? $formData.duration * 60
// : undefined,
// });
// log.expand!.gpx_data = gpxData;
// const blob = new Blob([gpxData], { type: selectedFile.type });
// log._gpx = new File([blob], selectedFile.name, {
// type: selectedFile.type,
// });
// $formData.expand!.summit_logs?.push(log);
if (parseResult.gpx.rte?.length && !parseResult.gpx.trk) {
parseResult.gpx.trk = [
{
trkseg: [
{
trkpt: parseResult.gpx.rte?.at(0)?.rtept,
},
],
},
];
parseResult.gpx.rte = undefined;
}
setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx);
} catch (e) {
console.error(e);
show_toast({
icon: "close",
type: "error",
text: $_("error-reading-file"),
});
return;
}
const r = await searchLocationReverse($formData.lat!, $formData.lon!);
if (r) {
setFields("location", r);
}
}
function clearWaypoints() {
for (const waypoint of $formData.expand!.waypoints ?? []) {
waypoint.marker?.remove();
}
$formData.expand!.waypoints = [];
$formData.waypoints = [];
}
function clearAnchorMarker() {
for (const anchor of anchors) {
anchor.marker?.remove();
}
}
function initRouteAnchors(gpx: GPX) {
const segments = gpx.trk?.at(0)?.trkseg ?? [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const points = segment.trkpt ?? [];
if (points.length > 0) {
addAnchor(
points[0].$.lat!,
points[0].$.lon!,
anchors.length,
false,
);
}
if (i == segments.length - 1) {
addAnchor(
points[points.length - 1].$.lat!,
points[points.length - 1].$.lon!,
anchors.length,
false,
);
}
}
}
function openMarkerPopup(waypoint: Waypoint) {
waypoint.marker?.togglePopup();
}
function handleWaypointMenuClick(
currentWaypoint: Waypoint,
index: number,
item: DropdownItem,
) {
if (item.value === "edit") {
waypoint.set(currentWaypoint);
waypointModal.openModal();
} else if (item.value === "delete") {
currentWaypoint.marker?.remove();
deleteWaypoint(index);
}
}
function beforeWaypointModalOpen(lat?: number, lon?: number) {
if (!map) {
return;
}
const mapCenter = map.getCenter();
waypoint.set(new Waypoint(lat ?? mapCenter.lat, lon ?? mapCenter.lng));
waypointModal.openModal();
}
function deleteWaypoint(index: number) {
const wp = $formData.expand!.waypoints?.splice(index, 1);
$formData.waypoints.splice(index, 1);
if (!$formData.expand!.waypoints?.length) {
$formData.expand!.waypoints = [];
}
$formData.expand!.waypoints = $formData.expand!.waypoints;
// updateTrailOnMap();
}
function saveWaypoint(savedWaypoint: Waypoint) {
let editedWaypointIndex =
$formData.expand!.waypoints?.findIndex(
(s) => s.id == savedWaypoint.id,
) ?? -1;
if (editedWaypointIndex >= 0) {
$formData.expand!.waypoints![editedWaypointIndex] = savedWaypoint;
} else {
savedWaypoint.id = cryptoRandomString({ length: 15 });
$formData.expand!.waypoints = [
...($formData.expand!.waypoints ?? []),
savedWaypoint,
];
// updateTrailOnMap();
}
}
function moveMarker(marker: M.Marker, wpId?: string) {
const position = marker.getLngLat();
const editableWaypointIndex =
$formData.expand!.waypoints?.findIndex((w) => w.id == wpId) ?? -1;
const editableWaypoint =
$formData.expand!.waypoints![editableWaypointIndex];
if (!editableWaypoint) {
return;
}
editableWaypoint.lat = position.lat;
editableWaypoint.lon = position.lng;
$formData.expand!.waypoints = [...($formData.expand!.waypoints ?? [])];
// updateTrailOnMap();
}
function beforeSummitLogModalOpen() {
const newSummitLog = new SummitLog(
new Date().toISOString().split("T")[0],
);
newSummitLog.author = $currentUser?.actor;
summitLog.set(newSummitLog);
summitLogModal.openModal();
}
function saveSummitLog(log: SummitLog) {
let editedSummitLogIndex =
$formData.expand!.summit_logs_via_trail?.findIndex(
(s) => s.id == log.id,
);
if ((editedSummitLogIndex ?? -1) >= 0) {
$formData.expand!.summit_logs_via_trail![editedSummitLogIndex!] =
log;
} else {
log.id = cryptoRandomString({ length: 15 });
$formData.expand!.summit_logs_via_trail = [
...($formData.expand!.summit_logs_via_trail ?? []),
log,
];
}
}
function handleSummitLogMenuClick(
currentSummitLog: SummitLog,
index: number,
item: DropdownItem,
) {
if (item.value === "edit") {
summitLog.set(currentSummitLog);
summitLogModal.openModal();
} else if (item.value === "delete") {
$formData.expand!.summit_logs_via_trail?.splice(index, 1);
$formData.expand!.summit_logs_via_trail =
$formData.expand!.summit_logs_via_trail;
}
}
async function handleListSelection(list: List) {
if (!$formData.id) {
return;
}
try {
if (list.trails?.includes($formData.id!)) {
list = await lists_remove_trail(list, $formData as Trail);
} else {
list = await lists_add_trail(list, $formData as Trail);
}
const index = lists.items.findIndex((l) => l.id == list.id);
if (index >= 0) {
lists.items[index] = list;
}
// await lists_index({ q: "", author: $currentUser?.id ?? "" }, 1, -1);
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: "Error adding trail to list.",
});
}
}
function startDrawing() {
if (!map) {
return;
}
drawingActive = true;
if (!route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.length) {
}
for (const anchor of anchors) {
anchor.marker?.addTo(map);
}
}
async function stopDrawing() {
drawingActive = false;
for (const anchor of anchors) {
anchor.marker?.remove();
}
if (route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)) {
$formData.lat = route.trk
?.at(0)
?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lat;
$formData.lon = route.trk
?.at(0)
?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lon;
}
const r = await searchLocationReverse($formData.lat!, $formData.lon!);
if (r) {
setFields("location", r);
}
}
async function handleMapClick(e: M.MapMouseEvent) {
if (!drawingActive) {
if (
(
e.originalEvent.target as HTMLElement
).tagName.toLowerCase() !== "canvas"
) {
return;
}
mapPopup?.remove();
mapPopup = createEditTrailMapPopup(e.lngLat, () => {
mapPopup?.remove();
beforeWaypointModalOpen(e.lngLat.lat, e.lngLat.lng);
});
mapPopup.addTo(map!);
} else {
const anchorCount = anchors.length;
if (anchorCount == 0) {
addAnchor(e.lngLat.lat, e.lngLat.lng, anchors.length);
} else {
await addAnchorAndRecalculate(e.lngLat.lat, e.lngLat.lng);
}
}
}
async function addAnchorAndRecalculate(lat: number, lon: number) {
const previousAnchor = anchors[anchors.length - 1];
const anchor = addAnchor(lat, lon, anchors.length);
const markerText = startAnchorLoading(anchor);
try {
const routeWaypoints = await calculateRouteBetween(
previousAnchor.lat,
previousAnchor.lon,
lat,
lon,
routingOptions,
);
insertIntoRoute(routeWaypoints);
updateTrailWithRouteData();
normalizeRouteTime();
} catch (e) {
console.error(e);
show_toast({
text: "Error calculating route",
icon: "close",
type: "error",
});
} finally {
stopAnchorLoading(anchor, markerText);
}
}
function addAnchor(
lat: number,
lon: number,
index: number,
addtoMap: boolean = true,
) {
const anchor: ValhallaAnchor = {
id: cryptoRandomString({ length: 15 }),
lat: lat,
lon: lon,
};
const marker = createAnchorMarker(
lat,
lon,
index + 1,
() => {
removeAnchor(anchors.findIndex((a) => a.id == anchor.id));
},
() => {
const thisAnchor = anchors.find((a) => a.id == anchor.id);
addAnchorAndRecalculate(
thisAnchor?.lat ?? lat,
thisAnchor?.lon ?? lon,
);
marker.togglePopup();
},
(e) => {
draggingMarker = true;
},
async (_) => {
if (!drawingActive) {
return;
}
const position = marker.getLngLat();
anchor.lat = position.lat;
anchor.lon = position.lng;
await recalculateRoute(
anchors.findIndex((a) => a.id == anchor.id),
);
draggingMarker = false;
},
);
if (addtoMap && map) {
marker.addTo(map);
}
anchor.marker = marker;
anchors.splice(index, 0, anchor);
return anchor;
}
function startAnchorLoading(anchor: ValhallaAnchor) {
const markerIcon = anchor.marker?.getElement();
if (!markerIcon) {
return null;
}
markerIcon.classList.add("spinner", "spinner-light", "spinner-small");
const savedMarkerNumber = markerIcon.textContent;
markerIcon.textContent = "";
return savedMarkerNumber;
}
function stopAnchorLoading(anchor: ValhallaAnchor, index: string | null) {
const markerIcon = anchor.marker?.getElement();
if (!markerIcon || !index) {
return;
}
markerIcon.classList.remove(
"spinner",
"spinner-light",
"spinner-small",
);
markerIcon.textContent = index;
}
async function removeAnchor(anchorIndex: number) {
if (!drawingActive) {
return;
}
anchors[anchorIndex]?.marker?.remove();
anchors.splice(anchorIndex, 1);
for (let i = anchorIndex; i < anchors.length; i++) {
const anchor = anchors[i];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
const markerText = markerIcon.textContent ?? "0";
const markerIndex = parseInt(markerText);
const newIndex = markerIndex - 1;
markerIcon.textContent = newIndex + "";
anchor
.marker!.getPopup()
._content.getElementsByTagName("h5")[0].textContent =
$_("route-point") + " #" + newIndex;
}
}
if (anchorIndex == 0) {
deleteFromRoute(anchorIndex);
if ($formData.expand?.gpx_data) {
updateTrailWithRouteData();
}
} else if (anchorIndex == anchors.length) {
deleteFromRoute(anchorIndex - 1);
updateTrailWithRouteData();
} else {
deleteFromRoute(anchorIndex - 1);
await recalculateRoute(anchorIndex);
}
}
async function recalculateRoute(anchorIndex: number) {
const markerText = startAnchorLoading(anchors[anchorIndex]);
const anchor = anchors[anchorIndex];
if (!anchor) {
return;
}
let nextRouteSegment;
let previousRouteSegment;
try {
if (anchorIndex < anchors.length - 1) {
const nextAnchor = anchors[anchorIndex + 1];
nextRouteSegment = await calculateRouteBetween(
anchor.lat,
anchor.lon,
nextAnchor.lat,
nextAnchor.lon,
routingOptions,
);
}
if (anchorIndex > 0) {
const previousAnchor = anchors[anchorIndex - 1];
previousRouteSegment = await calculateRouteBetween(
previousAnchor.lat,
previousAnchor.lon,
anchor.lat,
anchor.lon,
routingOptions,
);
}
if (nextRouteSegment) {
editRoute(anchorIndex, nextRouteSegment);
}
if (previousRouteSegment) {
editRoute(anchorIndex - 1, previousRouteSegment);
}
if ($formData.expand?.gpx_data) {
updateTrailWithRouteData();
}
normalizeRouteTime();
} catch (e) {
console.error(e);
show_toast({
text: "Error calculating route",
icon: "close",
type: "error",
});
} finally {
stopAnchorLoading(anchors[anchorIndex], markerText);
}
}
async function handleSegmentDragEnd(data: {
segment: number;
event: M.MapMouseEvent;
}) {
if (draggingMarker) {
return;
}
const anchor = addAnchor(
data.event.lngLat.lat,
data.event.lngLat.lng,
data.segment + 1,
);
const markerText = startAnchorLoading(anchor);
for (let i = data.segment + 2; i < anchors.length; i++) {
const anchor = anchors[i];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
const markerText = markerIcon.textContent ?? "0";
const markerIndex = parseInt(markerText);
const newIndex = markerIndex + 1;
markerIcon.textContent = newIndex + "";
anchor
.marker!.getPopup()
._content.getElementsByTagName("h5")[0].textContent =
$_("route-point") + " #" + newIndex;
}
}
const previousAnchor = anchors[data.segment];
const nextAnchor = anchors[data.segment + 2];
try {
const previousRouteSegment = await calculateRouteBetween(
previousAnchor.lat,
previousAnchor.lon,
anchor.lat,
anchor.lon,
routingOptions,
);
const nextRouteSegment = await calculateRouteBetween(
anchor.lat,
anchor.lon,
nextAnchor.lat,
nextAnchor.lon,
routingOptions,
);
editRoute(data.segment, previousRouteSegment);
insertIntoRoute(nextRouteSegment, data.segment + 1);
normalizeRouteTime();
updateTrailWithRouteData();
} catch (e) {
console.error(e);
show_toast({
text: "Error calculating route",
icon: "close",
type: "error",
});
} finally {
stopAnchorLoading(anchor, markerText);
}
}
function updateTrailWithRouteData() {
overwriteGPX = true;
const totals = route.features;
$formData.distance = totals.distance;
$formData.duration = totals.duration / 1000 / 60;
$formData.elevation_gain = totals.elevationGain;
$formData.elevation_loss = totals.elevationLoss;
$formData.expand!.gpx_data = route.toString();
if (!$formData.id) {
$formData.id = cryptoRandomString({ length: 15 });
}
}
function updateTrailOnMap() {
mapTrail = [$formData as Trail];
}
function handleSearchClick(item: SearchItem) {
map?.flyTo({
center: [item.value.lon, item.value.lat],
zoom: 13,
animate: false,
});
}
async function searchCities(q: string) {
const r = await searchLocations(q);
searchDropdownItems = r.map((h) => ({
text: h.name,
description: h.description,
value: h,
icon: getIconForLocation(h),
}));
}
let gpxData = $derived($formData.expand?.gpx_data);
$effect(() => {
if (gpxData) {
untrack(() => updateTrailOnMap());
}
});
function getTrailTags() {
return (
$formData.expand?.tags?.map((t) => ({
text: t.name,
value: t,
})) ?? []
);
}
function setTrailTags(items: ComboboxItem[]) {
$formData.expand!.tags = items.map((i) =>
i.value ? i.value : new Tag(i.text),
);
}
async function searchTags(q: string) {
const result = await tags_index(q);
tagItems = result.items.map((t) => ({ text: t.name, value: t }));
}
function openPhotoBrowser() {
document.getElementById("waypoint-photo-input")!.click();
}
async function handleWaypointPhotoSelection() {
const files = (
document.getElementById("waypoint-photo-input") as HTMLInputElement
).files;
if (!files) {
return;
}
for (const file of files) {
const coords = await new Promise<number[]>((resolve) => {
EXIF.getData(file, function (p) {
const lat = EXIF.getTag(p, "GPSLatitude");
const latDir = EXIF.getTag(p, "GPSLatitudeRef");
const lon = EXIF.getTag(p, "GPSLongitude");
const lonDir = EXIF.getTag(p, "GPSLongitudeRef");
if (lat && lon) {
resolve([
convertDMSToDD(lat, latDir),
convertDMSToDD(lon, lonDir),
]);
} else {
resolve([]);
}
});
});
if (coords.length) {
const wp: Waypoint = new Waypoint(coords[0], coords[1], {
icon: "image",
});
wp._photos = [file];
saveWaypoint(wp);
} else {
show_toast(
{
type: "warning",
icon: "warning",
text: `${file.name}: ${$_("no-gps-data-in-image")}`,
},
10000,
);
}
}
}
</script>
<svelte:head>
<title
>{page.params.id !== "new"
? `${$formData.name} | ${$_("edit")}`
: $_("new-trail")} | wanderer</title
>
</svelte:head>
<main class="grid grid-cols-1 md:grid-cols-[400px_1fr]">
<form
id="trail-form"
class="overflow-y-auto overflow-x-hidden flex flex-col gap-4 px-8 order-1 md:order-none mt-8 md:mt-0"
use:form
>
<Search
onupdate={(q) => searchCities(q)}
onclick={(item) => handleSearchClick(item)}
placeholder="{$_('search-places')}..."
items={searchDropdownItems}
></Search>
<hr class="border-input-border" />
<h3 class="text-xl font-semibold">{$_("pick-a-trail")}</h3>
<Button
primary={true}
type="button"
disabled={drawingActive}
onclick={openFileBrowser}
>{$formData.expand?.gpx_data
? $_("upload-new-file")
: $_("upload-file")}</Button
>
{#if env.PUBLIC_VALHALLA_URL}
<div class="flex gap-4 items-center w-full">
<hr class="basis-full border-input-border" />
<span class="text-gray-500 uppercase">{$_("or")}</span>
<hr class="basis-full border-input-border" />
</div>
<button
class="btn-primary"
type="button"
onclick={drawingActive ? stopDrawing : startDrawing}
>
{$formData.expand?.gpx_data
? drawingActive
? $_("stop-editing")
: $_("edit-route")
: drawingActive
? $_("stop-drawing")
: $_("draw-a-route")}</button
>
{/if}
<input
type="file"
name="gpx"
id="fileInput"
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.kmz,.KMZ,.fit,.FIT"
style="display: none;"
onchange={handleFileSelection}
/>
<hr class="border-separator" />
<div class="flex gap-x-2">
<h3 class="text-xl font-semibold">{$_("basic-info")}</h3>
<button
aria-label="Edit basic info"
type="button"
class="btn-icon"
style="font-size: 0.9rem"
onclick={() => (editingBasicInfo = !editingBasicInfo)}
><i class="fa fa-{editingBasicInfo ? 'check' : 'pen'}"
></i></button
>
</div>
<fieldset
class="grid grid-cols-2 gap-4 justify-around"
data-felte-keep-on-remove
>
{#if editingBasicInfo}
<TextField
bind:value={$formData.distance}
name="distance"
label={$_("distance")}
></TextField>
<TextField
bind:value={$formData.duration}
name="duration"
label={$_("est-duration")}
></TextField><TextField
bind:value={$formData.elevation_gain}
name="elevation_gain"
label={$_("elevation-gain")}
></TextField>
<TextField
bind:value={$formData.elevation_loss}
name="elevation_loss"
label={$_("elevation-loss")}
></TextField>
{:else}
<div>
<p>{$_("distance")}</p>
<span class="font-medium"
>{formatDistance($formData.distance)}</span
>
<input
type="hidden"
name="distance"
value={$formData.distance}
/>
</div>
<div>
<p>{$_("est-duration")}</p>
<span class="font-medium"
>{formatTimeHHMM($formData.duration)}</span
>
<input
type="hidden"
name="duration"
value={$formData.duration}
/>
</div>
<div>
<p>{$_("elevation-gain")}</p>
<span class="font-medium"
>{formatElevation($formData.elevation_gain)}</span
>
<input
type="hidden"
name="elevation_gain"
value={$formData.elevation_gain}
/>
</div>
<div>
<p>{$_("elevation-loss")}</p>
<span class="font-medium"
>{formatElevation($formData.elevation_loss)}</span
>
<input
type="hidden"
name="elevation_gain"
value={$formData.elevation_gain}
/>
</div>
{/if}
</fieldset>
<TextField name="name" label={$_("name")} error={$errors.name}
></TextField>
<TextField
name="location"
label={$_("location")}
error={$errors.location}
></TextField>
<Datepicker label={$_("date")} bind:value={$formData.date}></Datepicker>
<Editor
extraClasses="min-h-24"
bind:value={$formData.description}
label={$_("describe-your-trail")}
></Editor>
<Combobox
bind:value={getTrailTags, setTrailTags}
onupdate={searchTags}
items={tagItems}
label={$_("tags")}
multiple
chips
></Combobox>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-4">
<Select
name="difficulty"
label={$_("difficulty")}
items={[
{ text: $_("easy"), value: "easy" },
{ text: $_("moderate"), value: "moderate" },
{ text: $_("difficult"), value: "difficult" },
]}
></Select>
<Select
name="category"
label={$_("category")}
items={$categories.map((c) => ({
text: $_(c.name),
value: c.id,
}))}
></Select>
</div>
<Toggle
name="public"
label={$formData.public ? $_("public") : $_("private")}
icon={$formData.public ? "globe" : "lock"}
></Toggle>
<hr class="border-separator" />
<h3 class="text-xl font-semibold">
{$_("waypoints", { values: { n: 2 } })}
</h3>
<ul>
{#each $formData.expand?.waypoints ?? [] as waypoint, i}
<li
onmouseenter={() => openMarkerPopup(waypoint)}
onmouseleave={() => openMarkerPopup(waypoint)}
>
<WaypointCard
{waypoint}
mode="edit"
onchange={(item) =>
handleWaypointMenuClick(waypoint, i, item)}
></WaypointCard>
</li>
{/each}
</ul>
<button
class="btn-secondary"
type="button"
onclick={() => beforeWaypointModalOpen()}
><i class="fa fa-plus mr-2"></i>{$_("add-waypoint")}</button
>
<button
class="btn-secondary"
type="button"
onclick={() => openPhotoBrowser()}
><i class="fa fa-image mr-2"></i>{$_("from-photos")}</button
>
<input
type="file"
id="waypoint-photo-input"
accept="image/*"
multiple={true}
style="display: none;"
onchange={() => handleWaypointPhotoSelection()}
/>
<hr class="border-separator" />
<h3 class="text-xl font-semibold">{$_("photos")}</h3>
<PhotoPicker
id="trail"
parent={$formData}
bind:photos={$formData.photos}
bind:thumbnail={$formData.thumbnail}
bind:photoFiles
></PhotoPicker>
<hr class="border-separator" />
<h3 class="text-xl font-semibold">{$_("summit-book")}</h3>
<ul>
{#each $formData.expand?.summit_logs_via_trail ?? [] as log, i}
<li>
<SummitLogCard
{log}
mode={log.author == $currentUser?.actor
? "edit"
: "show"}
onchange={(item) =>
handleSummitLogMenuClick(log, i, item)}
></SummitLogCard>
</li>
{/each}
</ul>
<button
class="btn-secondary"
type="button"
onclick={beforeSummitLogModalOpen}
><i class="fa fa-plus mr-2"></i>{$_("add-entry")}</button
>
{#if lists.items.length}
<hr class="border-separator" />
<h3 class="text-xl font-semibold">
{$_("list", { values: { n: 2 } })}
</h3>
<div class="flex gap-4 flex-wrap">
{#each lists.items as list}
{#if $formData.id && list.trails?.includes($formData.id)}
<div
class="flex gap-2 items-center border border-input-border rounded-xl p-2"
>
<img
class="w-8 aspect-square rounded-full object-cover"
src={list.avatar
? getFileURL(list, list.avatar)
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark}
alt="avatar"
/>
<span class="text-sm">{list.name}</span>
</div>
{/if}
{/each}
</div>
<Button
secondary={true}
tooltip={$_("save-your-trail-first")}
disabled={page.params.id == "new" && !savedAtLeastOnce}
type="button"
onclick={() => listSelectModal.openModal()}
><i class="fa fa-plus mr-2"></i>{$_("add-to-list")}</Button
>
{/if}
<hr class="border-separator" />
<Button
primary={true}
large={true}
type="submit"
extraClasses="mb-2"
{loading}>{$_("save-trail")}</Button
>
</form>
<div class="relative">
{#if drawingActive}
<div
in:scale={{ easing: backInOut }}
out:scale={{ easing: backInOut }}
class="absolute top-0 left-16 z-50"
>
<RoutingOptionsPopup bind:options={routingOptions}
></RoutingOptionsPopup>
</div>
{/if}
<div id="trail-map">
<MapWithElevationMaplibre
trails={mapTrail}
waypoints={$formData.expand?.waypoints}
drawing={drawingActive}
showTerrain={true}
onmarkerdragend={moveMarker}
activeTrail={0}
bind:map
onclick={(target) => handleMapClick(target)}
onsegmentdragend={(data) => handleSegmentDragEnd(data)}
mapOptions={{ preserveDrawingBuffer: true }}
></MapWithElevationMaplibre>
</div>
</div>
</main>
<WaypointModal bind:this={waypointModal} onsave={saveWaypoint}></WaypointModal>
<SummitLogModal bind:this={summitLogModal} onsave={(log) => saveSummitLog(log)}
></SummitLogModal>
<ListSelectModal
lists={lists.items}
bind:this={listSelectModal}
onchange={(e) => handleListSelection(e)}
></ListSelectModal>
<style>
#trail-map {
height: calc(50vh);
}
@media only screen and (min-width: 768px) {
#trail-map,
form {
height: calc(100vh - 124px);
}
}
</style>