diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index ab0da059d0395..202f0e4593cf8 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -1,6 +1,7 @@ + (showSelectionModal = true) }} +/> + void; } - let { album, searchQuery = '', onAlbumClick }: Props = $props(); + let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props(); + + const scrollIntoViewIfSelected: Action = (node) => { + $effect(() => { + if (selected) { + node.scrollIntoView(SCROLL_PROPERTIES); + } + }); + }; let albumNameArray: string[] = $state(['', '', '']); @@ -31,7 +42,10 @@ - {#if filteredAlbums.length > 0} - {#if !shared && search.length === 0} -

{$t('recent').toUpperCase()}

- {#each recentAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {/if} - - {#if !shared} -

- {(search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase()} -

- {/if} - {#each filteredAlbums as album (album.id)} - onAlbumClick(album)} /> - {/each} - {:else if albums.length > 0} -

{$t('no_albums_with_name_yet')}

- {:else} -

{$t('no_albums_yet')}

- {/if} - - {/if} - - diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte new file mode 100644 index 0000000000000..49b697b62affd --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-modal.svelte @@ -0,0 +1,126 @@ + + + +
+ {#if loading} + {#each { length: 3 } as _} +
+
+
+ +
+ + +
+
+
+ {/each} + {:else} + +
+ {#each albumModalRows as row} + {#if row.type === AlbumModalRowType.NEW_ALBUM} + + {:else if row.type === AlbumModalRowType.SECTION} +

{row.text}

+ {:else if row.type === AlbumModalRowType.MESSAGE} +

{row.text}

+ {:else if row.type === AlbumModalRowType.ALBUM_ITEM && row.album} + + {/if} + {/each} +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts new file mode 100644 index 0000000000000..242809d58f984 --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -0,0 +1,171 @@ +import { + type AlbumModalRow, + AlbumModalRowConverter, + AlbumModalRowType, +} from '$lib/components/shared-components/album-selection/album-selection-utils'; +import { AlbumSortBy, SortOrder } from '$lib/stores/preferences.store'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { albumFactory } from '@test-data/factories/album-factory'; + +// Some helper functions to make tests below more readable +const createNewAlbumRow = (selected: boolean) => ({ + type: AlbumModalRowType.NEW_ALBUM, + selected, +}); +const createMessageRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.MESSAGE, + text: message, +}); +const createSectionRow = (message: string): AlbumModalRow => ({ + type: AlbumModalRowType.SECTION, + text: message, +}); +const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ + type: AlbumModalRowType.ALBUM_ITEM, + album, + selected, +}); + +describe('Album Modal', () => { + it('non-shared with no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('', [], [], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); + }); + + it('non-shared with no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1); + + expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); + }); + + it('non-shared displays single albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const modalRows = converter.toModalRows('', [], [holidayAlbum], -1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + ]); + }); + + it('non-shared displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('shared only displays albums and no recents', () => { + const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + '', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + createAlbumRow(birthdayAlbum, false), + createAlbumRow(christmasAlbum, false), + ]); + }); + + it('search changes messaging and removes recent and non-matching albums', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); + const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); + const modalRows = converter.toModalRows( + 'Cons', + [holidayAlbum, constructionAlbum], + [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], + -1, + ); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('ALBUMS'), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select new album row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(true), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select recent row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, true), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, false), + ]); + }); + + it('selection can select last row', () => { + const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); + const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); + const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3); + + expect(modalRows).toStrictEqual([ + createNewAlbumRow(false), + createSectionRow('RECENT'), + createAlbumRow(holidayAlbum, false), + createSectionRow('ALL_ALBUMS'), + createAlbumRow(holidayAlbum, false), + createAlbumRow(constructionAlbum, true), + ]); + }); +}); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts new file mode 100644 index 0000000000000..73f289eb1d8cf --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -0,0 +1,94 @@ +import { sortAlbums } from '$lib/utils/album-utils'; +import { normalizeSearchString } from '$lib/utils/string-utils'; +import type { AlbumResponseDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +export const SCROLL_PROPERTIES: ScrollIntoViewOptions = { block: 'center', behavior: 'smooth' }; + +export enum AlbumModalRowType { + SECTION = 'section', + MESSAGE = 'message', + NEW_ALBUM = 'newAlbum', + ALBUM_ITEM = 'albumItem', +} + +export type AlbumModalRow = { + type: AlbumModalRowType; + selected?: boolean; + text?: string; + album?: AlbumResponseDto; +}; + +export const isSelectableRowType = (type: AlbumModalRowType) => + type === AlbumModalRowType.NEW_ALBUM || type === AlbumModalRowType.ALBUM_ITEM; + +const $t = get(t); + +export class AlbumModalRowConverter { + private readonly shared: boolean; + private readonly sortBy: string; + private readonly orderBy: string; + + constructor(shared: boolean, sortBy: string, orderBy: string) { + this.shared = shared; + this.sortBy = sortBy; + this.orderBy = orderBy; + } + + toModalRows( + search: string, + recentAlbums: AlbumResponseDto[], + albums: AlbumResponseDto[], + selectedRowIndex: number, + ): AlbumModalRow[] { + // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. + const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + const rows: AlbumModalRow[] = []; + rows.push({ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }); + + const filteredAlbums = sortAlbums( + search.length > 0 && albums.length > 0 + ? albums.filter((album) => { + return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + }) + : albums, + { sortBy: this.sortBy, orderBy: this.orderBy }, + ); + + if (filteredAlbums.length > 0) { + if (recentAlbumsToShow.length > 0) { + rows.push({ type: AlbumModalRowType.SECTION, text: $t('recent').toUpperCase() }); + const selectedOffsetDueToNewAlbumRow = 1; + for (const [i, album] of recentAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow, + album, + }); + } + } + + if (!this.shared) { + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); + } + + const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; + for (const [i, album] of filteredAlbums.entries()) { + rows.push({ + type: AlbumModalRowType.ALBUM_ITEM, + selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents, + album, + }); + } + } else if (albums.length > 0) { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_with_name_yet') }); + } else { + rows.push({ type: AlbumModalRowType.MESSAGE, text: $t('no_albums_yet') }); + } + return rows; + } +} diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte new file mode 100644 index 0000000000000..d8be0e2a30d13 --- /dev/null +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -0,0 +1,40 @@ + + + diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index a3cfd83ad51d6..9ca35c927efda 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -33,6 +33,8 @@ { key: ['f'], action: $t('favorite_or_unfavorite_photo') }, { key: ['i'], action: $t('show_or_hide_info') }, { key: ['s'], action: $t('stack_selected_photos') }, + { key: ['l'], action: $t('add_to_album') }, + { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') },