From 52596255c8dc897f74ba61cd2f7521b554b15e69 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 28 Oct 2025 15:09:11 -0400 Subject: [PATCH] feat: toasts (#23298) --- .../web/specs/asset-viewer/navbar.e2e-spec.ts | 2 +- .../specs/asset-viewer/slideshow.e2e-spec.ts | 2 +- i18n/en.json | 2 +- pnpm-lock.yaml | 10 +- web/package.json | 2 +- web/src/lib/components/ToastAction.svelte | 33 +++++ .../admin-settings/AdminSettings.svelte | 21 +-- .../admin-settings/AuthSettings.svelte | 8 +- .../NotificationSettings.svelte | 11 +- .../components/album-page/albums-list.svelte | 32 +++-- .../asset-viewer/actions/delete-action.svelte | 18 +-- .../actions/favorite-action.svelte | 11 +- .../actions/restore-action.svelte | 12 +- .../actions/set-album-cover-action.svelte | 11 +- .../actions/set-person-featured-action.svelte | 7 +- .../asset-viewer/activity-viewer.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 4 +- .../detail-panel-description.svelte | 12 +- .../face-editor/face-editor.svelte | 7 +- .../asset-viewer/photo-viewer.svelte | 5 +- .../manage-people-visibility.spec.ts | 9 +- .../manage-people-visibility.svelte | 16 +-- .../faces-page/merge-face-selector.svelte | 13 +- .../faces-page/person-side-panel.svelte | 8 +- .../faces-page/unmerge-face-selector.svelte | 16 +-- .../forms/library-import-paths-form.svelte | 15 +-- .../forms/library-scan-settings-form.svelte | 2 +- web/src/lib/components/jobs/JobsPanel.svelte | 11 +- .../memory-page/memory-viewer.svelte | 13 +- .../individual-shared-viewer.svelte | 8 +- .../navigation-bar/notification-panel.svelte | 9 +- .../__tests__/notification-card.spec.ts | 86 ------------ .../notification-component-test.svelte | 9 -- .../__tests__/notification-list.spec.ts | 41 ------ .../notification/notification-card.svelte | 125 ------------------ .../notification/notification-list.svelte | 25 ---- .../notification/notification.ts | 87 ------------ .../shared-components/upload-panel.svelte | 18 +-- .../timeline/actions/AssetJobActions.svelte | 7 +- .../timeline/actions/FavoriteAction.svelte | 13 +- .../actions/RemoveFromAlbumAction.svelte | 18 +-- .../actions/RemoveFromSharedLinkAction.svelte | 10 +- .../timeline/actions/RestoreAction.svelte | 13 +- .../PinCodeChangeForm.svelte | 13 +- .../PinCodeCreateForm.svelte | 13 +- .../change-password-settings.svelte | 18 +-- .../user-settings-page/device-list.svelte | 12 +- .../download-settings.svelte | 10 +- .../feature-settings.svelte | 10 +- .../notifications-settings.svelte | 15 +-- .../user-settings-page/oauth-settings.svelte | 17 +-- .../partner-settings.svelte | 2 +- .../user-api-key-list.svelte | 15 +-- .../user-profile-settings.svelte | 13 +- web/src/lib/modals/AlbumOptionsModal.svelte | 15 +-- web/src/lib/modals/AlbumUsersModal.svelte | 17 +-- web/src/lib/modals/ApiKeyModal.svelte | 30 +++-- web/src/lib/modals/AvatarEditModal.svelte | 8 +- web/src/lib/modals/JobCreateModal.svelte | 8 +- .../modals/PersonEditBirthDateModal.svelte | 8 +- .../modals/PersonMergeSuggestionModal.svelte | 12 +- web/src/lib/modals/PinCodeResetModal.svelte | 7 +- .../modals/ProfileImageCropperModal.svelte | 15 +-- .../lib/modals/SharedLinkCreateModal.svelte | 19 ++- web/src/lib/modals/TagCreateModal.svelte | 11 +- web/src/lib/modals/TagEditModal.svelte | 11 +- web/src/lib/utils.ts | 4 +- web/src/lib/utils/actions.ts | 35 +++-- web/src/lib/utils/asset-utils.ts | 123 +++++++---------- web/src/lib/utils/handle-error.ts | 4 +- .../[[assetId=id]]/+page.svelte | 16 +-- web/src/routes/(user)/people/+page.svelte | 21 +-- .../[[assetId=id]]/+page.svelte | 24 +--- .../shared-links/[[id=id]]/+page.svelte | 8 +- .../[[assetId=id]]/+page.svelte | 17 +-- .../[[assetId=id]]/+page.svelte | 21 +-- web/src/routes/+layout.svelte | 6 +- .../admin/library-management/+page.svelte | 23 +--- web/src/routes/admin/users/+page.svelte | 11 +- web/src/routes/admin/users/[id]/+page.svelte | 8 +- 80 files changed, 341 insertions(+), 1069 deletions(-) create mode 100644 web/src/lib/components/ToastAction.svelte delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-card.spec.ts delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte delete mode 100644 web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts delete mode 100644 web/src/lib/components/shared-components/notification/notification-card.svelte delete mode 100644 web/src/lib/components/shared-components/notification/notification-list.svelte delete mode 100644 web/src/lib/components/shared-components/notification/notification.ts diff --git a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts index 4f20e2db19..8fcd1bbdb4 100644 --- a/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/navbar.e2e-spec.ts @@ -59,7 +59,7 @@ test.describe('Asset Viewer Navbar', () => { await page.goto(`/photos/${asset.id}`); await page.waitForSelector('#immich-asset-viewer'); await page.keyboard.press('f'); - await expect(page.locator('#notification-list').getByTestId('message')).toHaveText('Added to favorites'); + await expect(page.getByText('Added to favorites')).toBeVisible(); }); }); }); diff --git a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts index 72bb3c5c59..c8cbc21588 100644 --- a/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/slideshow.e2e-spec.ts @@ -51,6 +51,6 @@ test.describe('Slideshow', () => { await expect(page.getByRole('button', { name: 'Exit Slideshow' })).toBeVisible(); await page.keyboard.press('f'); - await expect(page.locator('#notification-list')).not.toBeVisible(); + await expect(page.getByText('Added to favorites')).not.toBeVisible(); }); }); diff --git a/i18n/en.json b/i18n/en.json index d0a4da3de5..30c8949aef 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -906,7 +906,6 @@ "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", - "edited": "Edited", "editor": "Editor", "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", @@ -1717,6 +1716,7 @@ "running": "Running", "save": "Save", "save_to_gallery": "Save to gallery", + "saved": "Saved", "saved_api_key": "Saved API Key", "saved_profile": "Saved profile", "saved_settings": "Saved settings", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bd4d9e8e0..53dca7310a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,8 +684,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.37.1 - version: 0.37.1(@internationalized/date@3.8.2)(svelte@5.40.1) + specifier: ^0.39.1 + version: 0.39.1(@internationalized/date@3.8.2)(svelte@5.40.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -2732,8 +2732,8 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/ui@0.37.1': - resolution: {integrity: sha512-8S9KsyqyRcNgRHeBU8G3qMQ7D7fN4u9I31jjRc9c3s2tkiYucASofPJdcFdmGZnKLX5fIj+yofxiNZV9tVitOg==} + '@immich/ui@0.39.1': + resolution: {integrity: sha512-sal9VyFcmLRHE+NJh122dnmjfwlPOeZCi3yIsDzuI5xNMEUtNJ8MlXRE7hgrKU3FOLmy2QLhcI+oEJchCT+Ibg==} peerDependencies: svelte: ^5.0.0 @@ -14190,7 +14190,7 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/ui@0.37.1(@internationalized/date@3.8.2)(svelte@5.40.1)': + '@immich/ui@0.39.1(@internationalized/date@3.8.2)(svelte@5.40.1)': dependencies: '@mdi/js': 7.4.47 bits-ui: 2.9.8(@internationalized/date@3.8.2)(svelte@5.40.1) diff --git a/web/package.json b/web/package.json index dfcd7ef28a..a037b6cea2 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.37.1", + "@immich/ui": "^0.39.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/ToastAction.svelte b/web/src/lib/components/ToastAction.svelte new file mode 100644 index 0000000000..e77f92af5a --- /dev/null +++ b/web/src/lib/components/ToastAction.svelte @@ -0,0 +1,33 @@ + + + + + {#if button} +
+ +
+ {/if} +
+
diff --git a/web/src/lib/components/admin-settings/AdminSettings.svelte b/web/src/lib/components/admin-settings/AdminSettings.svelte index 199db0b571..54be8bea96 100644 --- a/web/src/lib/components/admin-settings/AdminSettings.svelte +++ b/web/src/lib/components/admin-settings/AdminSettings.svelte @@ -1,15 +1,12 @@ - -Notification message with link diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts b/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts deleted file mode 100644 index df1e5a9f82..0000000000 --- a/web/src/lib/components/shared-components/notification/__tests__/notification-list.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getAnimateMock } from '$lib/__mocks__/animate.mock'; -import '@testing-library/jest-dom'; -import { render, waitFor, type RenderResult } from '@testing-library/svelte'; -import { get } from 'svelte/store'; -import { NotificationType, notificationController } from '../notification'; -import NotificationList from '../notification-list.svelte'; - -function _getNotificationListElement(): HTMLAnchorElement | null { - return document.body.querySelector('#notification-list'); -} - -describe('NotificationList component', () => { - beforeAll(() => { - Element.prototype.animate = getAnimateMock(); - }); - - afterAll(() => { - vi.unstubAllGlobals(); - }); - - it('shows a notification when added and closes it automatically after the delay timeout', async () => { - const sut: RenderResult = render(NotificationList, { intro: false }); - const status = await sut.findAllByRole('status'); - - expect(status).toHaveLength(1); - expect(_getNotificationListElement()).not.toBeInTheDocument(); - - notificationController.show({ - message: 'Notification', - type: NotificationType.Info, - timeout: 1, - }); - - await waitFor(() => expect(_getNotificationListElement()).toBeInTheDocument()); - await waitFor(() => expect(_getNotificationListElement()?.children).toHaveLength(1)); - expect(get(notificationController.notificationList)).toHaveLength(1); - - await waitFor(() => expect(_getNotificationListElement()).not.toBeInTheDocument()); - expect(get(notificationController.notificationList)).toHaveLength(0); - }); -}); diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte deleted file mode 100644 index 581e051073..0000000000 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - -
-
-
- -

- {#if notification.type == NotificationType.Error}{$t('error')} - {:else if notification.type == NotificationType.Warning}{$t('warning')} - {:else if notification.type == NotificationType.Info}{$t('info')}{/if} -

-
- -
- -

- {#if isComponentNotification(notification)} - - {:else} - {notification.message} - {/if} -

- - {#if notification.button} -

- -

- {/if} -
diff --git a/web/src/lib/components/shared-components/notification/notification-list.svelte b/web/src/lib/components/shared-components/notification/notification-list.svelte deleted file mode 100644 index fa86d727db..0000000000 --- a/web/src/lib/components/shared-components/notification/notification-list.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- {#if $notificationList.length > 0} -
- {#each $notificationList as notification (notification.id)} -
- -
- {/each} -
- {/if} -
-
diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts deleted file mode 100644 index 79b1edd1a9..0000000000 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Component as ComponentType } from 'svelte'; -import { writable } from 'svelte/store'; - -export enum NotificationType { - Info = 'Info', - Error = 'Error', - Warning = 'Warning', -} - -export type NotificationButton = { - text: string; - onClick: () => unknown; -}; - -export type Notification = { - id: number; - type: NotificationType; - message: string; - /** The action to take when the notification is clicked */ - action: NotificationAction; - button?: NotificationButton; - /** Timeout in milliseconds */ - timeout: number; -}; - -type DiscardAction = { type: 'discard' }; -type NoopAction = { type: 'noop' }; - -export type NotificationAction = DiscardAction | NoopAction; - -type Props = Record; -type Component = { - type: ComponentType; - props: T; -}; - -type BaseNotificationOptions = Partial> & Pick; - -export type NotificationOptions = BaseNotificationOptions; -export type ComponentNotificationOptions = BaseNotificationOptions< - ComponentNotification, - 'component' ->; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ComponentNotification = Omit & { - component: Component; -}; - -export const isComponentNotification = ( - notification: Notification | ComponentNotification, -): notification is ComponentNotification => { - return 'component' in notification; -}; - -function createNotificationList() { - const notificationList = writable<(Notification | ComponentNotification)[]>([]); - let count = 1; - - const show = (options: T extends Props ? ComponentNotificationOptions : NotificationOptions) => { - notificationList.update((currentList) => { - currentList.push({ - id: count++, - type: NotificationType.Info, - action: { - type: options.button ? 'noop' : 'discard', - }, - timeout: 3000, - ...options, - }); - - return currentList; - }); - }; - - const removeNotificationById = (id: number) => { - notificationList.update((currentList) => currentList.filter((n) => n.id !== id)); - }; - - return { - show, - removeNotificationById, - notificationList, - }; -} - -export const notificationController = createNotificationList(); diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index 68b39e163d..91f6609e10 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -2,12 +2,11 @@ import { locale } from '$lib/stores/preferences.store'; import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadExecutionQueue } from '$lib/utils/file-uploader'; - import { Icon, IconButton } from '@immich/ui'; + import { Icon, IconButton, toastManager } from '@immich/ui'; import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js'; import { t } from 'svelte-i18n'; import { quartInOut } from 'svelte/easing'; import { fade, scale } from 'svelte/transition'; - import { notificationController, NotificationType } from './notification/notification'; import UploadAssetPreview from './upload-asset-preview.svelte'; let showDetail = $state(false); @@ -29,21 +28,12 @@ out:fade={{ duration: 250 }} onoutroend={() => { if ($stats.errors > 0) { - notificationController.show({ - message: $t('upload_errors', { values: { count: $stats.errors } }), - type: NotificationType.Warning, - }); + toastManager.danger($t('upload_errors', { values: { count: $stats.errors } })); } else if ($stats.success > 0) { - notificationController.show({ - message: $t('upload_success'), - type: NotificationType.Info, - }); + toastManager.success($t('upload_success')); } if ($stats.duplicates > 0) { - notificationController.show({ - message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }), - type: NotificationType.Warning, - }); + toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } })); } uploadAssetsStore.reset(); }} diff --git a/web/src/lib/components/timeline/actions/AssetJobActions.svelte b/web/src/lib/components/timeline/actions/AssetJobActions.svelte index cee1b367be..249b3c5d14 100644 --- a/web/src/lib/components/timeline/actions/AssetJobActions.svelte +++ b/web/src/lib/components/timeline/actions/AssetJobActions.svelte @@ -1,13 +1,10 @@ diff --git a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte index c0ea55fdc8..973760ac45 100644 --- a/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte +++ b/web/src/lib/components/timeline/actions/RemoveFromSharedLinkAction.svelte @@ -3,10 +3,9 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { removeSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; - import { IconButton, modalManager } from '@immich/ui'; + import { IconButton, modalManager, toastManager } from '@immich/ui'; import { mdiDeleteOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; - import { NotificationType, notificationController } from '../../shared-components/notification/notification'; interface Props { sharedLink: SharedLinkResponseDto; @@ -45,12 +44,7 @@ } const count = results.filter((item) => item.success).length; - - notificationController.show({ - type: NotificationType.Info, - message: $t('assets_removed_count', { values: { count } }), - }); - + toastManager.success($t('assets_removed_count', { values: { count } })); clearSelect(); } catch (error) { handleError(error, $t('errors.unable_to_remove_assets_from_shared_link')); diff --git a/web/src/lib/components/timeline/actions/RestoreAction.svelte b/web/src/lib/components/timeline/actions/RestoreAction.svelte index 7550b3dd54..ec70f01cd9 100644 --- a/web/src/lib/components/timeline/actions/RestoreAction.svelte +++ b/web/src/lib/components/timeline/actions/RestoreAction.svelte @@ -1,13 +1,9 @@