1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-06 03:46:47 +01:00

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
This commit is contained in:
Ethan Margaillan 2024-03-27 20:47:42 +01:00 committed by GitHub
parent 613b544bf0
commit 8bf571bf48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 134 additions and 54 deletions

View file

@ -2,6 +2,7 @@
import { getAssetThumbnailUrl } from '$lib/utils'; import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk'; import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
album: void; album: void;
@ -16,7 +17,7 @@
// It is used to highlight the search query in the album name // It is used to highlight the search query in the album name
$: { $: {
let { albumName } = album; let { albumName } = album;
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase()); let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length; let findLength = searchQuery.length;
albumNameArray = [ albumNameArray = [
albumName.slice(0, findIndex), albumName.slice(0, findIndex),

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { updateNumberOfComments } from '$lib/stores/activity.store'; import { updateNumberOfComments } from '$lib/stores/activity.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore } from '$lib/stores/assets.store'; import type { AssetStore } from '$lib/stores/assets.store';
@ -11,7 +10,7 @@
import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, isSharedLink, handlePromiseError } from '$lib/utils'; import { getAssetJobMessage, isSharedLink, handlePromiseError } from '$lib/utils';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { shortcuts } from '$lib/utils/shortcut'; import { shortcuts } from '$lib/utils/shortcut';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
@ -20,7 +19,6 @@
AssetTypeEnum, AssetTypeEnum,
ReactionType, ReactionType,
createActivity, createActivity,
createAlbum,
deleteActivity, deleteActivity,
deleteAssets, deleteAssets,
getActivities, getActivities,
@ -390,8 +388,7 @@
const handleAddToNewAlbum = async (albumName: string) => { const handleAddToNewAlbum = async (albumName: string) => {
isShowAlbumPicker = false; isShowAlbumPicker = false;
const album = await createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } }); await addAssetsToNewAlbum(albumName, [asset.id]);
await goto(`${AppRoute.ALBUMS}/${album.id}`);
}; };
const handleAddToAlbum = async (album: AlbumResponseDto) => { const handleAddToAlbum = async (album: AlbumResponseDto) => {

View file

@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte'; import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
NotificationType, import type { AlbumResponseDto } from '@immich/sdk';
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { createAlbum, type AlbumResponseDto } from '@immich/sdk';
import { getMenuContext } from '../asset-select-context-menu.svelte'; import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
export let shared = false; export let shared = false;
let showAlbumPicker = false; let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets, clearSelect } = getAssetControlContext();
@ -24,26 +19,12 @@
closeMenu(); closeMenu();
}; };
const handleAddToNewAlbum = (albumName: string) => { const handleAddToNewAlbum = async (albumName: string) => {
showAlbumPicker = false; showAlbumPicker = false;
closeMenu();
const assetIds = [...getAssets()].map((asset) => asset.id); const assetIds = [...getAssets()].map((asset) => asset.id);
createAlbum({ createAlbumDto: { albumName, assetIds } }) await addAssetsToNewAlbum(albumName, assetIds);
.then(async (response) => {
const { id, albumName } = response;
notificationController.show({
message: `Added ${assetIds.length} to ${albumName}`,
type: NotificationType.Info,
});
clearSelect();
await goto(`${AppRoute.ALBUMS}/${id}`);
})
.catch((error) => {
console.error(`[add-to-album.svelte]:handleAddToNewAlbum ${error}`, error);
});
}; };
const handleAddToAlbum = async (album: AlbumResponseDto) => { const handleAddToAlbum = async (album: AlbumResponseDto) => {

View file

@ -5,6 +5,7 @@
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import AlbumListItem from '../asset-viewer/album-list-item.svelte'; import AlbumListItem from '../asset-viewer/album-list-item.svelte';
import BaseModal from './base-modal.svelte'; import BaseModal from './base-modal.svelte';
import { normalizeSearchString } from '$lib/utils/string-utils';
let albums: AlbumResponseDto[] = []; let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = []; let recentAlbums: AlbumResponseDto[] = [];
@ -30,7 +31,7 @@
filteredAlbums = filteredAlbums =
search.length > 0 && albums.length > 0 search.length > 0 && albums.length > 0
? albums.filter((album) => { ? albums.filter((album) => {
return album.albumName.toLowerCase().includes(search.toLowerCase()); return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
}) })
: albums; : albums;
} }
@ -84,7 +85,7 @@
<Icon path={mdiPlus} size="30" /> <Icon path={mdiPlus} size="30" />
</div> </div>
<p class=""> <p class="">
New {shared ? 'Shared ' : ''}Album {#if search.length > 0}<b>{search}</b>{/if} New Album {#if search.length > 0}<b>{search}</b>{/if}
</p> </p>
</button> </button>
{#if filteredAlbums.length > 0} {#if filteredAlbums.length > 0}

View file

@ -12,6 +12,7 @@
export let notification: Notification; export let notification: Notification;
$: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline; $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
$: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
const backgroundColor: Record<NotificationType, string> = { const backgroundColor: Record<NotificationType, string> = {
[NotificationType.Info]: '#E0E2F0', [NotificationType.Info]: '#E0E2F0',
@ -31,6 +32,12 @@
[NotificationType.Warning]: '#D08613', [NotificationType.Warning]: '#D08613',
}; };
const buttonStyle: Record<NotificationType, string> = {
[NotificationType.Info]: 'text-white bg-immich-primary hover:bg-immich-primary/75',
[NotificationType.Error]: 'text-white bg-immich-error hover:bg-immich-error/75',
[NotificationType.Warning]: 'text-white bg-immich-warning hover:bg-immich-warning/75',
};
onMount(() => { onMount(() => {
const timeoutId = setTimeout(discard, notification.timeout); const timeoutId = setTimeout(discard, notification.timeout);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@ -41,11 +48,16 @@
}; };
const handleClick = () => { const handleClick = () => {
const action = notification.action; if (notification.action.type === 'discard') {
if (action.type === 'discard') {
discard(); discard();
} else if (action.type == 'link') { }
window.open(action.target); };
const handleButtonClick = () => {
const button = notification.button;
if (button) {
discard();
return notification.button?.onClick();
} }
}; };
</script> </script>
@ -55,7 +67,7 @@
transition:fade={{ duration: 250 }} transition:fade={{ duration: 250 }}
style:background-color={backgroundColor[notification.type]} style:background-color={backgroundColor[notification.type]}
style:border-color={borderColor[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:click={handleClick}
on:keydown={handleClick} on:keydown={handleClick}
> >
@ -72,6 +84,22 @@
</div> </div>
<p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message"> <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
{notification.message} {#if notification.html}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html notification.message}
{:else}
{notification.message}
{/if}
</p> </p>
{#if notification.button}
<p class="pl-[28px] mt-2.5 text-sm">
<button
class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
on:click={handleButtonClick}
>
{notification.button.text}
</button>
</p>
{/if}
</div> </div>

View file

@ -6,20 +6,31 @@ export enum NotificationType {
Warning = 'Warning', Warning = 'Warning',
} }
export type NotificationButton = {
text: string;
onClick: () => unknown;
};
export type Notification = { export type Notification = {
id: number; id: number;
type: NotificationType; type: NotificationType;
message: string; 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 */ /** The action to take when the notification is clicked */
action: NotificationAction; action: NotificationAction;
button?: NotificationButton;
/** Timeout in miliseconds */ /** Timeout in miliseconds */
timeout: number; timeout: number;
}; };
type DiscardAction = { type: 'discard' }; type DiscardAction = { type: 'discard' };
type NoopAction = { type: 'noop' }; 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<Exclude<Notification, 'id'>> & { message: string }; export type NotificationOptions = Partial<Exclude<Notification, 'id'>> & { message: string };
@ -32,7 +43,9 @@ function createNotificationList() {
currentList.push({ currentList.push({
id: count++, id: count++,
type: NotificationType.Info, type: NotificationType.Info,
action: { type: 'discard' }, action: {
type: options.button ? 'noop' : 'discard',
},
timeout: 3000, timeout: 3000,
...options, ...options,
}); });

View file

@ -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 type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { downloadManager } from '$lib/stores/download'; import { downloadManager } from '$lib/stores/download';
import { downloadRequest, getKey } from '$lib/utils'; import { downloadRequest, getKey } from '$lib/utils';
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import { import {
addAssetsToAlbum as addAssets, addAssetsToAlbum as addAssets,
createAlbum,
defaults, defaults,
getDownloadInfo, getDownloadInfo,
type AssetResponseDto, type AssetResponseDto,
type AssetTypeEnum, type AssetTypeEnum,
type BulkIdResponseDto,
type DownloadInfoDto, type DownloadInfoDto,
type DownloadResponseDto, type DownloadResponseDto,
type UserResponseDto, type UserResponseDto,
@ -18,20 +21,60 @@ import { DateTime } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { handleError } from './handle-error'; import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>): Promise<BulkIdResponseDto[]> => export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => {
addAssets({ const result = await addAssets({
id: albumId, id: albumId,
bulkIdsDto: { ids: assetIds }, bulkIdsDto: {
ids: assetIds,
},
key: getKey(), 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 ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
notificationController.show({ notificationController.show({
type: NotificationType.Info, 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 album;
return results; } catch {
}); notificationController.show({
type: NotificationType.Error,
message: 'Failed to create album',
});
}
};
export const downloadBlob = (data: Blob, filename: string) => { export const downloadBlob = (data: Blob, filename: string) => {
const url = URL.createObjectURL(data); const url = URL.createObjectURL(data);

View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
};