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

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