Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/lib/api/notificationsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* API service layer for notifications.
* Handles all HTTP communication with the backend.
*/

import type { Notification } from '$lib/types/notification';

export type Fetcher = typeof fetch;

export interface NotificationsApiDeps {
fetcher: Fetcher;
}

export interface NotificationsPaginatedResponse {
data: Notification[];
pagination: {
total: number;
hasMore: boolean;
};
}

export interface UnreadCountResponse {
count: number;
}

export function createNotificationsApi({ fetcher }: NotificationsApiDeps) {
const fetchUnreadCount = async (): Promise<UnreadCountResponse> => {
const res = await fetcher('/api/notifications/unread-count');

if (!res.ok) {
throw new Error('Failed to fetch unread count');
}

return (await res.json()) as UnreadCountResponse;
};

const fetchNotifications = async (
page: number,
limit: number = 10
): Promise<NotificationsPaginatedResponse> => {
const res = await fetcher(`/api/notifications?page=${page}&limit=${limit}`);

if (!res.ok) {
throw new Error('Failed to fetch notifications');
}

return (await res.json()) as NotificationsPaginatedResponse;
};

const markAsRead = async (notificationId: string): Promise<void> => {
const res = await fetcher(`/api/notifications/${notificationId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true })
});

if (!res.ok) {
throw new Error('Failed to mark notification as read');
}
};

const markAllAsRead = async (): Promise<void> => {
const res = await fetcher('/api/notifications/mark-all-read', {
method: 'PATCH'
});

if (!res.ok) {
throw new Error('Failed to mark all notifications as read');
}
};

const dismiss = async (notificationId: string): Promise<void> => {
const res = await fetcher(`/api/notifications/${notificationId}`, {
method: 'DELETE'
});

if (!res.ok) {
throw new Error('Failed to dismiss notification');
}
};

return {
fetchUnreadCount,
fetchNotifications,
markAsRead,
markAllAsRead,
dismiss
};
}

export type NotificationsApi = ReturnType<typeof createNotificationsApi>;
4 changes: 4 additions & 0 deletions src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { LogOutIcon, MenuIcon, XIcon } from '@lucide/svelte';
import { Avatar } from '@skeletonlabs/skeleton-svelte';
import { avatarUrl } from '$lib/stores/avatar';
import { notificationsPresenter } from '$lib/presenters/notifications';
import NotificationBell from './NotificationBell.svelte';

let {
Expand Down Expand Up @@ -70,6 +71,9 @@
sessionStorage.removeItem(STORAGE_KEYS.SETTINGS_CACHE);
sessionStorage.removeItem(STORAGE_KEYS.QUOTE_CACHE);

// Reset notifications presenter state
notificationsPresenter.reset();

await signOut();
location.href = routes.login;
} catch (e) {
Expand Down
37 changes: 20 additions & 17 deletions src/lib/components/NotificationBell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@
import { onMount } from 'svelte';
import { Popover, Portal } from '@skeletonlabs/skeleton-svelte';
import { Bell, CheckCheck, Loader2 } from '@lucide/svelte';
import { notificationStore } from '$lib/stores/notifications.svelte';
import { notificationsPresenter } from '$lib/presenters/notifications';
import NotificationItem from './NotificationItem.svelte';

let isOpen = $state(false);
let isClosing = $state(false);

// Subscribe to presenter state
const presenterState = notificationsPresenter.state;

onMount(() => {
notificationStore.fetchUnreadCount();
notificationsPresenter.fetchUnreadCount();
});

async function handleOpen() {
if (!notificationStore.initialized) {
await notificationStore.fetchNotifications(true);
if (!$presenterState.initialized) {
await notificationsPresenter.fetchNotifications(true);
}
}

Expand All @@ -35,19 +38,19 @@
}

function handleMarkRead(id: string) {
notificationStore.markAsRead(id);
notificationsPresenter.markAsRead(id);
}

function handleDismiss(id: string) {
notificationStore.dismiss(id);
notificationsPresenter.dismiss(id);
}

function handleMarkAllRead() {
notificationStore.markAllAsRead();
notificationsPresenter.markAllAsRead();
}

function handleLoadMore() {
notificationStore.loadMore();
notificationsPresenter.loadMore();
}
</script>

Expand All @@ -61,11 +64,11 @@
aria-label="Notifications"
>
<Bell class="size-5 text-surface-600 dark:text-surface-400" />
{#if notificationStore.unreadCount > 0}
{#if $presenterState.unreadCount > 0}
<span
class="absolute -top-0.5 -right-0.5 min-w-5 h-5 px-1.5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full"
>
{notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount}
{$presenterState.unreadCount > 99 ? '99+' : $presenterState.unreadCount}
</span>
{/if}
</Popover.Trigger>
Expand All @@ -86,7 +89,7 @@
>
Notifications
</Popover.Title>
{#if notificationStore.unreadCount > 0}
{#if $presenterState.unreadCount > 0}
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 -mr-2 rounded-lg text-xs sm:text-xs text-primary-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:text-primary-400 dark:hover:bg-primary-950/50 transition-colors"
Expand All @@ -100,11 +103,11 @@

<!-- Notification list -->
<div class="flex-1 overflow-y-auto">
{#if notificationStore.loading && notificationStore.notifications.length === 0}
{#if $presenterState.loading && $presenterState.notifications.length === 0}
<div class="flex items-center justify-center py-12">
<Loader2 class="size-6 text-surface-400 animate-spin" />
</div>
{:else if notificationStore.notifications.length === 0}
{:else if $presenterState.notifications.length === 0}
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<Bell class="size-10 text-surface-300 dark:text-surface-600 mb-3" />
<Popover.Description class="text-sm text-surface-500 dark:text-surface-400">
Expand All @@ -116,7 +119,7 @@
</div>
{:else}
<div class="p-2 space-y-1">
{#each notificationStore.notifications as notification (notification.id)}
{#each $presenterState.notifications as notification (notification.id)}
<NotificationItem
{notification}
onMarkRead={handleMarkRead}
Expand All @@ -125,15 +128,15 @@
{/each}
</div>

{#if notificationStore.hasMore}
{#if $presenterState.hasMore}
<div class="px-3 pb-3">
<button
type="button"
class="w-full py-3 sm:py-2 text-sm font-medium text-primary-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:text-primary-400 dark:hover:bg-primary-950/50 rounded-xl transition-colors disabled:opacity-50"
onclick={handleLoadMore}
disabled={notificationStore.loading}
disabled={$presenterState.loading}
>
{#if notificationStore.loading}
{#if $presenterState.loading}
<Loader2 class="size-4 animate-spin inline mr-1" />
Loading...
{:else}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/presenters/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createNotificationsPresenter } from './notificationsPresenter';

export const notificationsPresenter = createNotificationsPresenter({
fetcher: fetch
});
Loading