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 <>
This commit is contained in:
slothful-vassal
2025-06-26 15:28:27 +02:00
committed by GitHub
parent 9b6522ec21
commit 06f6d58b5e
22 changed files with 666 additions and 163 deletions

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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<Trail> | 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 @@
<h5 class="text-md font-semibold">{list.name}</h5>
<i
class="fa fa-{listContainsCurrentTrail(list)
class="fa fa-{listContainsAllTrails(list)
? 'minus'
: 'plus'} rounded-full border border-input-border p-2"
></i>

View File

@@ -18,15 +18,21 @@
interface Props {
trail: Trail;
fullWidth?: boolean;
selected: boolean;
hovered: boolean;
onmouseenter?: MouseEventHandler<HTMLDivElement>;
onmouseleave?: MouseEventHandler<HTMLDivElement>;
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}
</div>
{#if $currentUser && trail.like_count > 0}
{#if hovered || selected}
<div
class="flex absolute items-center justify-center top-4 left-4 bg-background w-8 h-8 rounded-full"
class="flex absolute top-4 left-4 w-8 h-8 rounded-full items-center justify-center bg-background text-content"
>
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="absolute pointer-events-none left-5 -top-1 text-xs rounded-full bg-menu-background px-1 text-center"
>
{trail.like_count}
</div>
<input
id="trail-selected"
type="checkbox"
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
bind:checked={selected}
onclick={(e) => handleInputClick(e)}
/>
</div>
{/if}
{#if (trail.public || trailIsShared) && $currentUser}
@@ -121,6 +131,20 @@
{/if}
</div>
{/if}
{#if $currentUser && trail.like_count > 0}
<div
class="flex absolute items-center justify-center {trailIsShared || trail.public ? 'top-14': 'top-4'} right-4 bg-background w-8 h-8 rounded-full"
>
<span class="tooltip" data-title={$_("likes")}>
<i class="fa fa-heart"></i>
</span>
<div
class="absolute pointer-events-none left-5 -top-1 text-xs rounded-full bg-menu-background px-1 text-center"
>
{trail.like_count}
</div>
</div>
{/if}
<div class="p-4">
<div>
<h4 class="font-semibold text-lg line-clamp-2">{trail.name}</h4>
@@ -162,9 +186,9 @@
type="button"
>
{#if expandedTags}
{$_('show-less')}
{$_("show-less")}
{:else}
+{trail.tags.length - 2} {$_('more')}
+{trail.tags.length - 2} {$_("more")}
{/if}
</button>
{/if}

View File

@@ -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<Trail> | 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<Trail> | 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<boolean> {
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;
}
</script>
<Dropdown items={dropdownItems} onchange={(item) => handleDropdownClick(item)}
<Dropdown items={dropdownItems()} onchange={(item) => handleDropdownClick(item)}
>{#snippet children({ toggleMenu: openDropdown })}
<button
aria-label="Open dropdown"
class=" btn-primary !rounded-full h-12 w-12"
onclick={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{#if mode == "multi-select"}
<button
aria-label="Open dropdown"
class="btn-primary flex-shrink-0 !font-medium"
onclick={openDropdown}
>
<span
>{trails?.size}
{$_("selected")} <i class="fa fa-caret-down ml-1"></i></span
>
</button>
{:else}
<button
aria-label="Open dropdown"
class=" btn-primary !rounded-full h-12 w-12"
onclick={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{/if}
{/snippet}
</Dropdown>
<ConfirmModal
text={$_("delete-trail-confirm")}
bind:this={confirmModal}
onconfirm={deleteTrail}
onconfirm={deleteTrails}
></ConfirmModal>
<ListSelectModal
{lists}
trails={getTrails()}
bind:this={listSelectModal}
onchange={(list) => handleListSelection(list)}
></ListSelectModal>
<TrailExportModal
bind:this={trailExportModal}
onexport={(settings) => exportTrail(settings)}
onexport={(settings) => exportTrails(settings)}
></TrailExportModal>
<TrailShareModal {trail} bind:this={trailShareModal}></TrailShareModal>
<TrailShareModal
trail={trail()}
onsave={handleShareUpdate}
bind:this={trailShareModal}
></TrailShareModal>

View File

@@ -401,7 +401,10 @@
{#if ($currentUser && $currentUser.actor == trail.author) || trail.expand?.trail_share_via_trail?.length || trail.public}
<div class="flex flex-col items-center gap-y-2">
<LikeButton {trail}></LikeButton>
<TrailDropdown {trail} {mode} {handle}></TrailDropdown>
<TrailDropdown
trails={new Set<Trail>([trail])}
{mode}
></TrailDropdown>
</div>
{/if}
</div>
@@ -658,7 +661,7 @@
elevationProfileContainer={"epc-container"}
showStyleSwitcher={false}
showFullscreen={true}
mapOptions={{ attributionControl: {compact: true} }}
mapOptions={{ attributionControl: { compact: true } }}
onfullscreen={toggleMapFullScreen}
bind:markers
></MapWithElevationMaplibre>

View File

@@ -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<Trail> | undefined,
) => void;
onpagination?: (page: number) => void;
}
@@ -42,6 +46,9 @@
let selectedDisplayOption = $state(displayOptions[0].value);
let selection: Set<Trail> | 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<Trail>();
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);
}
</script>
<div class="min-w-0">
@@ -122,6 +219,15 @@
{onpagination}
></Pagination>
</div>
{#if selection !== undefined && selection.size > 0}
<div class="flex relative flex-shrink-0">
<TrailDropdown
trails={selection}
mode={"multi-select"}
onconfirm={handleTrailsEditDone}
/>
</div>
{/if}
{#if filter}
<div class="shrink-0">
{#if selectedDisplayOption !== "table"}
@@ -158,7 +264,10 @@
<div id="trails" class="flex items-start flex-wrap gap-8 py-8 max-w-full">
{#if loading}
{#if selectedDisplayOption === "table"}
<TrailTable trails={null} tableHeader={sortOptions}
<TrailTable
trails={null}
selection={new Set<Trail>()}
tableHeader={sortOptions}
></TrailTable>
{:else}
{#each { length: 12 } as _, index}
@@ -180,11 +289,13 @@
{#if selectedDisplayOption === "table"}
<TrailTable
{trails}
{selection}
tableHeader={sortOptions.filter(
(option) => option.value !== "elevation_loss",
)}
{filter}
onsort={handleSortUpdate}
onTrailSelect={(t) => handleSelectionUpdate(t)}
></TrailTable>
{: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"}
<TrailCard fullWidth={fullWidthCards} {trail}
<TrailCard
fullWidth={fullWidthCards}
{trail}
selected={isSelected(trail)}
hovered={isHovered(trail)}
onTrailSelect={() =>
handleSelectionUpdate(trail)}
></TrailCard>
{:else}
<TrailListItem {trail}></TrailListItem>
<TrailListItem
{trail}
selected={isSelected(trail)}
hovered={isHovered(trail)}
onTrailSelect={() =>
handleSelectionUpdate(trail)}
></TrailListItem>
{/if}
</a>
{/each}

View File

@@ -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}
</div>
<div class="min-w-0 basis-full">
<div class="min-w-0 basis-full relative">
<div class="flex items-center justify-between">
<h4 class="font-semibold text-lg">
{trail.name}
@@ -139,9 +154,6 @@
{#if trail.location}
<h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>
{/if}
<h5>
<i class="fa fa-gauge mr-3"></i>{$_(trail.difficulty ?? "?")}
</h5>
</div>
<div class="flex flex-wrap mt-1 gap-x-4 gap-y-2 text-sm text-gray-500">
@@ -168,10 +180,23 @@
</div>
{#if showDescription}
<p
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis"
class="mt-3 text-sm whitespace-nowrap min-w-0 max-w-full overflow-hidden text-ellipsis basis-full"
>
{formatHTMLAsText(trail.description ?? "")}
</p>
{/if}
{#if hovered || selected}
<div
class="flex absolute bottom-0 right-0 w-8 h-8 rounded-full items-center justify-center bg-background text-content"
>
<input
id="trail-selected"
type="checkbox"
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
bind:checked={selected}
onclick={(e) => handleInputClick(e)}
/>
</div>
{/if}
</div>
</li>

View File

@@ -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;

View File

@@ -14,14 +14,18 @@
interface Props {
tableHeader: SelectItem[];
trails?: Trail[] | null;
selection: Set<Trail> | 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;
}
</script>
<div
class="table-container w-full border border-input-border rounded-xl overflow-x-scroll overflow-y-clip"
class="table-container w-full border border-input-border rounded-xl overflow-x-auto overflow-y-clip"
>
<table class="w-full">
<thead>
<tr class="bg-secondary-hover">
<th
class="p-4 text-left text-sm font-medium {getColumnWidth("select")}"
>
<div class="flex items-center">
<input
id="trail-selected"
type="checkbox"
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
onclick={setSelectedAllTrails}
checked={allSelected()}
/>
</div>
</th>
{#each tableHeader as column}
<th
class="p-4 text-left text-sm font-medium {getColumnWidth(
@@ -78,6 +136,16 @@
}/${trail.id}`,
)}
>
<td class="p-4 text-sm">
<div class="flex items-center">
<input
type="checkbox"
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
checked={isSelected(trail)}
onclick={(e: Event) => setSelectedTrail(e, trail)}
/>
</div>
</td>
<td
class="flex justify-between items-center text-sm relative"
>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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ę",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Response> = 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({