diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts index 6c6dc98f73..0c4996fbfc 100644 --- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts +++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts @@ -1,6 +1,4 @@ -import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock'; -import { ThumbnailFormat } from '@immich/sdk'; import { albumFactory } from '@test-data'; import '@testing-library/jest-dom'; import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte'; @@ -33,7 +31,7 @@ describe('AlbumCard component', () => { shared: true, }, ])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => { - sut = render(AlbumCard, { album }); + sut = render(AlbumCard, { album, showItemCount: true }); const albumImgElement = sut.getByTestId('album-image'); const albumNameElement = sut.getByTestId('album-name'); @@ -52,36 +50,22 @@ describe('AlbumCard component', () => { expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText)); }); - it('shows album data and loads the thumbnail image when available', async () => { - const thumbnailFile = new File([new Blob()], 'fileThumbnail'); - const thumbnailUrl = 'blob:thumbnailUrlOne'; - sdkMock.getAssetThumbnail.mockResolvedValue(thumbnailFile); - createObjectURLMock.mockReturnValueOnce(thumbnailUrl); - + it('shows album data', () => { const album = albumFactory.build({ - albumThumbnailAssetId: 'thumbnailIdOne', shared: false, albumName: 'some album name', }); - sut = render(AlbumCard, { album }); + sut = render(AlbumCard, { album, showItemCount: true }); const albumImgElement = sut.getByTestId('album-image'); const albumNameElement = sut.getByTestId('album-name'); const albumDetailsElement = sut.getByTestId('album-details'); - expect(albumImgElement).toHaveAttribute('alt', album.albumName); - - await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl)); expect(albumImgElement).toHaveAttribute('alt', album.albumName); - expect(sdkMock.getAssetThumbnail).toHaveBeenCalledTimes(1); - expect(sdkMock.getAssetThumbnail).toHaveBeenCalledWith({ - id: 'thumbnailIdOne', - format: ThumbnailFormat.Jpeg, - }); - expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile); + expect(albumImgElement).toHaveAttribute('src'); expect(albumNameElement).toHaveTextContent('some album name'); - expect(albumDetailsElement).toHaveTextContent('0 items'); + expect(albumDetailsElement).toHaveTextContent('0 item'); }); it('hides context menu when "onShowContextMenu" is undefined', () => { diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte new file mode 100644 index 0000000000..4c303caa68 --- /dev/null +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -0,0 +1,69 @@ + + +{#if group} +
+ + +

toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer"> + + {group.name} + ({albums.length} {albums.length > 1 ? 'albums' : 'album'}) +

+
+
+{/if} + +
+ {#if !isCollapsed} +
+ {#each albums as album, index (album.id)} + showContextMenu({ x: e.x, y: e.y }, album)} + > + showContextMenu(position, album) : undefined} + /> + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index 2958660771..a4dc9aaa6b 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -2,43 +2,25 @@ import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; import { user } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl } from '$lib/utils'; - import { ThumbnailFormat, getAssetThumbnail, getUserById, type AlbumResponseDto } from '@immich/sdk'; + import type { AlbumResponseDto } from '@immich/sdk'; import { mdiDotsVertical } from '@mdi/js'; - import { onMount } from 'svelte'; - import { getContextMenuPosition, type ContextMenuPosition } from '../../utils/context-menu'; - import IconButton from '../elements/buttons/icon-button.svelte'; + import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu'; + import { getShortDateRange } from '$lib/utils/date-time'; + import IconButton from '$lib/components/elements/buttons/icon-button.svelte'; + import AlbumCover from '$lib/components/album-page/album-cover.svelte'; export let album: AlbumResponseDto; - export let isSharingView = false; - export let showItemCount = true; + export let showOwner = false; + export let showDateRange = false; + export let showItemCount = false; export let preload = false; - export let onShowContextMenu: ((position: ContextMenuPosition) => void) | undefined = undefined; - - $: imageData = album.albumThumbnailAssetId - ? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp) - : null; - - const loadHighQualityThumbnail = async (assetId: string | null) => { - if (!assetId) { - return; - } - - const data = await getAssetThumbnail({ id: assetId, format: ThumbnailFormat.Jpeg }); - return URL.createObjectURL(data); - }; + export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined; const showAlbumContextMenu = (e: MouseEvent) => { e.stopPropagation(); e.preventDefault(); onShowContextMenu?.(getContextMenuPosition(e)); }; - - onMount(async () => { - imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || null; - }); - - const getAlbumOwnerInfo = () => getUserById({ id: album.ownerId });
{/if} -
- {#if album.albumThumbnailAssetId} - {album.albumName} - {:else} - - {/if} -
+

{album.albumName}

- + {#if showDateRange && album.startDate && album.endDate} +

+ {getShortDateRange(album.startDate, album.endDate)} +

+ {/if} + + {#if showItemCount}

{album.assetCount.toLocaleString($locale)} - {album.assetCount == 1 ? `item` : `items`} + {album.assetCount === 1 ? `item` : `items`}

{/if} - {#if isSharingView || album.shared} -

·

+ {#if (showOwner || album.shared) && showItemCount} +

{/if} - {#if isSharingView} - {#await getAlbumOwnerInfo() then albumOwner} - {#if $user.email == albumOwner.email} -

Owned

- {:else} -

- Shared by {albumOwner.name} -

- {/if} - {/await} + {#if showOwner} + {#if $user.id === album.ownerId} +

Owned

+ {:else if album.owner} +

Shared by {album.owner.name}

+ {:else} +

Shared

+ {/if} {:else if album.shared}

Shared

{/if} diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte new file mode 100644 index 0000000000..8e289de03a --- /dev/null +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -0,0 +1,36 @@ + + +
+ {#if thumbnailUrl} + {album?.albumName + {:else} + + {/if} +
diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index e860fbcd21..505e934144 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -37,7 +37,7 @@ on:input={(e) => autoGrowHeight(e.currentTarget)} on:focusout={handleUpdateDescription} use:autoGrowHeight - placeholder="Add description" + placeholder="Add a description" use:shortcut={{ shortcut: { key: 'Enter', ctrl: true }, onShortcut: (e) => e.currentTarget.blur(), diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 08d610cebf..cccb080ff3 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -6,7 +6,7 @@ import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import { createAssetInteractionStore } from '../../stores/asset-interaction.store'; import { AssetStore } from '../../stores/assets.store'; - import { downloadArchive } from '../../utils/asset-utils'; + import { downloadAlbum } from '../../utils/asset-utils'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte'; import AssetGrid from '../photos-page/asset-grid.svelte'; @@ -36,10 +36,6 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); - - const downloadAlbum = async () => { - await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); - }; 0 && sharedLink.allowDownload} - downloadAlbum()} icon={mdiFolderDownloadOutline} /> + downloadAlbum(album)} icon={mdiFolderDownloadOutline} /> {/if} diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte index a5f2c92769..ae51c2f480 100644 --- a/web/src/lib/components/album-page/albums-controls.svelte +++ b/web/src/lib/components/album-page/albums-controls.svelte @@ -2,30 +2,94 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Dropdown from '$lib/components/elements/dropdown.svelte'; import Icon from '$lib/components/elements/icon.svelte'; - import { AlbumFilter, AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store'; + import { + AlbumFilter, + AlbumGroupBy, + AlbumViewMode, + albumViewSettings, + SortOrder, + } from '$lib/stores/preferences.store'; import { mdiArrowDownThin, mdiArrowUpThin, + mdiFolderArrowDownOutline, + mdiFolderArrowUpOutline, + mdiFolderRemoveOutline, mdiFormatListBulletedSquare, mdiPlusBoxOutline, + mdiUnfoldLessHorizontal, + mdiUnfoldMoreHorizontal, mdiViewGridOutline, } from '@mdi/js'; - import { sortByOptions, type Sort, handleCreateAlbum } from '$lib/components/album-page/albums-list.svelte'; + import { + type AlbumGroupOptionMetadata, + type AlbumSortOptionMetadata, + findGroupOptionMetadata, + findSortOptionMetadata, + getSelectedAlbumGroupOption, + groupOptionsMetadata, + sortOptionsMetadata, + } from '$lib/utils/album-utils'; import SearchBar from '$lib/components/elements/search-bar.svelte'; import GroupTab from '$lib/components/elements/group-tab.svelte'; + import { createAlbumAndRedirect, collapseAllAlbumGroups, expandAllAlbumGroups } from '$lib/utils/album-utils'; + import { fly } from 'svelte/transition'; - export let searchAlbum: string; + export let albumGroups: string[]; + export let searchQuery: string; - const searchSort = (searched: string): Sort => { - return sortByOptions.find((option) => option.title === searched) || sortByOptions[0]; + const flipOrdering = (ordering: string) => { + return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc; + }; + + const handleChangeGroupBy = ({ id, defaultOrder }: AlbumGroupOptionMetadata) => { + if ($albumViewSettings.groupBy === id) { + $albumViewSettings.groupOrder = flipOrdering($albumViewSettings.groupOrder); + } else { + $albumViewSettings.groupBy = id; + $albumViewSettings.groupOrder = defaultOrder; + } + }; + + const handleChangeSortBy = ({ id, defaultOrder }: AlbumSortOptionMetadata) => { + if ($albumViewSettings.sortBy === id) { + $albumViewSettings.sortOrder = flipOrdering($albumViewSettings.sortOrder); + } else { + $albumViewSettings.sortBy = id; + $albumViewSettings.sortOrder = defaultOrder; + } }; const handleChangeListMode = () => { $albumViewSettings.view = $albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover; }; + + let selectedGroupOption: AlbumGroupOptionMetadata; + let groupIcon: string; + + $: { + selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy); + if (selectedGroupOption.isDisabled()) { + selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None); + } + } + + $: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy); + + $: { + if (selectedGroupOption.id === AlbumGroupBy.None) { + groupIcon = mdiFolderRemoveOutline; + } else { + groupIcon = + $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline; + } + } + + $: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin; + -