adds notifications
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<div
|
||||
class="skeleton-notification-card animate-pulse flex items-center gap-x-3 px-3 py-2 m-2 bg-menu-background rounded-xl"
|
||||
>
|
||||
<!-- Avatar placeholder -->
|
||||
<div
|
||||
class="h-8 w-8 bg-menu-item-background-focus rounded-full shrink-0"
|
||||
></div>
|
||||
|
||||
<!-- Text placeholders -->
|
||||
<div class="basis-full space-y-2">
|
||||
<div class="h-4 bg-menu-item-background-focus rounded"></div>
|
||||
<div class="h-4 bg-menu-item-background-focus rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let name: string = "";
|
||||
export let value: boolean = false;
|
||||
export let label: string = "";
|
||||
export let error: string = "";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleToggleChange() {
|
||||
dispatch("change", value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="relative my-2 inline-flex items-center cursor-pointer">
|
||||
@@ -12,6 +20,7 @@
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
value="1"
|
||||
on:change={handleToggleChange}
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
|
||||
|
||||
@@ -35,20 +35,20 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 items-center"
|
||||
class="flex gap-4 items-start"
|
||||
in:fade={{ duration: 150 }}
|
||||
out:fade={{ duration: 150 }}
|
||||
>
|
||||
{#if comment.expand?.author.private}
|
||||
<img
|
||||
class="rounded-full w-10 aspect-square"
|
||||
class="rounded-full w-10 aspect-square shrink-0"
|
||||
src={avatarSrc}
|
||||
alt="avatar"
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
href="/profile/{comment.expand?.author.id}"
|
||||
class="text-sm font-semibold"
|
||||
class="text-sm font-semibold shrink-0"
|
||||
>
|
||||
<img
|
||||
class="rounded-full w-10 aspect-square"
|
||||
@@ -97,7 +97,7 @@
|
||||
<TextField extraClasses="mt-2" bind:value={editedComment}
|
||||
></TextField>
|
||||
{:else}
|
||||
<p>{comment.text}</p>
|
||||
<p class="whitespace-pre-wrap text-sm">{comment.text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
import { theme, toggleTheme } from "$lib/stores/theme_store";
|
||||
import { currentUser, logout } from "$lib/stores/user_store";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { backInOut, cubicOut } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
import Drawer from "./base/drawer.svelte";
|
||||
import Dropdown from "./base/dropdown.svelte";
|
||||
import LogoTextLight from "./logo/logo_text_light.svelte";
|
||||
import { _, format } from "svelte-i18n";
|
||||
import { page } from "$app/stores";
|
||||
import NotificationCard from "./notification/notification_card.svelte";
|
||||
import NotificationDropdown from "./notification/notification_dropdown.svelte";
|
||||
|
||||
let navBarItems = [
|
||||
{ text: "Home", value: "/" },
|
||||
@@ -200,6 +203,7 @@
|
||||
<a class="btn-primary btn-large" href="/trail/edit/new"
|
||||
><i class="fa fa-plus mr-2"></i>{$_("new-trail")}</a
|
||||
>
|
||||
<NotificationDropdown></NotificationDropdown>
|
||||
<Dropdown
|
||||
items={dropdownItems}
|
||||
on:change={(e) => handleDropdownClick(e.detail)}
|
||||
|
||||
126
web/src/lib/components/notification/notification_card.svelte
Normal file
126
web/src/lib/components/notification/notification_card.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
NotificationType,
|
||||
type Notification,
|
||||
} from "$lib/models/notification";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { formatTimeSince } from "$lib/util/format_util";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let notification: Notification;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const avatarSrc = notification.expand?.author.avatar
|
||||
? getFileURL(
|
||||
notification.expand.author,
|
||||
notification.expand.author.avatar,
|
||||
)
|
||||
: `https://api.dicebear.com/7.x/initials/svg?seed=${notification.expand?.author.username ?? ""}&backgroundType=gradientLinear`;
|
||||
|
||||
const timeSince = formatTimeSince(new Date(notification.created ?? ""));
|
||||
|
||||
$: title = getTitle(notification);
|
||||
$: description = getDescription(notification);
|
||||
$: link = getLink(notification);
|
||||
|
||||
function getTitle(n: Notification) {
|
||||
switch (n.type) {
|
||||
case NotificationType.listCreate:
|
||||
return $_("notification-list-create", {
|
||||
values: { user: n.expand.author.username },
|
||||
});
|
||||
case NotificationType.listShare:
|
||||
return $_("notification-list-share", {
|
||||
values: { user: n.expand.author.username },
|
||||
});
|
||||
case NotificationType.newFollower:
|
||||
return $_("notification-new-follower");
|
||||
case NotificationType.trailComment:
|
||||
return $_("notification-trail-comment", {
|
||||
values: {
|
||||
user: n.expand.author.username,
|
||||
trail: n.metadata?.trail,
|
||||
},
|
||||
});
|
||||
case NotificationType.trailCreate:
|
||||
return $_("notification-new-trail", {
|
||||
values: { user: n.expand.author.username },
|
||||
});
|
||||
case NotificationType.trailShare:
|
||||
return $_("notification-trail-share", {
|
||||
values: { user: n.expand.author.username },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getDescription(n: Notification) {
|
||||
switch (n.type) {
|
||||
case NotificationType.listCreate:
|
||||
return n.metadata?.list ?? "";
|
||||
case NotificationType.listShare:
|
||||
return n.metadata?.list ?? "";
|
||||
case NotificationType.newFollower:
|
||||
return n.expand.author.username ?? "";
|
||||
case NotificationType.trailComment:
|
||||
return n.metadata?.comment ?? "";
|
||||
case NotificationType.trailCreate:
|
||||
return n.metadata?.trail ?? "";
|
||||
case NotificationType.trailShare:
|
||||
return n.metadata?.trail ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function getLink(n: Notification) {
|
||||
switch (n.type) {
|
||||
case NotificationType.listCreate:
|
||||
return `/lists?list=${n.metadata?.id}`;
|
||||
case NotificationType.listShare:
|
||||
return `/lists?list=${n.metadata?.id}`;
|
||||
case NotificationType.newFollower:
|
||||
return n.expand.author.private === true
|
||||
? null
|
||||
: `/profile/${n.author}`;
|
||||
case NotificationType.trailComment:
|
||||
return `/trail/view/${n.metadata?.id}`;
|
||||
case NotificationType.trailCreate:
|
||||
return `/trail/view/${n.metadata?.id}`;
|
||||
case NotificationType.trailShare:
|
||||
return `/trail/view/${n.metadata?.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick() {
|
||||
dispatch("click", { notification, link });
|
||||
}
|
||||
</script>
|
||||
|
||||
<li
|
||||
class="flex items-center gap-x-3 px-3 py-2 hover:bg-menu-item-background-hover relative cursor-pointer"
|
||||
role="presentation"
|
||||
on:click={handleItemClick}
|
||||
>
|
||||
<img class="rounded-full w-8 aspect-square" src={avatarSrc} alt="avatar" />
|
||||
<div>
|
||||
<p
|
||||
class="text-sm {notification.seen
|
||||
? 'font-medium'
|
||||
: 'font-semibold'} mr-3"
|
||||
>
|
||||
{title}:
|
||||
</p>
|
||||
<p class="text-sm line-clamp-1">{description}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{$_(`n-${timeSince.unit}-ago`, {
|
||||
values: { n: timeSince.value },
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !notification.seen}
|
||||
<div
|
||||
class="bg-content w-[6px] aspect-square rounded-full absolute top-3 right-3"
|
||||
></div>
|
||||
{/if}
|
||||
</li>
|
||||
141
web/src/lib/components/notification/notification_dropdown.svelte
Normal file
141
web/src/lib/components/notification/notification_dropdown.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import type { Notification } from "$lib/models/notification";
|
||||
import { fly } from "svelte/transition";
|
||||
import NotificationCard from "./notification_card.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import {
|
||||
notifications_index,
|
||||
notifications_mark_as_seen,
|
||||
} from "$lib/stores/notification_store";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import SkeletonListItem from "../base/skeleton_list_item.svelte";
|
||||
import SkeletonNotificationCard from "../base/skeleton_notification_card.svelte";
|
||||
|
||||
let notifications: Notification[] = [];
|
||||
|
||||
const pagination = {
|
||||
page: $page.data.notifications.page,
|
||||
totalPages: $page.data.notifications.totalPages,
|
||||
};
|
||||
|
||||
let loadingNextPage: boolean = false;
|
||||
let isOpen = false;
|
||||
|
||||
$: unreadCount = notifications.reduce(
|
||||
(value, n) => (value += n.seen ? 0 : 1),
|
||||
0,
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!notifications.length && $page.data.notifications?.items?.length) {
|
||||
notifications = $page.data.notifications.items;
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
isOpen = !isOpen;
|
||||
pagination.page = 0;
|
||||
await loadNextPage();
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
if (
|
||||
(e.target as HTMLElement).parentElement?.classList.contains(
|
||||
"dropdown-toggle",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
async function onListScroll(e: Event) {
|
||||
const container = e.target as HTMLDivElement;
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
if (
|
||||
scrollTop + clientHeight >= scrollHeight * 0.8 &&
|
||||
pagination.page !== pagination.totalPages &&
|
||||
!loadingNextPage
|
||||
) {
|
||||
await loadNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextPage() {
|
||||
loadingNextPage = true;
|
||||
|
||||
if (!$currentUser) {
|
||||
return;
|
||||
}
|
||||
pagination.page += 1;
|
||||
const result = await notifications_index(
|
||||
{ recipient: $currentUser.id },
|
||||
pagination.page,
|
||||
);
|
||||
|
||||
notifications = result.items;
|
||||
loadingNextPage = false;
|
||||
}
|
||||
|
||||
async function handleNotificationClick(e: CustomEvent) {
|
||||
await notifications_mark_as_seen(e.detail.notification);
|
||||
e.detail.notification.seen = true;
|
||||
notifications = notifications;
|
||||
|
||||
if (e.detail.link) {
|
||||
goto(e.detail.link);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:mouseup={handleWindowClick} />
|
||||
|
||||
<div class="dropdown relative">
|
||||
{#if unreadCount > 0}
|
||||
<div
|
||||
class="absolute -top-1 -right-1 text-sm rounded-full bg-content text-content-inverse w-4 aspect-square text-center"
|
||||
>
|
||||
{unreadCount}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="dropdown-toggle">
|
||||
<button on:click={toggleMenu} class="btn-icon">
|
||||
<i class="fa fa-bell"></i>
|
||||
</button>
|
||||
</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-scroll mt-4 max-h-96 w-64"
|
||||
class:none={isOpen}
|
||||
on:scroll={onListScroll}
|
||||
style="z-index: 1001"
|
||||
in:fly={{ y: -10, duration: 150 }}
|
||||
out:fly={{ y: -10, duration: 150 }}
|
||||
>
|
||||
{#if loadingNextPage}
|
||||
{#each { length: 5 } as _, index}
|
||||
<SkeletonNotificationCard></SkeletonNotificationCard>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each notifications as notification}
|
||||
<NotificationCard
|
||||
on:click={handleNotificationClick}
|
||||
{notification}
|
||||
></NotificationCard>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user