Migrates to svelte 5

This commit is contained in:
Christian Beutel
2025-01-24 17:46:13 +01:00
parent 7571f6e9b9
commit 3721b7f537
106 changed files with 5524 additions and 4232 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from "$app/stores";
import { page } from "$app/state";
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
import type { Settings } from "$lib/models/settings";
import type { Trail } from "$lib/models/trail";
@@ -19,34 +19,72 @@
import type { GeoJSON } from "geojson";
import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { createEventDispatcher, onDestroy, onMount } from "svelte";
import { onDestroy, onMount, untrack } from "svelte";
export let trails: Trail[] = [];
export let waypoints: Waypoint[] = [];
export let markers: M.Marker[] = [];
export let map: M.Map | null = null;
export let drawing: boolean = false;
export let showElevation: boolean = true;
export let showInfoPopup: boolean = false;
export let showGrid: boolean = false;
export let showStyleSwitcher: boolean = true;
export let showFullscreen: boolean = false;
export let showTerrain: boolean = false;
export let fitBounds: "animate" | "instant" | "off" = "instant";
export let onMarkerDragEnd:
| ((marker: M.Marker, wpId?: string) => void)
| undefined = undefined;
interface Props {
trails?: Trail[];
waypoints?: Waypoint[];
markers?: M.Marker[];
map?: M.Map | null;
drawing?: boolean;
showElevation?: boolean;
showInfoPopup?: boolean;
showGrid?: boolean;
showStyleSwitcher?: boolean;
showFullscreen?: boolean;
showTerrain?: boolean;
fitBounds?: "animate" | "instant" | "off";
onmarkerdragend?:
| ((marker: M.Marker, wpId?: string) => void)
| undefined;
elevationProfileContainer?: string | HTMLDivElement | undefined;
mapOptions?: Partial<M.MapOptions> | undefined;
activeTrail?: number | null;
minZoom?: number;
onsegmentdragend?: (data: {
segment: number;
event: M.MapMouseEvent;
}) => void;
onselect?: (trail: Trail) => void;
onunselect?: (trail: Trail) => void;
onfullscreen?: () => void;
onmoveend?: (map: M.Map) => void;
onzoom?: (map: M.Map) => void;
onclick?: (event: M.MapMouseEvent & Object) => void;
oninit?: (map: M.Map) => void;
}
export let elevationProfileContainer: string | HTMLDivElement | undefined =
undefined;
export let mapOptions: Partial<M.MapOptions> | undefined = undefined;
export let activeTrail: number | null = null;
export let minZoom: number = 0;
let {
trails = [],
waypoints = [],
markers = $bindable([]),
map = $bindable(),
drawing = false,
showElevation = true,
showInfoPopup = false,
showGrid = false,
showStyleSwitcher = true,
showFullscreen = false,
showTerrain = false,
fitBounds = "instant",
elevationProfileContainer = undefined,
mapOptions = undefined,
activeTrail = $bindable(0),
minZoom = 0,
onmarkerdragend,
onsegmentdragend,
onselect,
onunselect,
onfullscreen,
onmoveend,
onzoom,
onclick,
oninit,
}: Props = $props();
let mapContainer: HTMLDivElement;
let epc: ElevationProfileControl | null = null;
let graticule: MaplibreGraticule | null = null;
let epc: ElevationProfileControl;
let graticule: MaplibreGraticule;
let layers: Record<
string,
@@ -71,8 +109,6 @@
let hoveringTrail: boolean = false;
const dispatch = createEventDispatcher();
const trailColors = [
"#3549BB",
"#592E9E",
@@ -86,78 +122,76 @@
"#DE7DC5",
];
$: data = getData(trails);
$: if (data && map) {
initMap(map.loaded());
}
$: if (activeTrail !== null && trails[activeTrail] !== undefined) {
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
focusTrail(trails[activeTrail]);
let data = $derived(getData(trails));
$effect(() => {
if (data && map) {
untrack(() => initMap(map?.loaded() ?? false));
}
} else if (activeTrail === null && trails.length) {
unFocusTrail();
}
$: if ($theme == "dark") {
epc?.toggleTheme({
profileBackgroundColor: "#191b24",
elevationGridColor: "#ddd2",
labelColor: "#ddd8",
crosshairColor: "#fff5",
tooltipBackgroundColor: "#242734",
tooltipTextColor: "#fff",
});
} else {
epc?.toggleTheme({
profileBackgroundColor: "#242734",
elevationGridColor: "#0002",
labelColor: "#0009",
crosshairColor: "#0005",
tooltipBackgroundColor: "#fff",
tooltipTextColor: "#000",
});
}
$: if (drawing && map) {
startDrawing();
} else if (!drawing && map) {
stopDrawing();
}
$: if (showGrid) {
if (!graticule) {
graticule = new MaplibreGraticule({
minZoom: 0,
maxZoom: 20,
showLabels: true,
labelType: "hdms",
labelSize: 10,
labelColor: "#858585",
longitudePosition: "top",
latitudePosition: "right",
paint: {
"line-opacity": 0.8,
"line-color": "rgba(0,0,0,0.2)",
},
});
$effect(() => {
adjustTrailFocus(activeTrail);
});
$effect(() => {
if ($theme == "dark") {
epc?.toggleTheme({
profileBackgroundColor: "#191b24",
elevationGridColor: "#ddd2",
labelColor: "#ddd8",
crosshairColor: "#fff5",
tooltipBackgroundColor: "#242734",
tooltipTextColor: "#fff",
});
} else {
epc?.toggleTheme({
profileBackgroundColor: "#242734",
elevationGridColor: "#0002",
labelColor: "#0009",
crosshairColor: "#0005",
tooltipBackgroundColor: "#fff",
tooltipTextColor: "#000",
});
}
map?.addControl(graticule);
} else {
if (graticule) {
map?.removeControl(graticule);
});
$effect(() => {
if (drawing && map) {
startDrawing();
} else if (!drawing && map) {
stopDrawing();
}
}
$: if (waypoints) {
showWaypoints();
refreshElevationProfile();
}
});
$effect(() => {
if (showGrid) {
if (!graticule) {
graticule = new MaplibreGraticule({
minZoom: 0,
maxZoom: 20,
showLabels: true,
labelType: "hdms",
labelSize: 10,
labelColor: "#858585",
longitudePosition: "top",
latitudePosition: "right",
paint: {
"line-opacity": 0.8,
"line-color": "rgba(0,0,0,0.2)",
},
});
}
map?.addControl(graticule);
} else {
if (graticule) {
map?.removeControl(graticule);
}
}
});
$effect(() => {
if (waypoints) {
untrack(() => {
showWaypoints();
refreshElevationProfile();
});
}
});
function getData(trails: Trail[]) {
if (!trails.length) {
@@ -168,7 +202,7 @@
trails.forEach((t) => {
if (t.expand?.gpx_data) {
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
} else if (t.lat && t.lon) {
} else if (t.lat !== null && t.lon !== null) {
r.push({
id: "",
type: "Feature",
@@ -212,7 +246,7 @@
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
if (activeTrail !== null && mapLoaded) {
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
focusTrail(trails[activeTrail]);
} else {
flyToBounds();
@@ -246,7 +280,7 @@
return new M.LngLatBounds([minX, minY, maxX, maxY]);
}
function flyToBounds() {
function flyToBounds() {
const bounds =
activeTrail !== null && data[activeTrail]
? (data[activeTrail].bbox as M.LngLatBoundsLike)
@@ -325,7 +359,7 @@
createEmptyLayer(id);
if (!layers[id].source) {
if (!layers[id].source || !map.getSource(id)) {
try {
map.addSource(id, {
type: "geojson",
@@ -339,7 +373,7 @@
layers[id].source.setData(geojson);
}
if (!layers[id].layer) {
if (!layers[id].layer || !map.getLayer(id)) {
map.addLayer({
id: id,
type: "line",
@@ -407,12 +441,12 @@
function handleDragEnd(e: M.MapMouseEvent) {
map?.off("mousemove", moveElevationMarkerToCursorPosition);
epc?.hideCrosshair();
dispatch("segmentDragEnd", { segment: draggingSegment, event: e });
onsegmentdragend?.({ segment: draggingSegment!, event: e });
draggingSegment = null;
}
function addCaretLayer(id?: string) {
if (!map || !id) {
if (!map || !id || !map.getSource(id)) {
return;
}
if (map.getLayer("direction-carets")) {
@@ -459,11 +493,16 @@
id: string,
showElevationMarker: boolean = false,
) {
if (!id) {
return;
}
if (showElevationMarker) {
elevationMarker.setOpacity("1");
}
map?.setPaintProperty(id, "line-width", 7);
hoveringTrail = true;
if (map?.getLayer(id)) {
hoveringTrail = true;
}
// map?.setPaintProperty(id, "line-color", "#2766e3");
}
@@ -474,17 +513,33 @@
elevationMarker.setOpacity("0");
epc?.hideCrosshair();
hoveringTrail = false;
map?.setPaintProperty(id, "line-width", 5);
if (map?.getLayer(id)) {
map?.setPaintProperty(id, "line-width", 5);
}
// map?.setPaintProperty(id, "line-color", "#648ad5");
}
function adjustTrailFocus(activeTrail: number | null) {
if (activeTrail !== null && trails[activeTrail] !== undefined) {
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
untrack(() => focusTrail(trails[activeTrail]));
}
} else if (activeTrail === null && trails.length) {
untrack(() => unFocusTrail());
}
}
function focusTrail(trail: Trail) {
activeTrail = trails.findIndex((t) => t.id == trail.id);
if (activeTrail < 0) {
activeTrail = null;
return;
}
dispatch("select", trail);
onselect?.(trail);
try {
refreshElevationProfile();
@@ -501,7 +556,7 @@
function unFocusTrail(trail?: Trail) {
if (trail) {
dispatch("unselect", trail);
onunselect?.(trail);
unHighlightTrail(trail.id!);
}
@@ -617,9 +672,14 @@
hideWaypoints();
for (const waypoint of waypoints) {
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd);
marker.addTo(map);
markers.push(marker);
if (!markers.find((m) => m._element.id == waypoint.id)) {
const marker = createMarkerFromWaypoint(
waypoint,
onmarkerdragend,
);
marker.addTo(map);
markers.push(marker);
}
}
}
@@ -630,6 +690,7 @@
for (const m of markers) {
m.remove();
}
markers = [];
}
onMount(async () => {
@@ -646,7 +707,7 @@
const mapStyles: { text: string; value: string; thumbnail?: string }[] =
[
...(($page.data.settings as Settings)?.tilesets ?? []).map(
...((page.data.settings as Settings)?.tilesets ?? []).map(
(t) => ({
text: t.name,
value: t.url,
@@ -683,6 +744,10 @@
preferredMapStyleIndex = 0;
}
if (!mapContainer) {
return;
}
const finalMapOptions: M.MapOptions = {
...{
container: mapContainer,
@@ -730,7 +795,7 @@
map.addControl(
new M.ScaleControl({
maxWidth: 120,
unit: $page.data.settings?.unit ?? "metric",
unit: page.data.settings?.unit ?? "metric",
}),
"top-left",
);
@@ -741,7 +806,7 @@
enableHighAccuracy: true,
},
fitBoundsOptions: {
animate: fitBounds == "animate"
animate: fitBounds == "animate",
},
trackUserLocation: true,
}),
@@ -757,7 +822,7 @@
profileBackgroundColor:
$theme == "light" ? "#242734" : "#191b24",
backgroundColor: "bg-menu-background/90",
unit: $page.data.settings?.unit ?? "metric",
unit: page.data.settings?.unit ?? "metric",
profileLineWidth: 3,
displayDistanceGrid: true,
tooltipDisplayDPlus: false,
@@ -785,7 +850,7 @@
if (showFullscreen) {
map.addControl(
new FullscreenControl(() => {
dispatch("fullscreen");
onfullscreen?.();
}),
"bottom-right",
);
@@ -803,21 +868,21 @@
if (showTerrain) {
try {
if (
$page.data.settings?.terrain?.terrain &&
page.data.settings?.terrain?.terrain &&
!map?.getSource("terrain")
) {
map!.addSource("terrain", {
type: "raster-dem",
url: $page.data.settings?.terrain?.terrain,
url: page.data.settings?.terrain?.terrain,
});
}
if (
$page.data.settings?.terrain?.hillshading &&
page.data.settings?.terrain?.hillshading &&
!map?.getSource("hillshading")
) {
map!.addSource("hillshading", {
type: "raster-dem",
url: $page.data.settings?.terrain?.hillshading,
url: page.data.settings?.terrain?.hillshading,
});
map!.addLayer({
id: "hillshading",
@@ -830,7 +895,7 @@
});
map.on("moveend", (e) => {
dispatch("moveend", e.target);
onmoveend?.(e.target);
});
map.on("zoom", (e) => {
@@ -843,19 +908,19 @@
}
});
dispatch("zoom", e.target);
onzoom?.(e.target);
});
map.on("click", (e) => {
if (hoveringTrail && drawing) {
return;
}
dispatch("click", e);
onclick?.(e);
});
map.on("load", () => {
initMap(true);
dispatch("init", map);
oninit?.(map!);
});
showWaypoints();

View File

@@ -1,30 +1,48 @@
<script lang="ts">
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { onMount } from "svelte";
import PhotoCard from "../photo_card.svelte";
import { show_toast } from "$lib/stores/toast_store";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import PhotoCard from "../photo_card.svelte";
export let id: string;
export let photos: string[];
export let photoFiles: File[] | undefined;
export let parent: { [key: string]: any };
export let thumbnail: number = 0;
export let showThumbnailControls: boolean = true;
export let showExifControls: boolean = false;
export let maxSizeBytes = 20971520;
interface Props {
id: string;
photos: string[];
photoFiles: File[] | undefined;
parent: { [key: string]: any };
thumbnail?: number;
showThumbnailControls?: boolean;
showExifControls?: boolean;
maxSizeBytes?: number;
onexif?: (src: string) => void;
}
let photoPreviews: string[] = [];
let {
id,
photos = $bindable(),
photoFiles = $bindable(),
parent,
thumbnail = $bindable(0),
showThumbnailControls = true,
showExifControls = false,
maxSizeBytes = 20971520,
onexif,
}: Props = $props();
$: Promise.all(
(photoFiles ?? []).map(async (f) => {
return await readAsDataURLAsync(f);
}),
).then((v) => {
photoPreviews = v;
});
let photoPreviews: string[] = $state([]);
let offerUpload: boolean = false;
$effect(() => fetchPhotos(photoFiles ?? []));
function fetchPhotos(photos: File[]) {
Promise.all(
photos.map(async (f) => {
return await readAsDataURLAsync(f);
}),
).then((v) => {
photoPreviews = v;
});
}
let offerUpload: boolean = $state(false);
function handlePhotoDragOver(e: DragEvent) {
e.preventDefault();
@@ -120,14 +138,15 @@
? 'outline-dashed outline-input-border'
: ''}"
role="dialog"
on:dragover={handlePhotoDragOver}
on:dragleave={handlePhotoDragLeave}
on:drop={handlePhotoDrop}
ondragover={handlePhotoDragOver}
ondragleave={handlePhotoDragLeave}
ondrop={handlePhotoDrop}
>
<button
aria-label="Open photo browser"
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
type="button"
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button
onclick={openPhotoBrowser}><i class="fa fa-plus"></i></button
>
<input
type="file"
@@ -135,17 +154,17 @@
accept="image/*"
multiple={true}
style="display: none;"
on:change={() => handlePhotoSelection()}
onchange={() => handlePhotoSelection()}
/>
<div class="flex overflow-x-auto gap-x-3 w-full">
{#each (photos ?? []).concat(photoPreviews) as photo, i}
<div class="shrink-0 grow-0 basis-auto">
<PhotoCard
src={i >= photos.length ? photo : getFileURL(parent, photo)}
on:delete={() => handlePhotoDelete(i)}
ondelete={() => handlePhotoDelete(i)}
isThumbnail={thumbnail === i}
on:thumbnail={() => makePhotoThumbnail(i)}
on:exif
onthumbnail={() => makePhotoThumbnail(i)}
{onexif}
{showThumbnailControls}
{showExifControls}
></PhotoCard>

View File

@@ -1,4 +1,7 @@
<script lang="ts">
import { createBubbler } from "svelte/legacy";
const bubble = createBubbler();
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 type { Trail } from "$lib/models/trail";
@@ -12,25 +15,41 @@
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
import type { MouseEventHandler } from "svelte/elements";
export let trail: Trail;
export let fullWidth: boolean = false;
interface Props {
trail: Trail;
fullWidth?: boolean;
onmouseenter?: MouseEventHandler<HTMLDivElement>;
onmouseleave?: MouseEventHandler<HTMLDivElement>;
}
$: thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
let {
trail,
fullWidth = false,
onmouseenter,
onmouseleave,
}: Props = $props();
$: trailIsShared = (trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
);
let trailIsShared = $derived(
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
);
</script>
<div
class="trail-card relative rounded-2xl border border-input-border min-w-72 {fullWidth
? ''
: 'lg:w-72'} cursor-pointer"
on:mouseenter
on:mouseleave
{onmouseenter}
{onmouseleave}
role="listitem"
>
<div

View File

@@ -22,19 +22,25 @@
import { currentUser } from "$lib/stores/user_store";
import { pb } from "$lib/pocketbase";
export let trail: Trail;
export let mode: "overview" | "map" | "list";
interface Props {
trail: Trail;
mode: "overview" | "map" | "list";
}
let openConfirmModal: () => void;
let openListSelectModal: () => void;
let openExportModal: () => void;
let openShareModal: () => void;
let { trail, mode }: Props = $props();
let lists: List[] = [];
let confirmModal: ConfirmModal;
let listSelectModal: ListSelectModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
let lists: List[] = $state([]);
const allowEdit =
trail.author == $currentUser?.id ||
trail.expand?.trail_share_via_trail?.some((s) => s.permission == "edit");
trail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
);
const dropdownItems: DropdownItem[] = [
mode == "overview"
@@ -85,7 +91,7 @@
-1,
)
).items;
openListSelectModal();
listSelectModal.openModal();
} else if (item.value == "direction") {
window
.open(
@@ -96,13 +102,13 @@
} else if (item.value == "print") {
goto(`/map/trail/${trail.id}/print`);
} else if (item.value == "share") {
openShareModal();
trailShareModal.openModal();
} else if (item.value == "download") {
openExportModal();
trailExportModal.openModal();
} else if (item.value == "edit") {
goto(`/trail/edit/${trail.id}`);
} else if (item.value == "delete") {
openConfirmModal();
confirmModal.openModal();
}
}
@@ -164,7 +170,7 @@
}
async function deleteTrail() {
trails_delete(trail).then(() => history.back());
trails_delete(trail).then(() => goto('/trails'));
}
async function handleListSelection(list: List) {
@@ -196,30 +202,30 @@
}
</script>
<Dropdown
items={dropdownItems}
on:change={(e) => handleDropdownClick(e.detail)}
let:toggleMenu={openDropdown}
><button
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
on:click={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button></Dropdown
>
<Dropdown items={dropdownItems} onchange={(item) => handleDropdownClick(item)}
>{#snippet children({ toggleMenu: openDropdown })}
<button
aria-label="Open dropdown"
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-12 w-12"
onclick={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{/snippet}
</Dropdown>
<ConfirmModal
text={$_("delete-trail-confirm")}
bind:openModal={openConfirmModal}
on:confirm={deleteTrail}
bind:this={confirmModal}
onconfirm={deleteTrail}
></ConfirmModal>
<ListSelectModal
{lists}
bind:openModal={openListSelectModal}
on:change={(e) => handleListSelection(e.detail)}
bind:this={listSelectModal}
onchange={(list) => handleListSelection(list)}
></ListSelectModal>
<TrailExportModal
bind:openModal={openExportModal}
on:export={(e) => exportTrail(e.detail)}
bind:this={trailExportModal}
onexport={(settings) => exportTrail(settings)}
></TrailExportModal>
<TrailShareModal {trail} bind:openShareModal></TrailShareModal>
<TrailShareModal {trail} bind:this={trailShareModal}></TrailShareModal>

View File

@@ -1,73 +1,87 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Select from "../base/select.svelte";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export let title: string = $_("export");
interface Props {
title?: string;
onexport?: (settings: typeof exportSettings) => void;
}
const dispatch = createEventDispatcher();
let { title = $_("export"), onexport }: Props = $props();
let modal: Modal;
export function openModal() {
modal.openModal();
}
const fileFormats = [
{ text: "GPX", value: "gpx" },
{ text: "GeoJSON", value: "json" },
];
const exportSettings = {
const exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
summitLog: boolean;
} = $state({
fileFormat: "gpx",
photos: false,
summitLog: false,
};
});
function exportTrail() {
dispatch("export", exportSettings);
closeModal!();
onexport?.(exportSettings);
modal.closeModal();
}
</script>
<Modal id="export-modal" {title} size="max-w-sm" bind:openModal bind:closeModal>
<div slot="content">
<div class="mb-3">
<Select
bind:value={exportSettings.fileFormat}
items={fileFormats}
label={$_("file-format")}
></Select>
<Modal id="export-modal" {title} size="max-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<div class="mb-3">
<Select
bind:value={exportSettings.fileFormat}
items={fileFormats}
label={$_("file-format")}
></Select>
</div>
<div class="mb-2">
<input
id="include-photos-checkbox"
type="checkbox"
bind:checked={exportSettings.photos}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-photos-checkbox" class="ms-2 text-sm"
>{$_("photos")}</label
>
</div>
<div class="mb-2">
<input
id="include-summit-log-checkbox"
type="checkbox"
bind:checked={exportSettings.summitLog}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
>{$_("summit-book")}</label
>
</div>
</div>
<div class="mb-2">
<input
id="include-photos-checkbox"
type="checkbox"
bind:checked={exportSettings.photos}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-photos-checkbox" class="ms-2 text-sm"
>{$_("photos")}</label
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
onclick={exportTrail}
name="save">{$_("export")}</button
>
</div>
<div class="mb-2">
<input
id="include-summit-log-checkbox"
type="checkbox"
bind:checked={exportSettings.summitLog}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
>{$_("summit-book")}</label
>
</div>
</div>
<div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
on:click={exportTrail}
name="save">{$_("export")}</button
>
</div></Modal
{/snippet}</Modal
>

View File

@@ -3,7 +3,6 @@
import type { TrailFilter } from "$lib/models/trail";
import { country_codes } from "$lib/util/country_code_util";
import { formatDistance, formatElevation } from "$lib/util/format_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import { slide } from "svelte/transition";
import Datepicker from "../base/datepicker.svelte";
@@ -17,18 +16,30 @@
import UserSearch from "../user_search.svelte";
import { pb } from "$lib/pocketbase";
export let categories: Category[];
export let filterExpanded: boolean = true;
export let filter: TrailFilter;
export let showTrailSearch: boolean = true;
export let showCitySearch: boolean = true;
interface Props {
categories: Category[];
filterExpanded?: boolean;
filter: TrailFilter;
showTrailSearch?: boolean;
showCitySearch?: boolean;
onupdate?: (filter: TrailFilter) => void;
}
$: categorySelectItems = categories.map((c) => ({
value: c.id,
text: c.name,
}));
let {
categories,
filterExpanded = $bindable(true),
filter = $bindable(),
showTrailSearch = true,
showCitySearch = true,
onupdate,
}: Props = $props();
const dispatch = createEventDispatcher();
let categorySelectItems = $derived(
categories.map((c) => ({
value: c.id,
text: c.name,
})),
);
const radioGroupItems: RadioItem[] = [
{ text: $_("completed"), value: "completed" },
@@ -42,12 +53,12 @@
{ text: $_("difficult"), value: "difficult" },
];
let searchDropdownItems: SearchItem[] = [];
let searchDropdownItems: SearchItem[] = $state([]);
let citySearchQuery: string = "";
let citySearchQuery: string = $state("");
async function update() {
dispatch("update", filter);
onupdate?.(filter);
}
function setCategoryFilter(categories: SelectItem[]) {
@@ -134,13 +145,14 @@
<div class="basis-full">
<Search
bind:value={filter.q}
on:update={update}
onupdate={update}
placeholder="{$_('search-trails')}..."
></Search>
</div>
<button
aria-label="Toggle filter"
class="btn-icon md:hidden"
on:click={() => (filterExpanded = !filterExpanded)}
onclick={() => (filterExpanded = !filterExpanded)}
><i class="fa fa-sliders"></i></button
>
</div>
@@ -152,8 +164,10 @@
<hr class="my-4 border-separator" />
{/if}
<MultiSelect
on:change={(e) => setCategoryFilter(e.detail)}
value={categorySelectItems.filter(i => filter.category.includes(i.value))}
onchange={(value) => setCategoryFilter(value)}
value={categorySelectItems.filter((i) =>
filter.category.includes(i.value),
)}
label={$_("categories")}
items={categorySelectItems}
placeholder={`${$_("filter-categories")}...`}
@@ -161,8 +175,8 @@
<hr class="my-4 border-separator" />
{#if pb.authStore.model}
<UserSearch
on:click={(e) => setAuthorFilter(e.detail)}
on:clear={() => {
onclick={(item) => setAuthorFilter(item)}
onclear={() => {
filter.author = "";
update();
}}
@@ -175,7 +189,7 @@
type="checkbox"
checked={filter.public}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setPublicFilter}
onchange={setPublicFilter}
/>
<label for="public-checkbox" class="ms-2 text-sm"
>{$_("public")}</label
@@ -187,7 +201,7 @@
type="checkbox"
checked={filter.shared}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
on:change={setSharedFilter}
onchange={setSharedFilter}
/>
<label for="shared-checkbox" class="ms-2 text-sm"
>{$_("shared")}</label
@@ -196,7 +210,7 @@
<hr class="my-4 border-separator" />
{/if}
<MultiSelect
on:change={(e) => setDifficultyFilter(e.detail)}
onchange={(value) => setDifficultyFilter(value)}
label={$_("difficulty")}
items={difficultyItems}
placeholder={`${$_("filter-difficulty")}...`}
@@ -210,14 +224,14 @@
placeholder="{$_('search-cities')}..."
clearAfterSelect={false}
bind:value={citySearchQuery}
on:update={(e) => searchCities(e.detail)}
on:click={(e) => handleSearchClick(e.detail)}
onupdate={(q) => searchCities(q)}
onclick={(item) => handleSearchClick(item)}
></Search>
</div>
<Slider
maxValue={10000}
bind:currentValue={filter.near.radius}
on:set={() => update()}
onset={() => update()}
></Slider>
<p>
<span class="text-gray-500 text-sm">{$_("radius")}:</span>
@@ -231,7 +245,7 @@
maxValue={filter.distanceLimit}
bind:currentMin={filter.distanceMin}
bind:currentMax={filter.distanceMax}
on:set={() => update()}
onset={() => update()}
></DoubleSlider>
<div class="flex justify-between">
<span>{formatDistance(filter.distanceMin)}</span>
@@ -249,7 +263,7 @@
maxValue={filter.elevationGainLimit}
bind:currentMin={filter.elevationGainMin}
bind:currentMax={filter.elevationGainMax}
on:set={() => update()}
onset={() => update()}
></DoubleSlider>
<div class="flex justify-between">
<span>{formatElevation(filter.elevationGainMin)}</span>
@@ -268,7 +282,7 @@
maxValue={filter.elevationLossLimit}
bind:currentMin={filter.elevationLossMin}
bind:currentMax={filter.elevationLossMax}
on:set={() => update()}
onset={() => update()}
></DoubleSlider>
<div class="flex justify-between">
<span>{formatElevation(filter.elevationLossMin)}</span>
@@ -287,13 +301,13 @@
name="startDate"
label={$_("after")}
bind:value={filter.startDate}
on:change={update}
onchange={update}
></Datepicker>
<Datepicker
name="endDate"
label={$_("before")}
bind:value={filter.endDate}
on:change={update}
onchange={update}
></Datepicker>
</div>
@@ -303,7 +317,7 @@
name="completed"
items={radioGroupItems}
selected={2}
on:change={(e) => setCompletedFilter(e.detail)}
onchange={(item) => setCompletedFilter(item)}
></RadioGroup>
</div>
{/if}

View File

@@ -44,10 +44,14 @@
import EmptyStateDescription from "../empty_states/empty_state_description.svelte";
import { browser } from "$app/environment";
export let trail: Trail;
export let mode: "overview" | "map" | "list" = "map";
export let markers: M.Marker[] = [];
export let activeTab = 0;
interface Props {
trail: Trail;
mode?: "overview" | "map" | "list";
markers?: M.Marker[];
activeTab?: number;
}
let { trail, mode = "map", markers = [], activeTab = 0 }: Props = $props();
const tabs = [
$_("description"),
@@ -60,24 +64,19 @@
const trailIsShared =
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
$: thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
let gallery: PhotoGallery;
let openGallery: (idx?: number) => void;
let newComment: Comment = $state({
text: "",
rating: 0,
author: "",
trail: trail.id ?? "",
});
let newComment: Comment = new Comment("", 0, "", trail.id ?? "");
let commentsLoading: boolean = activeTab == 4;
let commentCreateLoading: boolean = false;
let commentsLoading: boolean = $state(activeTab == 4);
let commentCreateLoading: boolean = $state(false);
let commentDeleteLoading: boolean = false;
$: if (browser && activeTab == 4) {
fetchComments();
}
onMount(async () => {});
function openMarkerPopup(i: number) {
@@ -144,6 +143,18 @@
comments.set(newCommentList);
commentDeleteLoading = false;
}
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
);
$effect(() => {
if (browser && activeTab == 4) {
fetchComments();
}
});
</script>
<div
@@ -180,8 +191,7 @@
</span>
{/if}
{#if trailIsShared}
<ShareInfo type="trail" subject={trail}
></ShareInfo>
<ShareInfo type="trail" subject={trail}></ShareInfo>
{/if}
</div>
{/if}
@@ -316,8 +326,8 @@
<ul>
{#each trail.expand?.waypoints ?? [] as waypoint, i}
<li
on:mouseenter={() => openMarkerPopup(i)}
on:mouseleave={() => closeMarkerPopup(i)}
onmouseenter={() => openMarkerPopup(i)}
onmouseleave={() => closeMarkerPopup(i)}
>
<WaypointCard {waypoint}></WaypointCard>
</li>
@@ -339,14 +349,14 @@
photos={trail.photos.map((p) =>
getFileURL(trail, p),
)}
bind:open={openGallery}
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 -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
on:click={() => openGallery(i)}
onclick={() => gallery.openGallery(i)}
src={getFileURL(trail, photo)}
alt=""
/>
@@ -389,7 +399,7 @@
</div>
<div class="flex justify-end mt-3">
<Button
on:click={createComment}
onclick={createComment}
loading={commentCreateLoading}
secondary={true}
disabled={commentCreateLoading ||
@@ -415,9 +425,8 @@
mode={comment.author == $currentUser?.id
? "edit"
: "show"}
on:delete={(e) =>
deleteComment(e.detail)}
on:edit={(e) => editComment(e.detail)}
ondelete={deleteComment}
onedit={editComment}
></CommentCard>
</li>
{/each}
@@ -435,7 +444,7 @@
showStyleSwitcher={false}
showFullscreen={true}
mapOptions={{ attributionControl: false }}
on:fullscreen={toggleMapFullScreen}
onfullscreen={toggleMapFullScreen}
bind:markers
></MapWithElevationMaplibre>
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import type { Trail, TrailFilter } from "$lib/models/trail";
import { createEventDispatcher, onMount } from "svelte";
import { _ } from "svelte-i18n";
import Pagination from "../base/pagination.svelte";
import Select, { type SelectItem } from "../base/select.svelte";
@@ -10,15 +9,30 @@
import TrailTable from "./trail_table.svelte";
import SkeletonCard from "../base/skeleton_card.svelte";
import SkeletonListItem from "../base/skeleton_list_item.svelte";
import { onMount } from "svelte";
export let filter: TrailFilter | null = null;
export let trails: Trail[];
export let pagination: { page: number; totalPages: number } = {
page: 1,
totalPages: 1,
};
export let loading: boolean = false;
export let fullWidthCards: boolean = false;
interface Props {
filter?: TrailFilter | null;
trails: Trail[];
pagination?: { page: number; totalPages: number };
loading?: boolean;
fullWidthCards?: boolean;
onupdate?: (filter: TrailFilter | null) => void;
onpagination?: (page: number) => void;
}
let {
filter = $bindable(null),
trails,
pagination = {
page: 1,
totalPages: 1,
},
loading = false,
fullWidthCards = false,
onupdate,
onpagination,
}: Props = $props();
const displayOptions: SelectItem[] = [
{ text: $_("card", { values: { n: 2 } }), value: "cards" },
@@ -26,9 +40,7 @@
{ text: $_("table"), value: "table" },
];
let selectedDisplayOption = displayOptions[0].value;
let dispatch = createEventDispatcher();
let selectedDisplayOption = $state(displayOptions[0].value);
const sortOptions: SelectItem[] = [
{ text: $_("name"), value: "name" },
@@ -58,7 +70,7 @@
(storedSortOrder as typeof filter.sortOrder | null) ??
filter.sortOrder;
}
dispatch("update", filter);
onupdate?.(filter);
});
function setDisplayOption() {
@@ -70,7 +82,7 @@
return;
}
localStorage.setItem("sort", filter.sort);
dispatch("update", filter);
onupdate?.(filter);
}
function setSortOrder() {
@@ -83,7 +95,7 @@
filter.sortOrder = "+";
}
localStorage.setItem("sort_order", filter.sortOrder);
dispatch("update", filter);
onupdate?.(filter);
}
function handleSortUpdate(sort: any) {
@@ -106,7 +118,7 @@
<Pagination
page={pagination.page}
totalPages={pagination.totalPages}
on:pagination
{onpagination}
></Pagination>
</div>
{#if filter}
@@ -117,13 +129,14 @@
<Select
bind:value={filter.sort}
items={sortOptions}
on:change={setSort}
onchange={setSort}
></Select>
<button
aria-label="Change sort order"
id="sort-order-btn"
class="btn-icon"
class:rotated={filter.sortOrder == "-"}
on:click={() => setSortOrder()}
onclick={() => setSortOrder()}
><i class="fa fa-arrow-up"></i></button
>
</div>
@@ -136,7 +149,7 @@
<Select
bind:value={selectedDisplayOption}
items={displayOptions}
on:change={() => setDisplayOption()}
onchange={() => setDisplayOption()}
></Select>
</div>
</div>
@@ -170,7 +183,7 @@
(option) => option.value !== "elevation_loss",
)}
{filter}
on:sort={(sort) => handleSortUpdate(sort)}
onsort={handleSortUpdate}
></TrailTable>
{:else}
{#each trails as trail}
@@ -193,7 +206,7 @@
<Pagination
page={pagination.page}
totalPages={pagination.totalPages}
on:pagination
{onpagination}
></Pagination>
</div>

View File

@@ -13,15 +13,19 @@
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
export let trail: Trail;
export let showDescription: boolean = true;
interface Props {
trail: Trail;
showDescription?: boolean;
}
$: thumbnail = trail.photos.length
let { trail, showDescription = true }: Props = $props();
let thumbnail = $derived(trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
: emptyStateTrailDark);
</script>
<li

View File

@@ -2,23 +2,25 @@
import * as M from "maplibre-gl";
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
import { createEventDispatcher, onMount } from "svelte";
export let trailFile: File | undefined | null;
export let trailData: string | undefined;
export let label: string = "";
import { onMount } from "svelte";
interface Props {
trailFile: File | undefined | null;
trailData: string | undefined;
label?: string;
onchange: (data: string | null) => void;
}
let {
trailFile = $bindable(),
trailData = $bindable(),
label = "",
onchange
}: Props = $props();
let map: M.Map;
let layer: M.LineLayerSpecification | undefined;
let source: M.GeoJSONSource | undefined;
const dispatch = createEventDispatcher();
$: if (trailData !== undefined) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
onMount(async () => {
if (!map) {
await initMap();
@@ -40,7 +42,7 @@
trailFile = null;
trailData = undefined;
dispatch("change", null);
onchange?.(null);
} else {
document.getElementById("trail-input")!.click();
}
@@ -64,7 +66,7 @@
trailData = parseResult.gpxData;
dispatch("change", trailData);
onchange?.(trailData);
}
async function showTrailOnMap() {
@@ -112,10 +114,14 @@
map?.removeSource(source.id);
source = undefined;
}
trailData = undefined;
trailFile = null;
}
$effect(() => {
if (trailData !== undefined) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
});
</script>
<div>
@@ -131,11 +137,11 @@
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
multiple={false}
style="display: none;"
on:change={() => handleTrailSelection()}
onchange={() => handleTrailSelection()}
/>
<button
type="button"
on:click={openTrailBrowser}
onclick={openTrailBrowser}
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
id="trail-picker-map"
>

View File

@@ -2,8 +2,6 @@
import Modal from "$lib/components/base/modal.svelte";
import type { Trail } from "$lib/models/trail";
import { TrailShare } from "$lib/models/trail_share";
import type { User } from "$lib/models/user";
import { show_toast } from "$lib/stores/toast_store";
import {
shares,
trail_share_create,
@@ -11,30 +9,31 @@
trail_share_index,
trail_share_update,
} from "$lib/stores/trail_share_store";
import { users_search } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Button from "../base/button.svelte";
import Search, { type SearchItem } from "../base/search.svelte";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
import UserSearch from "../user_search.svelte";
let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openShareModal() {
fetchShares();
if (openModal) {
openModal();
}
interface Props {
trail: Trail;
onsave?: () => void;
}
export let trail: Trail;
let { trail, onsave }: Props = $props();
const dispatch = createEventDispatcher();
let modal: Modal;
let copyButtonText = $_("copy-link");
export function openModal() {
openShareModalLocal();
}
async function openShareModalLocal() {
modal.openModal();
await fetchShares();
}
let copyButtonText = $state($_("copy-link"));
let sharesLoading: boolean = false;
@@ -51,8 +50,8 @@
}
function close() {
dispatch("save");
closeModal!();
onsave?.();
modal.closeModal!();
}
async function shareTrail(item: SelectItem) {
@@ -85,64 +84,68 @@
id="share-modal"
title={$_("share-this-trail")}
size="max-w-sm overflow-visible"
bind:openModal
bind:closeModal
bind:this={modal}
>
<div slot="content">
<UserSearch includeSelf={false} on:click={(e) => shareTrail(e.detail)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#snippet content()}
<div>
<UserSearch includeSelf={false} onclick={(item) => shareTrail(item)}
></UserSearch>
<h4 class="font-semibold mt-4">{$_("shared-with")}</h4>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("trail-not-shared")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
bind:value={share.permission}
items={permissionSelectItems}
on:change={(e) =>
updateSharePermission(share, e.detail)}
></Select>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_("trail-not-shared")}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span
class="basis-full text-sm text-center text-gray-500"
>{$_("can")}</span
>
<div class="shrink-0">
<Select
value={share.permission}
items={permissionSelectItems}
onchange={(value) =>
updateSharePermission(share, value)}
></Select>
</div>
<button
aria-label="Delete share"
class="btn-icon text-red-500"
onclick={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
<button
class="btn-icon text-red-500"
on:click={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
{/if}
{/each}
{/if}
</div>
<div slot="footer" class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
on:click={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" on:click={close}>{$_("close")}</button>
</div></Modal
{/if}
{/each}
{/if}
</div>
{/snippet}
{#snippet footer()}
<div class="flex justify-between items-center gap-4">
<Button
secondary={true}
disabled={copyButtonText == $_("link-copied")}
onclick={copyURLToClipboard}
>
<i class="fa fa-link mr-2"></i>
{copyButtonText}
</Button>
<button class="btn-primary" onclick={close}>{$_("close")}</button>
</div>
{/snippet}</Modal
>

View File

@@ -8,15 +8,17 @@
import { _ } from "svelte-i18n";
import type { SelectItem } from "../base/select.svelte";
import { goto } from "$app/navigation";
import { createEventDispatcher } from "svelte";
import { getFileURL } from "$lib/util/file_util";
import ShareInfo from "../share_info.svelte";
export let tableHeader: SelectItem[];
export let trails: Trail[] | null = null;
export let filter: TrailFilter | null = null;
interface Props {
tableHeader: SelectItem[];
trails?: Trail[] | null;
filter?: TrailFilter | null;
onsort?: (value: any) => void
}
let dispatch = createEventDispatcher();
let { tableHeader, trails = null, filter = null, onsort }: Props = $props();
function getColumnWidth(columnValue: string): string {
switch (columnValue) {
@@ -48,7 +50,7 @@
class="p-4 text-left text-sm font-medium {getColumnWidth(
column.value,
)}"
on:click={() => dispatch("sort", column.value)}
onclick={() => onsort?.(column.value)}
>
<div class="cursor-pointer">
{column.text}
@@ -69,7 +71,7 @@
{#each trails as trail}
<tr
class="border-t border-input-border cursor-pointer hover:bg-secondary-hover transition-colors"
on:click={() => goto(`/trail/view/${trail.id}`)}
onclick={() => goto(`/trail/view/${trail.id}`)}
>
<td
class="flex justify-between items-center text-sm relative"