From 8bf571bf4823a8464b9197c3fde670dc93e154d7 Mon Sep 17 00:00:00 2001 From: Ethan Margaillan Date: Wed, 27 Mar 2024 20:47:42 +0100 Subject: [PATCH] feat(web): better UX when creating a new album (#8270) * feat(web): ask user before going to newly created album * feat(web): add button option to notification cards * feat(web): allow html messages in notification cards * show album -> view album * remove 'link' action from notifications * remove unused type --- .../asset-viewer/album-list-item.svelte | 3 +- .../asset-viewer/asset-viewer.svelte | 9 +-- .../photos-page/actions/add-to-album.svelte | 31 ++------- .../album-selection-modal.svelte | 5 +- .../notification/notification-card.svelte | 40 ++++++++++-- .../notification/notification.ts | 19 +++++- web/src/lib/utils/asset-utils.ts | 65 +++++++++++++++---- web/src/lib/utils/string-utils.ts | 16 +++++ 8 files changed, 134 insertions(+), 54 deletions(-) create mode 100644 web/src/lib/utils/string-utils.ts diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index f2cb181e4c..d97a40ae48 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -2,6 +2,7 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk'; import { createEventDispatcher } from 'svelte'; + import { normalizeSearchString } from '$lib/utils/string-utils.js'; const dispatch = createEventDispatcher<{ album: void; @@ -16,7 +17,7 @@ // It is used to highlight the search query in the album name $: { let { albumName } = album; - let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase()); + let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); let findLength = searchQuery.length; albumNameArray = [ albumName.slice(0, findIndex), diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 51da304114..be5d13e5f8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,7 +1,6 @@ @@ -55,7 +67,7 @@ transition:fade={{ duration: 250 }} style:background-color={backgroundColor[notification.type]} style:border-color={borderColor[notification.type]} - class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md hover:cursor-pointer" + class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}" on:click={handleClick} on:keydown={handleClick} > @@ -72,6 +84,22 @@

- {notification.message} + {#if notification.html} + + {@html notification.message} + {:else} + {notification.message} + {/if}

+ + {#if notification.button} +

+ +

+ {/if} diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts index 52e75bf41f..dfe4e1f923 100644 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ b/web/src/lib/components/shared-components/notification/notification.ts @@ -6,20 +6,31 @@ export enum NotificationType { Warning = 'Warning', } +export type NotificationButton = { + text: string; + onClick: () => unknown; +}; + export type Notification = { id: number; type: NotificationType; message: string; + /** + * Allow HTML to be inserted within the message. Make sure to verify/encode + * variables that may be interpoalted into 'message' + */ + html?: boolean; /** The action to take when the notification is clicked */ action: NotificationAction; + button?: NotificationButton; /** Timeout in miliseconds */ timeout: number; }; type DiscardAction = { type: 'discard' }; type NoopAction = { type: 'noop' }; -type LinkAction = { type: 'link'; target: string }; -export type NotificationAction = DiscardAction | NoopAction | LinkAction; + +export type NotificationAction = DiscardAction | NoopAction; export type NotificationOptions = Partial> & { message: string }; @@ -32,7 +43,9 @@ function createNotificationList() { currentList.push({ id: count++, type: NotificationType.Info, - action: { type: 'discard' }, + action: { + type: options.button ? 'noop' : 'discard', + }, timeout: 3000, ...options, }); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 27c99a4731..532479912a 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,15 +1,18 @@ -import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; +import { goto } from '$app/navigation'; +import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; +import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { downloadRequest, getKey } from '$lib/utils'; +import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; import { addAssetsToAlbum as addAssets, + createAlbum, defaults, getDownloadInfo, type AssetResponseDto, type AssetTypeEnum, - type BulkIdResponseDto, type DownloadInfoDto, type DownloadResponseDto, type UserResponseDto, @@ -18,20 +21,60 @@ import { DateTime } from 'luxon'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; -export const addAssetsToAlbum = async (albumId: string, assetIds: Array): Promise => - addAssets({ +export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => { + const result = await addAssets({ id: albumId, - bulkIdsDto: { ids: assetIds }, + bulkIdsDto: { + ids: assetIds, + }, key: getKey(), - }).then((results) => { - const count = results.filter(({ success }) => success).length; + }); + const count = result.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + timeout: 5000, + message: + count > 0 + ? `Added ${count} asset${count === 1 ? '' : 's'} to the album` + : `Asset${assetIds.length === 1 ? ' was' : 's were'} already part of the album`, + button: { + text: 'View Album', + onClick() { + return goto(`${AppRoute.ALBUMS}/${albumId}`); + }, + }, + }); +}; + +export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => { + try { + const album = await createAlbum({ + createAlbumDto: { + albumName, + assetIds, + }, + }); + const displayName = albumName ? `${encodeHTMLSpecialChars(albumName)}` : 'new album'; notificationController.show({ type: NotificationType.Info, - message: `Added ${count} asset${count === 1 ? '' : 's'}`, + timeout: 5000, + message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`, + html: true, + button: { + text: 'View Album', + onClick() { + return goto(`${AppRoute.ALBUMS}/${album.id}`); + }, + }, }); - - return results; - }); + return album; + } catch { + notificationController.show({ + type: NotificationType.Error, + message: 'Failed to create album', + }); + } +}; export const downloadBlob = (data: Blob, filename: string) => { const url = URL.createObjectURL(data); diff --git a/web/src/lib/utils/string-utils.ts b/web/src/lib/utils/string-utils.ts new file mode 100644 index 0000000000..b58f859f62 --- /dev/null +++ b/web/src/lib/utils/string-utils.ts @@ -0,0 +1,16 @@ +export const removeAccents = (str: string) => { + return str.normalize('NFD').replaceAll(/[\u0300-\u036F]/g, ''); +}; + +export const normalizeSearchString = (str: string) => { + return removeAccents(str.toLocaleLowerCase()); +}; + +export const encodeHTMLSpecialChars = (str: string) => { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +};