Files
wanderer/web/src/routes/lists/+page.svelte
2024-05-05 12:31:08 +02:00

278 lines
9.2 KiB
Svelte

<script lang="ts">
import { page } from "$app/stores";
import type { DropdownItem } from "$lib/components/base/dropdown.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte";
import ListCard from "$lib/components/list/list_card.svelte";
import ListModal from "$lib/components/list/list_modal.svelte";
import TrailList from "$lib/components/trail/trail_list.svelte";
import { List } from "$lib/models/list";
import type { Trail, TrailFilter } from "$lib/models/trail";
import {
list,
lists,
lists_create,
lists_delete,
lists_index,
lists_update,
} from "$lib/stores/list_store";
import { fetchGPX } from "$lib/stores/trail_store";
import { getFileURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import "$lib/vendor/leaflet-elevation/src/index.css";
import type {
GPX,
Icon,
LatLngBoundsExpression,
LeafletEvent,
Map,
Marker,
} from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css";
import { onMount, tick } from "svelte";
import { _ } from "svelte-i18n";
let openListModal: () => void;
let openConfirmModal: () => void;
let filter: TrailFilter = $page.data.filter;
let listToBeDeleted: List | null = null;
let L: any;
let map: Map;
let showMap: boolean = false;
let gpxLayers: GPX[] = [];
onMount(async () => {
L = (await import("leaflet")).default;
await import("leaflet-gpx");
await import("leaflet.awesome-markers");
map = L.map("map").setView([0, 0], 4);
map.attributionControl.setPrefix(false);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
}).addTo(map);
});
function addGPXLayer(trail: Trail) {
return new Promise<GPX>(function (resolve, reject) {
if (!trail.expand.gpx_data) {
reject();
}
const thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail])
: "/imgs/default_thumbnail.webp";
const gpxLayer = new L.GPX(trail.expand.gpx_data!, {
async: true,
polyline_options: {
className: "lightblue-theme elevation-polyline",
weight: 5,
},
gpx_options: {
parseElements: ["track", "route"],
},
marker_options: {
startIcon: L.AwesomeMarkers.icon({
icon: "circle-half-stroke",
prefix: "fa",
markerColor: "cadetblue",
iconColor: "white",
}) as Icon,
startIconUrl: "",
endIconUrl: "",
shadowUrl: "",
},
})
.on("addpoint", function (e: any) {
if (e.point_type === "start") {
const marker: Marker = e.point as Marker;
marker.bindPopup(
`<a href="map/trail/${trail.id}">
<li class="flex items-center gap-4 cursor-pointer text-black">
<div class="shrink-0"><img class="h-14 w-14 object-cover rounded-xl" src="${thumbnail}" alt="">
</div>
<div>
<h4 class="font-semibold text-lg">${trail.name}</h4>
<div class="flex gap-x-4">
${trail.location ? `<h5><i class="fa fa-location-dot mr-2"></i>${trail.location}</h5>` : ""}
<h5><i class="fa fa-person-hiking mr-2"></i>${$_(trail.difficulty as string)}</h5>
</div>
<div class="flex mt-2 gap-4 text-sm text-gray-500"><span class="shrink-0"><i
class="fa fa-left-right mr-2"></i>${formatDistance(
trail.distance,
)}</span> <span class="shrink-0"><i class="fa fa-up-down mr-2"></i>${formatElevation(
trail.elevation_gain,
)}</span> <span class="shrink-0"><i class="fa fa-clock mr-2"></i>${formatTimeHHMM(
trail.duration,
)}</span></div>
</div>
</li>
</a>`,
);
}
})
.on("loaded", function (e: LeafletEvent) {
resolve(gpxLayer);
})
.on("error", reject)
.addTo(map);
});
}
async function toggleMap() {
showMap = !showMap;
if (showMap) {
setCurrentList($list);
await tick();
map.invalidateSize();
}
}
function beforeListModalOpen() {
list.set(new List("", []));
openListModal();
}
async function saveList(e: CustomEvent<{ list: List; avatar?: File }>) {
const result = e.detail;
if (result.list.id) {
await lists_update(result.list, result.avatar);
} else {
await lists_create(result.list, result.avatar);
}
await lists_index();
}
async function handleDropdownClick(
e: CustomEvent<DropdownItem>,
currentList: List,
) {
const item = e.detail;
if (item.value == "edit") {
list.set(currentList);
openListModal();
} else if (item.value == "delete") {
openConfirmModal();
listToBeDeleted = currentList;
}
}
async function deleteList() {
if (!listToBeDeleted) {
return;
}
await lists_delete(listToBeDeleted);
await lists_index();
listToBeDeleted = null;
}
async function setCurrentList(item: List) {
for (const layer of gpxLayers) {
map.removeLayer(layer);
}
list.set(item);
if (item.expand && item.expand.trails.length > 0) {
let minLat = item.expand.trails[0].lat!;
let maxLat = item.expand.trails[0].lat!;
let minLon = item.expand.trails[0].lon!;
let maxLon = item.expand.trails[0].lon!;
for (const trail of item.expand.trails) {
minLat = Math.min(minLat, trail.lat!);
maxLat = Math.max(maxLat, trail.lat!);
minLon = Math.min(minLon, trail.lon!);
maxLon = Math.max(maxLon, trail.lon!);
const gpxData: string = await fetchGPX(trail);
trail.expand.gpx_data = gpxData;
gpxLayers.push(await addGPXLayer(trail));
}
const boundingBox: LatLngBoundsExpression = [
[maxLat, minLon],
[minLat, maxLon],
];
map.fitBounds(boundingBox);
} else {
map.setView([0, 0], 4);
}
}
</script>
<svelte:head>
<title>{$_("list", { values: { n: 2 } })} | wanderer</title>
</svelte:head>
<main
class="grid grid-cols-1 md:grid-cols-[400px_1fr] gap-4 lg:gap-8 max-w-7xl mx-4 md:mx-auto"
style="min-height: calc(100vh - 124px)"
>
<ul
class="list-list mx-2 md:mx-auto rounded-xl border border-input-border p-4 max-w-full"
>
<div class="flex gap-x-4 items-center">
<button
class="flex w-full items-center gap-4 hover:bg-menu-item-background-hover transition-colors rounded-xl p-4 cursor-pointer"
on:click={beforeListModalOpen}
>
<i class="fa fa-plus text-xl aspect-square"></i>
<h5 id="create-list-button" class="text-xl font-semibold">
{$_("create-new-list")}
</h5>
</button>
<button type="button" class="btn-icon" on:click={toggleMap}
><i class="fa-regular fa-{showMap ? 'rectangle-list' : 'map'}"
></i></button
>
</div>
<hr class="border-separator my-2" />
{#each $lists as item, i}
<li
class="list-list-item"
on:click={() => setCurrentList(item)}
role="presentation"
>
<ListCard
list={item}
on:change={(e) => handleDropdownClick(e, item)}
active={item.id === $list.id}
></ListCard>
{#if i != $lists.length - 1}
<hr class="border-separator my-2" />
{/if}
</li>
{/each}
</ul>
<div id="map" class="rounded-xl z-0" class:hidden={!showMap}></div>
<div class:hidden={showMap}>
<TrailList
bind:filter
trails={$list.expand?.trails ?? []}
on:update={async () => await lists_index()}
></TrailList>
</div>
<ListModal bind:openModal={openListModal} on:save={saveList}></ListModal>
<ConfirmModal
text={$_("delete-list-confirm")}
bind:openModal={openConfirmModal}
on:confirm={deleteList}
></ConfirmModal>
</main>
<style>
#map {
min-height: 600px;
}
</style>