From 06f6d58b5e6deeac85149badec5011197fa1c9d5 Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:28:27 +0200 Subject: [PATCH] Trail Table: Allow multi select (#264) * trail table: multi select * trail cards: multi select * multiselect for list view, several multiselect fixes, code beautify * remove unnecessary imports, move code to simplify diff of trail_dropdown * translations * fix horizontal scrollbar in table view * direct export from list, multiselect export * fix refreshing share icon after sharing from trail list * fix adding multiple trails to a trail-list * fix retrieving mail notification template * migrates to v0.17.0 --------- Co-authored-by: Christian Beutel <> --- .gitignore | 2 +- db/util/email_templates.go | 2 +- web/src/lib/components/actor_search.svelte | 2 +- .../components/list/list_select_modal.svelte | 20 +- .../lib/components/trail/trail_card.svelte | 48 +- .../components/trail/trail_dropdown.svelte | 439 +++++++++++++----- .../components/trail/trail_info_panel.svelte | 7 +- .../lib/components/trail/trail_list.svelte | 139 +++++- .../components/trail/trail_list_item.svelte | 37 +- .../components/trail/trail_share_modal.svelte | 8 +- .../lib/components/trail/trail_table.svelte | 76 ++- web/src/lib/i18n/locales/de.json | 3 + web/src/lib/i18n/locales/en.json | 3 + web/src/lib/i18n/locales/es.json | 3 + web/src/lib/i18n/locales/fr.json | 3 + web/src/lib/i18n/locales/hu.json | 3 + web/src/lib/i18n/locales/it.json | 3 + web/src/lib/i18n/locales/nl.json | 3 + web/src/lib/i18n/locales/pl.json | 3 + web/src/lib/i18n/locales/pt.json | 3 + web/src/lib/i18n/locales/zh.json | 3 + web/src/lib/util/gpx_util.ts | 19 +- 22 files changed, 666 insertions(+), 163 deletions(-) diff --git a/.gitignore b/.gitignore index 86c8c9f2..61487c08 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ run.sh build*.sh start.* -data*/ \ No newline at end of file +data*/ diff --git a/db/util/email_templates.go b/db/util/email_templates.go index 01edf2a6..d33fc0c5 100644 --- a/db/util/email_templates.go +++ b/db/util/email_templates.go @@ -59,7 +59,7 @@ func GenerateHTML(appUrl string, recipientName string, authorName string, notifi } html, err := registry.LoadFiles( - "templates/mail/notification.html", + "db/templates/mail/notification.html", ).Render(content) if err != nil { diff --git a/web/src/lib/components/actor_search.svelte b/web/src/lib/components/actor_search.svelte index e018baa6..fbe018f7 100644 --- a/web/src/lib/components/actor_search.svelte +++ b/web/src/lib/components/actor_search.svelte @@ -34,7 +34,7 @@ try { const actors: Actor[] = await searchActors(q, includeSelf); searchItems = actors.map((a) => ({ - text: "a.username!", + text: a.username, description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`, value: a, icon: "user", diff --git a/web/src/lib/components/list/list_select_modal.svelte b/web/src/lib/components/list/list_select_modal.svelte index 9ae6cc9c..ffb48d3e 100644 --- a/web/src/lib/components/list/list_select_modal.svelte +++ b/web/src/lib/components/list/list_select_modal.svelte @@ -2,6 +2,7 @@ import { type Snippet } from "svelte"; import type { List } from "$lib/models/list"; + import type { Trail } from "$lib/models/trail"; import { trail } from "$lib/stores/trail_store"; import { getFileURL } from "$lib/util/file_util"; import { _ } from "svelte-i18n"; @@ -12,11 +13,12 @@ interface Props { lists: List[]; + trails?: Set | undefined; children?: Snippet<[any]>; onchange?: (list: List) => void } - let { lists, children, onchange }: Props = $props(); + let { lists, trails, children, onchange }: Props = $props(); let modal: Modal; @@ -29,6 +31,20 @@ modal.closeModal!(); } + function listContainsAllTrails(list: List) : boolean { + if (trails === undefined) { + return listContainsCurrentTrail(list) ?? false; + } else if (list.trails !== undefined) { + for (const lTrail of trails) { + if (!list.trails!.includes(lTrail.id!)) return false; + } + + return true; + } + + return false; + } + function listContainsCurrentTrail(list: List) { return list.trails?.includes($trail.id!); } @@ -66,7 +82,7 @@
{list.name}
diff --git a/web/src/lib/components/trail/trail_card.svelte b/web/src/lib/components/trail/trail_card.svelte index feaf36f3..104d32ec 100644 --- a/web/src/lib/components/trail/trail_card.svelte +++ b/web/src/lib/components/trail/trail_card.svelte @@ -18,15 +18,21 @@ interface Props { trail: Trail; fullWidth?: boolean; + selected: boolean; + hovered: boolean; onmouseenter?: MouseEventHandler; onmouseleave?: MouseEventHandler; + onTrailSelect?: () => void; } let { trail, fullWidth = false, + selected = false, + hovered = false, onmouseenter, onmouseleave, + onTrailSelect, }: Props = $props(); let thumbnail = $derived( @@ -45,6 +51,11 @@ (trail.expand?.trail_share_via_trail?.length ?? 0) > 0, ); + function handleInputClick(e: Event) { + e.stopPropagation(); + onTrailSelect?.(); + hovered = true; + } // expand and collapse the tags let expandedTags = $state(false); @@ -85,18 +96,17 @@ /> {/if} - {#if $currentUser && trail.like_count > 0} + {#if hovered || selected}
- - - -
- {trail.like_count} -
+ handleInputClick(e)} + />
{/if} {#if (trail.public || trailIsShared) && $currentUser} @@ -121,6 +131,20 @@ {/if} {/if} + {#if $currentUser && trail.like_count > 0} +
+ + + +
+ {trail.like_count} +
+
+ {/if}

{trail.name}

@@ -162,9 +186,9 @@ type="button" > {#if expandedTags} - {$_('show-less')} + {$_("show-less")} {:else} - +{trail.tags.length - 2} {$_('more')} + +{trail.tags.length - 2} {$_("more")} {/if} {/if} diff --git a/web/src/lib/components/trail/trail_dropdown.svelte b/web/src/lib/components/trail/trail_dropdown.svelte index babfb8db..d21eb3c3 100644 --- a/web/src/lib/components/trail/trail_dropdown.svelte +++ b/web/src/lib/components/trail/trail_dropdown.svelte @@ -20,14 +20,15 @@ import ListSelectModal from "../list/list_select_modal.svelte"; import TrailExportModal from "./trail_export_modal.svelte"; import TrailShareModal from "./trail_share_modal.svelte"; + import { handleFromRecordWithIRI } from "$lib/util/activitypub_util"; interface Props { - trail: Trail; - handle: string; - mode: "overview" | "map" | "list"; + trails?: Set | undefined; + mode: "overview" | "map" | "list" | "multi-select"; + onconfirm?: (resetSelection?: boolean) => void; } - let { trail, handle, mode }: Props = $props(); + let { trails, mode, onconfirm }: Props = $props(); let confirmModal: ConfirmModal; let listSelectModal: ListSelectModal; @@ -36,55 +37,145 @@ let lists: List[] = $state([]); - const isOwned: boolean = trail.author == $currentUser?.actor; - - const allowEdit = - isOwned || - trail.expand?.trail_share_via_trail?.some( - (s) => s.permission == "edit", + function allowEdit(): boolean { + return ( + hasTrail() && + !isMultiselectMode() && + (trail()!.expand?.author?.id === $currentUser?.actor || + trail()!.expand?.trail_share_via_trail?.some( + (s) => s.permission == "edit", + ))! ); + } - const dropdownItems: DropdownItem[] = [ - mode == "overview" - ? { text: $_("show-on-map"), value: "show", icon: "map" } - : { - text: $_("show-in-overview"), - value: "show", - icon: "table-columns", - }, + function dropdownItems(): DropdownItem[] { + return [ + ...(!isMultiselectMode() + ? [ + mode == "overview" || mode == "multi-select" + ? { + text: $_("show-on-map"), + value: "show", + icon: "map", + } + : { + text: $_("show-in-overview"), + value: "show", + icon: "table-columns", + }, + ] + : []), + ...(!isMultiselectMode() + ? [{ text: $_("directions"), value: "direction", icon: "car" }] + : []), + ...(canExport() + ? [ + { + text: $_("export"), + value: "download", + icon: "download", + }, + ] + : []), + ...(!isMultiselectMode() + ? [{ text: $_("print"), value: "print", icon: "print" }] + : []), + ...(!isFromCurrentUser() + ? [] + : [ + { + text: $_("add-to-list"), + value: "list", + icon: "bookmark", + }, + ]), + ...(isMultiselectMode() || !isFromCurrentUser() + ? [] + : [{ text: $_("share"), value: "share", icon: "share" }]), + ...(allowEdit() + ? [{ text: $_("edit"), value: "edit", icon: "pen" }] + : []), + ...(allowDelete() + ? [{ text: $_("delete"), value: "delete", icon: "trash" }] + : []), + ]; + } - { text: $_("directions"), value: "direction", icon: "car" }, - ...(trail.gpx - ? [ - { - text: $_("export"), - value: "download", - icon: "download", - }, - ] - : []), - { text: $_("print"), value: "print", icon: "print" }, - ...(isOwned - ? [{ text: $_("add-to-list"), value: "list", icon: "bookmark" }] - : []), - ...(isOwned - ? [{ text: $_("share"), value: "share", icon: "share" }] - : []), - ...(allowEdit - ? [{ text: $_("edit"), value: "edit", icon: "pen" }] - : []), - ...(isOwned - ? [{ text: $_("delete"), value: "delete", icon: "trash" }] - : []), - ]; + function isMultiselectMode(): boolean { + return trails !== undefined && trails.size > 1; + } + + function hasTrail(): boolean { + return ( + trails !== undefined && + trails.size > 0 && + [...trails][0] !== undefined + ); + } + + function hasGpx(): boolean { + if (!hasTrail()) return false; + + for (const gTrail of trails!) { + if (gTrail.gpx) return true; + } + + return false; + } + + function canExport(): boolean { + return hasGpx(); + } + + function trailId(): string | undefined { + return trail()?.id; + } + + function getTrails(): Set | undefined { + return trails; + } + + function trail(): Trail | undefined { + return hasTrail() ? [...trails!][0] : undefined; + } + + function isFromCurrentUser(uTrail?: Trail): boolean { + if (uTrail !== undefined) { + return uTrail.expand?.author?.id === $currentUser?.actor; + } else if (trails !== undefined && trails.size > 0) { + for (const sTrail of trails) { + if (sTrail.expand?.author?.id === $currentUser?.actor) { + return true; + } + } + } + + return false; + } + + function allowDelete(): boolean { + return isFromCurrentUser(); + } + + function allowDeleteTrail(dTrail?: Trail): boolean { + return isFromCurrentUser(dTrail); + } async function handleDropdownClick(item: { text: string; value: any }) { + if (!trail()) { + return; + } + + const handle = handleFromRecordWithIRI(trail()); + if (item.value == "show") { - goto( - mode == "overview" - ? `/map/trail/${handle}/${trail.id!}` - : `/trail/view/${handle}/${trail.id!}`, - ); + if (hasTrail()) { + goto( + mode == "overview" || mode == "multi-select" + ? `/map/trail/${handle}/${trailId()}` + : `/trail/view/${handle}/${trailId()}`, + ); + } } else if (item.value == "list") { lists = ( await lists_index( @@ -95,79 +186,105 @@ ).items; listSelectModal.openModal(); } else if (item.value == "direction") { - window - .open( - `https://www.google.com/maps/dir/Current+Location/${trail.lat},${trail.lon}`, - "_blank", - ) - ?.focus(); + if (hasTrail()) { + window + .open( + `https://www.google.com/maps/dir/Current+Location/${trail()!.lat},${trail()!.lon}`, + "_blank", + ) + ?.focus(); + } } else if (item.value == "print") { - goto(`/map/trail/${handle}/${trail.id}/print`); + if (hasTrail()) { + goto(`/map/trail/${handle}/${trailId()}/print`); + } } else if (item.value == "share") { trailShareModal.openModal(); } else if (item.value == "download") { trailExportModal.openModal(); } else if (item.value == "edit") { - goto(`/trail/edit/${trail.id}`); + if (hasTrail()) { + goto(`/trail/edit/${trailId()}`); + } } else if (item.value == "delete") { confirmModal.openModal(); } } - async function exportTrail(exportSettings: { + async function exportTrails(exportSettings: { fileFormat: "gpx" | "json"; photos: boolean; summitLog: boolean; }) { - try { - let fileData: string = await trail2gpx(trail, $currentUser); - if (exportSettings.fileFormat == "json") { - fileData = JSON.stringify( - gpx( - new DOMParser().parseFromString( - fileData, - "application/gpx+xml" as any, - ), - ), - ); + if (trails !== undefined && trails.size > 0) { + for (const cTrail of trails) { + await doExportTrail(exportSettings, cTrail); } - if (!exportSettings.photos && !exportSettings.summitLog) { - const blob = new Blob([fileData], { - type: - exportSettings.fileFormat == "json" - ? "application/json" - : "application/gpx+xml", - }); - saveAs(blob, `${trail.name}.${exportSettings.fileFormat}`); - } else { - const zip = new JSZip(); - zip.file( - `${trail.name}.${exportSettings.fileFormat}`, - fileData, - ); - if (exportSettings.photos) { - const photoFolder = zip.folder($_("photos")); - for (const photo of trail.photos) { - const photoURL = getFileURL(trail, photo); - const photoBlob = await fetch(photoURL).then( - (response) => response.blob(), - ); - const photoData = new File([photoBlob], photo); - photoFolder?.file(photo, photoData, { base64: true }); - } - } - if (exportSettings.summitLog) { - let summitLogString = ""; - for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) { - summitLogString += `${summitLog.date},${summitLog.text}\n`; - } - zip.file( - `${trail.name} - ${$_("summit-book")}.csv`, - summitLogString, + } + } + + async function doExportTrail( + exportSettings: { + fileFormat: "gpx" | "json"; + photos: boolean; + summitLog: boolean; + }, + eTrail: Trail, + ) { + try { + if (eTrail !== undefined) { + let fileData: string = await trail2gpx(eTrail, $currentUser); + if (exportSettings.fileFormat == "json") { + fileData = JSON.stringify( + gpx( + new DOMParser().parseFromString( + fileData, + "application/gpx+xml" as any, + ), + ), ); } - const blob = await zip.generateAsync({ type: "blob" }); - saveAs(blob, `${trail.name}.zip`); + if (!exportSettings.photos && !exportSettings.summitLog) { + const blob = new Blob([fileData], { + type: + exportSettings.fileFormat == "json" + ? "application/json" + : "application/gpx+xml", + }); + saveAs(blob, `${eTrail.name}.${exportSettings.fileFormat}`); + } else { + const zip = new JSZip(); + zip.file( + `${eTrail.name}.${exportSettings.fileFormat}`, + fileData, + ); + if (exportSettings.photos) { + const photoFolder = zip.folder($_("photos")); + for (const photo of eTrail.photos) { + const photoURL = getFileURL(eTrail, photo); + const photoBlob = await fetch(photoURL).then( + (response) => response.blob(), + ); + const photoData = new File([photoBlob], photo); + photoFolder?.file(photo, photoData, { + base64: true, + }); + } + } + if (exportSettings.summitLog) { + let summitLogString = ""; + for (const summitLog of eTrail.expand + ?.summit_logs_via_trail ?? []) { + summitLogString += `${summitLog.date},${summitLog.text}\n`; + } + zip.file( + `${eTrail.name} - ${$_("summit-book")}.csv`, + summitLogString, + ); + } + const blob = await zip.generateAsync({ type: "blob" }); + saveAs(blob, `${eTrail.name}.zip`); + } } } catch (e) { console.error(e); @@ -179,28 +296,57 @@ } } - async function deleteTrail() { - await trails_delete(trail); - setTimeout(() => { - goto("/trails"); - }, 500); + async function deleteTrails() { + if (hasTrail()) { + for (const dTrail of trails!) { + await doDeleteTrail(dTrail); + } + + onconfirm?.(true); + } + } + + async function doDeleteTrail(dTrail: Trail) { + if (dTrail === undefined) return; + + if (!allowDeleteTrail(dTrail)) return; + + await trails_delete(dTrail); + } + + async function handleShareUpdate() { + onconfirm?.(); } async function handleListSelection(list: List) { try { - if (list.trails?.includes(trail.id!)) { - await lists_remove_trail(list, trail); + let deleted = false; + let multiple = false; + + if (hasTrail()) { + multiple = true; + for (const lTrail of trails!) { + if (await doHandleListSelection(list, lTrail)) { + deleted = true; + } + } + } + + if (deleted) { show_toast({ type: "success", icon: "check", - text: `${$_("removed-trail-from")} "${list.name}"`, + text: multiple + ? `${$_("removed-trails-from")} "${list.name}"` + : `${$_("removed-trail-from")} "${list.name}"`, }); } else { - await lists_add_trail(list, trail); show_toast({ type: "success", icon: "check", - text: `${$_("added-trail-to")} "${list.name}"`, + text: multiple + ? `${$_("added-trails-to")} "${list.name}"` + : `${$_("added-trail-to")} "${list.name}"`, }); } } catch (e) { @@ -213,32 +359,79 @@ }); } } + + async function doHandleListSelection( + list: List, + lTrail: Trail, + ): Promise { + if (list.trails?.includes(lTrail.id!)) { + if (listContainsAllTrails(list)) { + await lists_remove_trail(list, lTrail); + return true; + } + } else { + await lists_add_trail(list, lTrail); + } + + return false; + } + function listContainsAllTrails(list: List): boolean { + if (trails === undefined) { + return false; + } else if (list.trails !== undefined) { + for (const lTrail of trails) { + if (!list.trails!.includes(lTrail.id!)) return false; + } + + return true; + } + + return false; + } - handleDropdownClick(item)} + handleDropdownClick(item)} >{#snippet children({ toggleMenu: openDropdown })} - + {#if mode == "multi-select"} + + {:else} + + {/if} {/snippet} handleListSelection(list)} > exportTrail(settings)} + onexport={(settings) => exportTrails(settings)} > - + diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index 3feaf294..a970d014 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -401,7 +401,10 @@ {#if ($currentUser && $currentUser.actor == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
- + ([trail])} + {mode} + >
{/if}
@@ -658,7 +661,7 @@ elevationProfileContainer={"epc-container"} showStyleSwitcher={false} showFullscreen={true} - mapOptions={{ attributionControl: {compact: true} }} + mapOptions={{ attributionControl: { compact: true } }} onfullscreen={toggleMapFullScreen} bind:markers > diff --git a/web/src/lib/components/trail/trail_list.svelte b/web/src/lib/components/trail/trail_list.svelte index 6d12f0ea..83860a3c 100644 --- a/web/src/lib/components/trail/trail_list.svelte +++ b/web/src/lib/components/trail/trail_list.svelte @@ -10,6 +10,7 @@ import SkeletonCard from "../base/skeleton_card.svelte"; import SkeletonListItem from "../base/skeleton_list_item.svelte"; import { onMount } from "svelte"; + import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte"; interface Props { filter?: TrailFilter | null; @@ -17,7 +18,10 @@ pagination?: { page: number; totalPages: number }; loading?: boolean; fullWidthCards?: boolean; - onupdate?: (filter: TrailFilter | null) => void; + onupdate?: ( + filter: TrailFilter | null, + selection: Set | undefined, + ) => void; onpagination?: (page: number) => void; } @@ -42,6 +46,9 @@ let selectedDisplayOption = $state(displayOptions[0].value); + let selection: Set | undefined = $state(); + let hoveredTrail: Trail | undefined = $state(); + const sortOptions: SelectItem[] = [ { text: $_("name"), value: "name" }, { text: $_("distance"), value: "distance" }, @@ -71,7 +78,7 @@ (storedSortOrder as typeof filter.sortOrder | null) ?? filter.sortOrder; } - onupdate?.(filter); + onupdate?.(filter, selection); }); function setDisplayOption() { @@ -83,7 +90,7 @@ return; } localStorage.setItem("sort", filter.sort); - onupdate?.(filter); + onupdate?.(filter, selection); } function setSortOrder() { @@ -96,7 +103,7 @@ filter.sortOrder = "+"; } localStorage.setItem("sort_order", filter.sortOrder); - onupdate?.(filter); + onupdate?.(filter, selection); } function handleSortUpdate(sort: any) { @@ -111,6 +118,96 @@ setSort(); } } + + function isHovered(trail: Trail): boolean { + if (trail === undefined) { + return false; + } + + if (hoveredTrail === undefined) { + return false; + } + + return hoveredTrail.id === trail.id; + } + + function isSelected(trail: Trail): boolean { + if (trail === undefined) { + return false; + } else { + if (selection === undefined) { + return false; + } else { + for (const sTrail of selection) { + if (sTrail !== undefined && sTrail.id === trail.id) { + return true; + } + } + } + } + + return false; + } + + function handleSelectionUpdate(trail: Trail) { + let newSelection = new Set(); + + if (trail !== undefined) { + let isSelected = false; + + if (selection !== undefined && selection.size > 0) { + for (const sTrail of selection) { + if (sTrail !== undefined && sTrail.id === trail.id) { + isSelected = true; + continue; + } + + newSelection.add(sTrail); + } + } + + if (!isSelected) { + newSelection.add(trail); + } + } else if ( + selection === undefined || + selection.size === 0 || + (trails !== undefined && selection.size !== trails.length) + ) { + for (const eTrail of trails) { + newSelection.add(eTrail); + } + } + + selection = newSelection; + } + + function handleHoverUpdate(hTrail: Trail) { + if (hTrail === undefined) { + return; + } + + if (hoveredTrail === undefined) hoveredTrail = hTrail; + else hoveredTrail = undefined; + } + + function handleTrailsEditDone(resetSelection: boolean = false) { + if (resetSelection) { + selection?.clear(); + hoveredTrail = undefined; + } + + setTimeout(() => { + onupdate?.(filter, selection); + }, 500); + } + + function handleMouseEnter(trail: Trail) { + handleHoverUpdate(trail); + } + function handleMouseLeave(trail: Trail) { + handleHoverUpdate(trail); + }
@@ -122,6 +219,15 @@ {onpagination} >
+ {#if selection !== undefined && selection.size > 0} +
+ +
+ {/if} {#if filter}
{#if selectedDisplayOption !== "table"} @@ -158,7 +264,10 @@
{#if loading} {#if selectedDisplayOption === "table"} - ()} + tableHeader={sortOptions} > {:else} {#each { length: 12 } as _, index} @@ -180,11 +289,13 @@ {#if selectedDisplayOption === "table"} option.value !== "elevation_loss", )} {filter} onsort={handleSortUpdate} + onTrailSelect={(t) => handleSelectionUpdate(t)} > {:else} {#each trails as trail} @@ -194,12 +305,26 @@ href="/trail/view/@{trail.author}{trail.domain ? `@${trail.domain}` : ''}/{trail.id}" + onmouseenter={(e) => handleMouseEnter(trail)} + onmouseleave={(e) => handleMouseLeave(trail)} > {#if selectedDisplayOption === "cards"} - + handleSelectionUpdate(trail)} > {:else} - + + handleSelectionUpdate(trail)} + > {/if} {/each} diff --git a/web/src/lib/components/trail/trail_list_item.svelte b/web/src/lib/components/trail/trail_list_item.svelte index eda907ab..a578ad7c 100644 --- a/web/src/lib/components/trail/trail_list_item.svelte +++ b/web/src/lib/components/trail/trail_list_item.svelte @@ -19,9 +19,18 @@ interface Props { trail: Trail; showDescription?: boolean; + selected: boolean; + hovered: boolean; + onTrailSelect?: () => void; } - let { trail, showDescription = true }: Props = $props(); + let { + trail, + showDescription = true, + selected = false, + hovered = false, + onTrailSelect, + }: Props = $props(); let thumbnail = $derived( trail.photos.length @@ -35,6 +44,12 @@ : emptyStateTrailDark, ); + function handleInputClick(e: Event) { + e.stopPropagation(); + onTrailSelect?.(); + hovered = true; + } + let expandedTags = $state(false); function toggleExpandTags(e: MouseEvent) { @@ -64,7 +79,7 @@ /> {/if}
-
+

{trail.name} @@ -139,9 +154,6 @@ {#if trail.location}

{trail.location}
{/if} -
- {$_(trail.difficulty ?? "?")} -
@@ -168,10 +180,23 @@
{#if showDescription}

{formatHTMLAsText(trail.description ?? "")}

{/if} + {#if hovered || selected} +
+ handleInputClick(e)} + /> +
+ {/if}
diff --git a/web/src/lib/components/trail/trail_share_modal.svelte b/web/src/lib/components/trail/trail_share_modal.svelte index 8d836b83..81086fee 100644 --- a/web/src/lib/components/trail/trail_share_modal.svelte +++ b/web/src/lib/components/trail/trail_share_modal.svelte @@ -16,7 +16,7 @@ import Select from "../base/select.svelte"; interface Props { - trail: Trail; + trail?: Trail; onsave?: () => void; } @@ -56,6 +56,10 @@ } async function shareTrail(item: SelectItem) { + if (trail === undefined) { + return; + } + if (!item.value.isLocal && !trail.public) { displayShareError = true; return; @@ -80,6 +84,8 @@ } async function fetchShares() { + if (trail === undefined) return; + sharesLoading = true; await trail_share_index({ trail: trail.id! }); sharesLoading = false; diff --git a/web/src/lib/components/trail/trail_table.svelte b/web/src/lib/components/trail/trail_table.svelte index d11d7488..37cde3c9 100644 --- a/web/src/lib/components/trail/trail_table.svelte +++ b/web/src/lib/components/trail/trail_table.svelte @@ -10,18 +10,22 @@ import { goto } from "$app/navigation"; import { getFileURL } from "$lib/util/file_util"; import ShareInfo from "../share_info.svelte"; - + interface Props { tableHeader: SelectItem[]; trails?: Trail[] | null; + selection: Set | undefined; filter?: TrailFilter | null; - onsort?: (value: any) => void; + onsort?: (value: any) => void + onTrailSelect?: (value: any) => void } - let { tableHeader, trails = null, filter = null, onsort }: Props = $props(); + let { tableHeader, trails = null, selection, filter = null, onsort, onTrailSelect: onselect }: Props = $props(); function getColumnWidth(columnValue: string): string { switch (columnValue) { + case "select": + return "w-[2%]"; case "name": return "w-[25%]"; case "distance": @@ -37,14 +41,68 @@ return ""; } } + + function setSelectedTrail(e: Event, trail: Trail) { + e.stopPropagation() + + if (trail !== undefined) { + if (onselect !== undefined) { + onselect(trail) + } + else { + console.error("undefined event handler") + } + } + } + + function setSelectedAllTrails(e: Event) { + onselect?.(undefined) + } + + function isSelected(trail: Trail): boolean { + if (selection === undefined) { + return false; + } + + if (trail !== undefined) { + for (const strail of selection) { + if (strail !== undefined && strail.id === trail.id) + return true; + } + } + + return false; + } + + function allSelected(): boolean { + if (selection === undefined || trails === undefined || trails === null) { + return false; + } + + return selection.size === trails.length; + } +
+ {#each tableHeader as column}
+
+ +
+
+
+ setSelectedTrail(e, trail)} + /> +
+
diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 58891396..2b827849 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -14,6 +14,7 @@ "add-to-list": "Zu Liste hinzufügen", "add-waypoint": "Wegpunkt hinzufügen", "added-trail-to": "Route hinzugefügt zu", + "added-trails-to": "Routen hinzugefügt zu", "after": "Nach", "all-activities": "Alle Aktivitäten", "alphabetical": "Alphabetisch", @@ -275,6 +276,7 @@ "register": "Registrieren", "remote-users-cannot-edit": "Nutzer anderer Instanzen können nicht bearbeiten", "removed-trail-from": "Route entfernt aus", + "removed-trails-from": "Routen entfernt aus", "required": "Pflichtfeld", "reset-password": "Passwort zurücksetzen", "road": "Straße", @@ -289,6 +291,7 @@ "search-places": "Orte suchen", "search-trails": "Route suchen", "select-list": "Liste auswählen", + "selected": "selected", "settings": "Einstellungen", "settings-notification-comment-mention": "Jemand hat dich in einem Kommentar erwähnt", "settings-notification-list-create": "Ein Benutzer, dem Du folgst, hat eine Liste erstellt", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index d965306c..9d442b11 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -14,6 +14,7 @@ "add-to-list": "Add to list", "add-waypoint": "Add Waypoint", "added-trail-to": "Added trail to", + "added-trails-to": "Added trails to", "after": "After", "all-activities": "All activities", "alphabetical": "Alphabetical", @@ -275,6 +276,7 @@ "register": "Register", "remote-users-cannot-edit": "", "removed-trail-from": "Removed trail from", + "removed-trails-from": "Removed trails from", "required": "Required", "reset-password": "Reset Password", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Search places", "search-trails": "Search trails", "select-list": "Select List", + "selected": "", "settings": "Settings", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "A user who you follow has created a list", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 86b4d85a..862c7519 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -14,6 +14,7 @@ "add-to-list": "Añadir a la lista", "add-waypoint": "Añadir Punto de Interés", "added-trail-to": "Ruta añadida a", + "added-trails-to": "Rutas añadida a", "after": "Después", "all-activities": "Todas las actividades", "alphabetical": "Alfabético", @@ -275,6 +276,7 @@ "register": "Registrar", "remote-users-cannot-edit": "", "removed-trail-from": "Ruta borrada de", + "removed-trails-from": "Rutas borrada de", "required": "Obligatorio", "reset-password": "Restablecer Contraseña", "road": "Carretera", @@ -289,6 +291,7 @@ "search-places": "Buscar lugares", "search-trails": "Buscar ruta", "select-list": "Seleccionar Lista", + "selected": "", "settings": "Configuración", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "Un usuario al que sigues ha creado una nueva lista", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index d0fe82da..2d324f93 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -14,6 +14,7 @@ "add-to-list": "Ajouter à une liste", "add-waypoint": "Ajouter un point de passage", "added-trail-to": "Ajouter un itinéraire à", + "added-trails-to": "Ajouter les itinéraires à", "after": "Après", "all-activities": "Toutes les activités", "alphabetical": "Alphabétique", @@ -275,6 +276,7 @@ "register": "Créer un compte", "remote-users-cannot-edit": "", "removed-trail-from": "Enlever l'itinéraire de", + "removed-trails-from": "Enlever les itinéraires de", "required": "Requis", "reset-password": "Réinitialiser le mot de passe", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Chercher des lieux", "search-trails": "Chercher un itinéraire", "select-list": "Liste de choix", + "selected": "", "settings": "Paramètres", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "Un utilisateur que vous suivez à créé une nouvelle liste", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index ed7afb3d..30abea05 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -14,6 +14,7 @@ "add-to-list": "Hozzáadás a listához", "add-waypoint": "Útvonalpont hozzáadása", "added-trail-to": "Hozzáadott nyomvonal a", + "added-trails-to": "Hozzáadott nyomvonalak a", "after": "After", "all-activities": "All activities", "alphabetical": "Betűrendben", @@ -275,6 +276,7 @@ "register": "Regisztráció", "remote-users-cannot-edit": "", "removed-trail-from": "Eltávolított nyomvonal a", + "removed-trails-from": "Eltávolított nyomvonalak a", "required": "Kötelező", "reset-password": "Reset Password", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Search places", "search-trails": "Nyomvonalak keresése", "select-list": "Lista kiválasztása", + "selected": "", "settings": "Beállítások", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "A user who you follow has created a list", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index e87b36c7..ab6a4f8d 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -14,6 +14,7 @@ "add-to-list": "Aggiungi alla lista", "add-waypoint": "Aggiungi un punto di passaggio", "added-trail-to": "Percorso aggiunto a", + "added-trails-to": "Percorsi aggiunto a", "after": "Dopo", "all-activities": "Tutte le Attività", "alphabetical": "Alfabetico", @@ -275,6 +276,7 @@ "register": "Registrati", "remote-users-cannot-edit": "", "removed-trail-from": "Percorso rimosso da", + "removed-trails-from": "Percorsi rimosso da", "required": "Obbligatorio", "reset-password": "Ripristinare Password", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Search places", "search-trails": "Cerca percorsi", "select-list": "Seleziona lista", + "selected": "", "settings": "Impostazioni", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "Un utente che segui ha creato una lista", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 3a72e275..f8b0093c 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -14,6 +14,7 @@ "add-to-list": "Toevoegen aan lijst", "add-waypoint": "Routepunt toevoegen", "added-trail-to": "Route toegevoegd aan", + "added-trails-to": "Route toegevoegd aan", "after": "Na", "all-activities": "Alle activiteiten", "alphabetical": "Alfabetisch", @@ -275,6 +276,7 @@ "register": "Registreren", "remote-users-cannot-edit": "", "removed-trail-from": "Route verwijderd van", + "removed-trails-from": "Routes verwijderd van", "required": "Verplicht", "reset-password": "Wachtwoord opnieuw instellen", "road": "Weg", @@ -289,6 +291,7 @@ "search-places": "Zoek plaatsen", "search-trails": "Zoek routes", "select-list": "Kies een lijst", + "selected": "", "settings": "Instellingen", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "Een gebruiker die je volgt, heeft een lijst gecreëerd", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index dd3a55ed..3f71a1fe 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -14,6 +14,7 @@ "add-to-list": "Dodaj do listy", "add-waypoint": "Dodaj Punkt", "added-trail-to": "Dodaj szlak do", + "added-trails-to": "Dodaj szlaki do", "after": "Po", "all-activities": "Wszystkie aktywności", "alphabetical": "Alfabetyczne", @@ -275,6 +276,7 @@ "register": "Zarejestruj", "remote-users-cannot-edit": "", "removed-trail-from": "Usunięto szlak z", + "removed-trails-from": "Usunięto szlaki z", "required": "Wymagane", "reset-password": "Resetuj hasło", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Szukaj miejsc", "search-trails": "Szukaj szlaków", "select-list": "Wybierz Listę", + "selected": "", "settings": "Ustawienia", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "Użytkownik, którego obserwujesz, utworzył listę", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 5eed9766..3089a6c8 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -14,6 +14,7 @@ "add-to-list": "Adicionar à lista", "add-waypoint": "Adicionar ponto de vista", "added-trail-to": "Trilha adicionada para", + "added-trails-to": "trilhas adicionada para", "after": "Depois", "all-activities": "Todas as atividades", "alphabetical": "Alfabético", @@ -275,6 +276,7 @@ "register": "Registo", "remote-users-cannot-edit": "", "removed-trail-from": "Trilha removida de", + "removed-trails-from": "Trilhos removidos de", "required": "Obrigatório", "reset-password": "Reset Password", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Search places", "search-trails": "Procurar trilhos", "select-list": "Selecionar lista", + "selected": "", "settings": "Definições", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "A user who you follow has created a list", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 5c66edab..79458fe3 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -14,6 +14,7 @@ "add-to-list": "添加到列表", "add-waypoint": "添加坐标", "added-trail-to": "添加路线到", + "added-trails-to": "添加路线到", "after": "之后", "all-activities": "所有活动", "alphabetical": "字母", @@ -275,6 +276,7 @@ "register": "注册", "remote-users-cannot-edit": "", "removed-trail-from": "路线已删除自", + "removed-trails-from": "路线已删除自", "required": "必填", "reset-password": "重置密码", "road": "Road", @@ -289,6 +291,7 @@ "search-places": "Search places", "search-trails": "搜索路线", "select-list": "选择列表", + "selected": "", "settings": "设置", "settings-notification-comment-mention": "Someone mentioned you in a comment", "settings-notification-list-create": "A user who you follow has created a list", diff --git a/web/src/lib/util/gpx_util.ts b/web/src/lib/util/gpx_util.ts index c1b02eb1..e0b32f50 100644 --- a/web/src/lib/util/gpx_util.ts +++ b/web/src/lib/util/gpx_util.ts @@ -14,6 +14,8 @@ import JSZip from "jszip"; import type { AuthRecord } from "pocketbase"; import * as xmldom from 'xmldom'; import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util"; +import { trails_show } from "$lib/stores/trail_store"; +import { handleFromRecordWithIRI } from "./activitypub_util"; export async function gpx2trail(gpxString: string, fallbackName?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { @@ -71,10 +73,21 @@ export async function gpx2trail(gpxString: string, fallbackName?: string, f: (ur } export async function trail2gpx(trail: Trail, user?: AuthRecord) { + let gpxTrail = trail; + if (!trail.expand?.gpx_data) { - throw Error("Trail has no GPX data") + // no gpx_data -> empty trail? + // or just not expanded? -> expand now + const response = await trails_show(trail.id!, handleFromRecordWithIRI(trail), true); + + if (!response.expand?.gpx_data) { + throw Error("Trail has no GPX data") + } else { + gpxTrail = response; + } } - const gpx = await GPX.parse(trail.expand.gpx_data) as GPX; + + const gpx = await GPX.parse(gpxTrail.expand!.gpx_data!) as GPX; if (gpx instanceof Error) { throw gpx; @@ -92,7 +105,7 @@ export async function trail2gpx(trail: Trail, user?: AuthRecord) { gpx.wpt = []; } - for (const wp of trail.expand.waypoints ?? []) { + for (const wp of gpxTrail.expand!.waypoints ?? []) { const gpxWpt = gpx.wpt.find((w) => w.$.lat == wp.lat && w.$.lon == wp.lon) if (!gpxWpt) { gpx.wpt.push({