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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user