implements trail sharing

This commit is contained in:
Christian Beutel
2024-06-06 02:02:23 +02:00
parent 5d46ad96b4
commit ad5a4e668b
44 changed files with 1257 additions and 308 deletions

View File

@@ -8,6 +8,7 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
export let items: DropdownItem[] = [];
export let size: string = "regular";
@@ -30,26 +31,38 @@
dispatch("change", item);
closeMenu();
}
function handleWindowClick(e: MouseEvent) {
if ((e.target as HTMLElement).parentElement?.classList.contains("dropdown-toggle")) {
return;
}
isOpen = false;
}
</script>
<svelte:window on:mouseup={() => (isOpen = false)} />
<svelte:window on:mouseup={handleWindowClick} />
<div class="dropdown relative">
<slot {toggleMenu}>
<button
class="btn-icon flex items-center justify-center"
on:click={toggleMenu}
type="button"
>
<i class="fa fa-ellipsis-vertical text-{size}"></i>
</button>
</slot>
<div class="dropdown-toggle">
<slot {toggleMenu}>
<button
class="btn-icon flex items-center justify-center"
on:click={toggleMenu}
type="button"
>
<i class="fa fa-ellipsis-vertical text-{size}"></i>
</button>
</slot>
</div>
{#if isOpen}
<ul
class="menu absolute bg-menu-background border border-input-border rounded-l-xl rounded-b-xl shadow-md right-0 overflow-hidden mt-2"
class:none={isOpen}
style="z-index: 1001"
in:fly={{ y: -10, duration: 150 }}
out:fly={{ y: -10, duration: 150 }}
>
{#each items as item}
<li

View File

@@ -28,7 +28,7 @@
class="relative w-full {size} max-h-full rounded-xl text-content"
>
<!-- Modal content -->
<div class="relative bg-background shadow">
<div class="relative bg-background shadow rounded-xl">
<!-- Modal header -->
<div
class="flex items-center justify-between p-4 md:p-5 border-b border-separator rounded-t"

View File

@@ -97,7 +97,9 @@
on:mousedown|stopPropagation={() => handleItemClick(item)}
on:keydown|stopPropagation={() => handleItemClick(item)}
>
<i class="fa fa-{item.icon} mr-6"></i>
<slot name="item-header" {item}>
<i class="fa fa-{item.icon} mr-6"></i>
</slot>
<div>
<p>{item.text}</p>

View File

@@ -8,6 +8,7 @@
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import TrailShareInfo from "./trail_share_info.svelte";
export let trail: Trail;
@@ -22,20 +23,25 @@
on:mouseleave
role="listitem"
>
{#if trail.public}
<div
class=" top-4 right-4 z-10 tooltip"
style="position: absolute!important"
data-title={$_("public")}
>
<i class="fa fa-globe"></i>
</div>
{/if}
<div
class="relative w-full min-h-40 max-h-48 overflow-hidden rounded-t-2xl"
>
<img src={thumbnail} alt="" />
</div>
{#if trail.public || trail.expand?.trail_share_via_trail?.length}
<div
class="flex absolute top-4 right-4 w-8 h-8 rounded-full items-center justify-center bg-background text-content"
>
{#if trail.public}
<span class="tooltip" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<TrailShareInfo {trail}></TrailShareInfo>
{/if}
</div>
{/if}
<div class="p-4">
<div>
<h4 class="font-semibold text-lg">{trail.name}</h4>
@@ -45,7 +51,7 @@
month: "long",
day: "2-digit",
year: "numeric",
timeZone: 'UTC'
timeZone: "UTC",
})}
</p>
{/if}
@@ -65,12 +71,12 @@
<div class="flex mt-1 gap-4 text-sm text-gray-500 whitespace-nowrap">
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
trail.distance
trail.distance,
)}</span
>
<span
><i class="fa fa-up-down mr-2"></i>{formatElevation(
trail.elevation_gain
trail.elevation_gain,
)}</span
>
<span

View File

@@ -19,6 +19,8 @@
import JSZip from "jszip";
import { getFileURL, saveAs } from "$lib/util/file_util";
import TrailShareModal from "./trail_share_modal.svelte";
import { currentUser } from "$lib/stores/user_store";
import { pb } from "$lib/pocketbase";
export let trail: Trail;
export let mode: "overview" | "map";
@@ -28,6 +30,10 @@
let openExportModal: () => void;
let openShareModal: () => void;
const allowEdit =
trail.author == $currentUser?.id ||
trail.expand.trail_share_via_trail?.some((s) => s.permission == "edit");
const dropdownItems: DropdownItem[] = [
mode == "overview"
? { text: $_("show-on-map"), value: "show", icon: "map" }
@@ -49,9 +55,15 @@
: []),
{ text: $_("print"), value: "print", icon: "print" },
{ text: $_("add-to-list"), value: "list", icon: "bookmark" },
{ text: $_("share"), value: "share", icon: "share" },
{ text: $_("edit"), value: "edit", icon: "pen" },
{ text: $_("delete"), value: "delete", icon: "trash" },
...(trail.author != pb.authStore.model?.id
? []
: [{ text: $_("share"), value: "share", icon: "share" }]),
...(allowEdit
? [{ text: $_("edit"), value: "edit", icon: "pen" }]
: []),
...(trail.author == $currentUser?.id
? [{ text: $_("delete"), value: "delete", icon: "trash" }]
: []),
];
async function handleDropdownClick(item: { text: string; value: any }) {
@@ -72,7 +84,7 @@
?.focus();
} else if (item.value == "print") {
goto(`/map/trail/${trail.id}/print`);
} else if (item.value == "share") {
} else if (item.value == "share") {
openShareModal();
} else if (item.value == "download") {
openExportModal();
@@ -189,4 +201,4 @@
bind:openModal={openExportModal}
on:export={(e) => exportTrail(e.detail)}
></TrailExportModal>
<TrailShareModal bind:openModal={openShareModal}></TrailShareModal>
<TrailShareModal {trail} bind:openShareModal></TrailShareModal>

View File

@@ -31,6 +31,7 @@
import Textarea from "../base/textarea.svelte";
import CommentCard from "../comment/comment_card.svelte";
import PhotoGallery from "../photo_gallery.svelte";
import TrailShareInfo from "./trail_share_info.svelte";
export let trail: Trail;
export let mode: "overview" | "map" = "map";
@@ -93,7 +94,7 @@
shadowUrl: "",
},
})
.on("loaded", function (e) {
.on("loaded", function (e) {
map.fitBounds(e.target.getBounds());
})
.addTo(map);
@@ -152,14 +153,31 @@
</script>
<div
class="trail-info-panel max-w-5xl mx-auto border border-input-border rounded-3xl overflow-x-hidden h-full"
class="trail-info-panel max-w-5xl mx-auto border border-input-border rounded-3xl h-full"
>
<div class="trail-info-panel-header sticky -top-44 z-10">
<section class="relative h-80">
<img class="w-full h-80" src={thumbnail} alt="" />
<img class="w-full h-80 rounded-3xl" src={thumbnail} alt="" />
<div
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
></div>
{#if trail.public || trail.expand?.trail_share_via_trail?.length}
<div
class="flex absolute top-8 right-10 w-8 h-8 rounded-full items-center justify-center bg-white text-primary"
>
{#if trail.public}
<span
class="tooltip text-2xl"
data-title={$_("public")}
>
<i class="fa fa-globe"></i>
</span>
{/if}
{#if trail.expand?.trail_share_via_trail?.length}
<TrailShareInfo {trail} large={true}></TrailShareInfo>
{/if}
</div>
{/if}
<div
class="flex absolute justify-between items-end w-full bottom-8 left-0 px-8 gap-y-4"
>
@@ -193,7 +211,7 @@
</h3>
</div>
</div>
{#if $currentUser && $currentUser.id == trail.author}
{#if ($currentUser && $currentUser.id == trail.author) || trail.expand.trail_share_via_trail?.length}
<TrailDropdown {trail} {mode}></TrailDropdown>
{/if}
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { page } from "$app/stores";
import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util";
import {
@@ -8,6 +7,7 @@
formatTimeHHMM,
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import TrailShareInfo from "./trail_share_info.svelte";
export let trail: Trail;
@@ -23,20 +23,26 @@
<img class="h-28 w-28 object-cover rounded-xl" src={thumbnail} alt="" />
</div>
<div class="min-w-0 basis-full">
<h4 class="font-semibold text-lg">
{trail.name}{#if trail.public}
<div class="flex items-center gap-4">
<h4 class="font-semibold text-lg">
{trail.name}
</h4>
{#if trail.public}
<span class="tooltip ml-3" data-title={$_("public")}>
<i class="fa fa-globe"></i>
</span>
{/if}
</h4>
{#if trail.expand?.trail_share_via_trail?.length}
<TrailShareInfo {trail}></TrailShareInfo>
{/if}
</div>
{#if trail.date}
<p class="text-xs text-gray-500 mb-3">
{new Date(trail.date).toLocaleDateString(undefined, {
month: "long",
day: "2-digit",
year: "numeric",
timeZone: 'UTC'
timeZone: "UTC",
})}
</p>
{/if}
@@ -54,12 +60,12 @@
<div class="flex mt-1 gap-4 text-sm text-gray-500">
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
trail.distance
trail.distance,
)}</span
>
<span
><i class="fa fa-up-down mr-2"></i>{formatElevation(
trail.elevation_gain
trail.elevation_gain,
)}</span
>
<span

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import type { User } from "$lib/models/user";
import { pb } from "$lib/pocketbase";
import { users_show } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import { fly, slide } from "svelte/transition";
export let trail: Trail;
export let large: boolean = false;
let showInfo: boolean = false;
let loading: boolean = false;
let infoLoaded: boolean = false;
let trailIsOwned: boolean = trail.author == pb.authStore.model?.id;
let author: User;
async function fetchInfo() {
if (!infoLoaded) {
loading = true;
loading = false;
if (trailIsOwned) {
for (const share of trail.expand.trail_share_via_trail ?? []) {
share.expand = {
user: await users_show(share.user),
};
}
} else {
author = await users_show(trail.author!);
}
infoLoaded = true;
}
showInfo = true;
}
</script>
<div class="relative trail-share-info">
<i
class="fa fa-share-nodes"
class:text-xl={large}
role="button"
tabindex="0"
on:mouseenter={fetchInfo}
on:mouseleave={() => (showInfo = false)}
></i>
{#if showInfo}
<ul
class="menu absolute top-7 bg-menu-background border border-input-border rounded-xl shadow-md overflow-hidden p-3 space-y-3 text-content"
in:fly={{y: -10, duration: 150}} out:fly={{y: -10, duration: 150}}
>
{#if loading}
<div class="spinner spinner-dark"></div>
{:else if infoLoaded && trail.expand.trail_share_via_trail}
{#if trailIsOwned}
{#each trail.expand.trail_share_via_trail as share}
{#if share.expand}
<li>
<div class="flex items-center mr-8">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="font-semibold text-base">
{share.expand.user.username}
</p>
<span class="mx-2 text-sm text-gray-500"
>{$_("can")}</span
>
<p class="whitespace-nowrap text-base">
{#if share.permission == "view"}
<i class="fa fa-eye"></i>
{:else}
<i class="fa fa-pen"></i>
{/if}
</p>
</div>
</li>
{/if}
{/each}
{:else if author}
<li>
<p class="text-xs text-gray-500 mb-2">
{$_("shared-by")}
</p>
<div class="flex items-center mr-8">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(author, author.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p class="font-semibold">
{author.username}
</p>
</div>
<p class="text-xs text-gray-500 mt-2">
{$_("you-can")}
</p>
<p class="whitespace-nowrap">
{#if trail.expand.trail_share_via_trail[0].permission == "view"}
<i class="fa fa-eye mr-1"></i>
{$_("view")}
{:else}
<i class="fa fa-pen mr-1"></i>
{$_("edit")}
{/if}
</p>
</li>
{/if}
{/if}
</ul>
{/if}
</div>

View File

@@ -1,15 +1,35 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import type { Trail } from "$lib/models/trail";
import { TrailShare } from "$lib/models/trail_share";
import type { User } from "$lib/models/user";
import { show_toast } from "$lib/stores/toast_store";
import {
shares,
trail_share_create,
trail_share_delete,
trail_share_index,
trail_share_update,
} from "$lib/stores/trail_share_store";
import { users_search } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import Button from "../base/button.svelte";
import Search, { type SearchItem } from "../base/search.svelte";
import { users_search } from "$lib/stores/user_store";
import type { User } from "$lib/models/user";
import { show_toast } from "$lib/stores/toast_store";
import type { SelectItem } from "../base/select.svelte";
import Select from "../base/select.svelte";
export let openModal: (() => void) | undefined = undefined;
let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openShareModal() {
fetchShares();
if (openModal) {
openModal();
}
}
export let trail: Trail;
const dispatch = createEventDispatcher();
@@ -17,14 +37,21 @@
let searchItems: SearchItem[] = [];
let sharesLoading: boolean = false;
const permissionSelectItems: SelectItem[] = [
{ text: $_("view"), value: "view" },
{ text: $_("edit"), value: "edit" },
];
async function updateUsers(q: string) {
try {
const users: User[] = await users_search(q);
searchItems = users.map((u) => ({
text: u.username!,
value: u.id,
icon: "user"
}))
value: u,
icon: "user",
}));
} catch (e) {
console.error(e);
show_toast({
@@ -47,21 +74,90 @@
dispatch("save");
closeModal!();
}
async function shareTrail(item: SelectItem) {
const share = new TrailShare(item.value.id, trail.id!, "view");
await trail_share_create(share);
fetchShares();
}
async function updateSharePermission(share:TrailShare, permission: "view"| "edit" ) {
share.permission = permission;
await trail_share_update(share);
}
async function deleteShare(share: TrailShare) {
await trail_share_delete(share);
fetchShares();
}
async function fetchShares() {
sharesLoading = true;
await trail_share_index(trail.id!);
sharesLoading = false;
}
</script>
<Modal
id="share-modal"
title="Share this trail"
size="max-w-lg"
title={$_('share-this-trail')}
size="max-w-sm overflow-visible"
bind:openModal
bind:closeModal
>
<div slot="content">
<Search
on:update={(e) => updateUsers(e.detail)}
on:click={(e) => shareTrail(e.detail)}
placeholder={`${$_("username")}`}
items={searchItems}
></Search>
>
<img
slot="item-header"
let:item
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(item.value, item.value.avatar) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${item.value.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
</Search>
<h4 class="font-semibold mt-4">{$_('shared-with')}</h4>
{#if $shares.length == 0}
<p class="text-gray-500 text-center mt-2 text-sm">
{$_('trail-not-shared')}
</p>
{:else}
{#each $shares as share}
{#if share.expand}
<div class="flex items-center gap-x-2 p-2">
<img
class="rounded-full w-8 aspect-square mr-2"
src={getFileURL(
share.expand.user,
share.expand.user.avatar,
) ||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.user.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<p>{share.expand.user.username}</p>
<span class="basis-full text-sm text-center text-gray-500">{$_('can')}</span>
<div class="shrink-0">
<Select
bind:value={share.permission}
items={permissionSelectItems}
on:change={(e) => updateSharePermission(share, e.detail)}
></Select>
</div>
<button class="btn-icon text-red-500"
on:click={() => deleteShare(share)}
><i class="fa fa-trash"></i></button
>
</div>
{/if}
{/each}
{/if}
</div>
<div slot="footer" class="flex justify-between items-center gap-4">
<Button