mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat: notifications (#17701)
* feat: notifications * UI works * chore: pr feedback * initial fetch and clear notification upon logging out * fix: merge --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
23717ce981
commit
1b5fc9c665
55 changed files with 3186 additions and 196 deletions
|
|
@ -8,6 +8,7 @@
|
|||
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||
import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte';
|
||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||
import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
|
||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/stores/auth-manager.svelte';
|
||||
|
|
@ -18,13 +19,14 @@
|
|||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { mdiBellBadge, mdiBellOutline, mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.svelte';
|
||||
import AccountInfoPanel from './account-info-panel.svelte';
|
||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
showUploadButton?: boolean;
|
||||
|
|
@ -36,7 +38,9 @@
|
|||
let shouldShowAccountInfo = $state(false);
|
||||
let shouldShowAccountInfoPanel = $state(false);
|
||||
let shouldShowHelpPanel = $state(false);
|
||||
let shouldShowNotificationPanel = $state(false);
|
||||
let innerWidth: number = $state(0);
|
||||
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
|
||||
|
||||
let info: ServerAboutResponseDto | undefined = $state();
|
||||
|
||||
|
|
@ -146,6 +150,27 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
use:clickOutside={{
|
||||
onOutclick: () => (shouldShowNotificationPanel = false),
|
||||
onEscape: () => (shouldShowNotificationPanel = false),
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color={hasUnreadNotifications ? 'primary' : 'secondary'}
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
icon={hasUnreadNotifications ? mdiBellBadge : mdiBellOutline}
|
||||
onclick={() => (shouldShowNotificationPanel = !shouldShowNotificationPanel)}
|
||||
aria-label={$t('notifications')}
|
||||
/>
|
||||
|
||||
{#if shouldShowNotificationPanel}
|
||||
<NotificationPanel />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
use:clickOutside={{
|
||||
onOutclick: () => (shouldShowAccountInfoPanel = false),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
import { NotificationLevel, NotificationType, type NotificationDto } from '@immich/sdk';
|
||||
import { IconButton, Stack, Text } from '@immich/ui';
|
||||
import { mdiBackupRestore, mdiInformationOutline, mdiMessageBadgeOutline, mdiSync } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
interface Props {
|
||||
notification: NotificationDto;
|
||||
onclick: (id: string) => void;
|
||||
}
|
||||
|
||||
let { notification, onclick }: Props = $props();
|
||||
|
||||
const getAlertColor = (level: NotificationLevel) => {
|
||||
switch (level) {
|
||||
case NotificationLevel.Error: {
|
||||
return 'danger';
|
||||
}
|
||||
case NotificationLevel.Warning: {
|
||||
return 'warning';
|
||||
}
|
||||
case NotificationLevel.Info: {
|
||||
return 'primary';
|
||||
}
|
||||
case NotificationLevel.Success: {
|
||||
return 'success';
|
||||
}
|
||||
default: {
|
||||
return 'primary';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getIconBgColor = (level: NotificationLevel) => {
|
||||
switch (level) {
|
||||
case NotificationLevel.Error: {
|
||||
return 'bg-red-500 dark:bg-red-300 dark:hover:bg-red-200';
|
||||
}
|
||||
case NotificationLevel.Warning: {
|
||||
return 'bg-amber-500 dark:bg-amber-200 dark:hover:bg-amber-200';
|
||||
}
|
||||
case NotificationLevel.Info: {
|
||||
return 'bg-blue-500 dark:bg-blue-200 dark:hover:bg-blue-200';
|
||||
}
|
||||
case NotificationLevel.Success: {
|
||||
return 'bg-green-500 dark:bg-green-200 dark:hover:bg-green-200';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getIconType = (type: NotificationType) => {
|
||||
switch (type) {
|
||||
case NotificationType.BackupFailed: {
|
||||
return mdiBackupRestore;
|
||||
}
|
||||
case NotificationType.JobFailed: {
|
||||
return mdiSync;
|
||||
}
|
||||
case NotificationType.SystemMessage: {
|
||||
return mdiMessageBadgeOutline;
|
||||
}
|
||||
case NotificationType.Custom: {
|
||||
return mdiInformationOutline;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString: string): string => {
|
||||
try {
|
||||
const date = DateTime.fromISO(dateString);
|
||||
if (!date.isValid) {
|
||||
return dateString; // Return original string if parsing fails
|
||||
}
|
||||
// Use Luxon's toRelative with the current locale
|
||||
return date.setLocale('en').toRelative() || dateString;
|
||||
} catch (error) {
|
||||
console.error('Error formatting relative time:', error);
|
||||
return dateString; // Fallback to original string on error
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="min-h-[80px] p-2 py-3 hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/10 border-b border-gray-200 dark:border-immich-dark-gray w-full"
|
||||
type="button"
|
||||
onclick={() => onclick(notification.id)}
|
||||
title={notification.createdAt}
|
||||
>
|
||||
<div class="grid grid-cols-[56px_1fr_32px] items-center gap-2">
|
||||
<div class="flex place-items-center place-content-center">
|
||||
<IconButton
|
||||
icon={getIconType(notification.type)}
|
||||
color={getAlertColor(notification.level)}
|
||||
aria-label={notification.title}
|
||||
shape="round"
|
||||
class={getIconBgColor(notification.level)}
|
||||
size="small"
|
||||
></IconButton>
|
||||
</div>
|
||||
|
||||
<Stack class="text-left" gap={1}>
|
||||
<Text size="tiny" class="uppercase text-black dark:text-white font-semibold">{notification.title}</Text>
|
||||
{#if notification.description}
|
||||
<Text class="overflow-hidden text-gray-600 dark:text-gray-300">{notification.description}</Text>
|
||||
{/if}
|
||||
|
||||
<Text size="tiny" color="muted">{formatRelativeTime(notification.createdAt)}</Text>
|
||||
</Stack>
|
||||
|
||||
{#if !notification.readAt}
|
||||
<div class="w-2 h-2 rounded-full bg-primary text-right justify-self-center"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import NotificationItem from '$lib/components/shared-components/navigation-bar/notification-item.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType as WebNotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
|
||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { Button, Scrollable, Stack, Text } from '@immich/ui';
|
||||
import { mdiBellOutline, mdiCheckAll } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
const noUnreadNotifications = $derived(notificationManager.notifications.length === 0);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
try {
|
||||
await notificationManager.markAsRead(id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_update_notification_status'));
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await notificationManager.markAllAsRead();
|
||||
notificationController.show({ message: $t('marked_all_as_read'), type: WebNotificationType.Info });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_update_notification_status'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
id="notification-panel"
|
||||
class="absolute right-[25px] top-[70px] z-[100] w-[min(360px,100vw-50px)] rounded-3xl bg-gray-100 border border-gray-200 shadow-lg dark:border dark:border-immich-dark-gray dark:bg-immich-dark-gray text-light"
|
||||
use:focusTrap
|
||||
>
|
||||
<Stack class="max-h-[500px]">
|
||||
<div class="flex justify-between items-center mt-4 mx-4">
|
||||
<Text size="medium" color="secondary" class="font-semibold">{$t('notifications')}</Text>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={noUnreadNotifications}
|
||||
leadingIcon={mdiCheckAll}
|
||||
size="small"
|
||||
color="primary"
|
||||
onclick={() => markAllAsRead()}>{$t('mark_all_as_read')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{#if noUnreadNotifications}
|
||||
<Stack
|
||||
class="py-12 flex flex-col place-items-center place-content-center text-gray-700 dark:text-gray-300"
|
||||
gap={1}
|
||||
>
|
||||
<Icon path={mdiBellOutline} size={20}></Icon>
|
||||
<Text>{$t('no_notifications')}</Text>
|
||||
</Stack>
|
||||
{:else}
|
||||
<Scrollable class="pb-6">
|
||||
<Stack gap={0}>
|
||||
{#each notificationManager.notifications as notification (notification.id)}
|
||||
<div animate:flip={{ duration: 400 }}>
|
||||
<NotificationItem {notification} onclick={(id) => markAsRead(id)} />
|
||||
</div>
|
||||
{/each}
|
||||
</Stack>
|
||||
</Scrollable>
|
||||
{/if}
|
||||
</Stack>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue