Migrates to svelte 5
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user