diff --git a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts index 6314688abb..5faf8380d1 100644 --- a/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts +++ b/e2e/src/web/specs/timeline/timeline.parallel-e2e-spec.ts @@ -463,7 +463,7 @@ test.describe('Timeline', () => { }); changes.albumAdditions.push(...requestJson.ids); }); - await page.getByText('Done').click(); + await page.getByText('Add assets').click(); await expect(put).resolves.toEqual({ ids: [ 'c077ea7b-cfa1-45e4-8554-f86c00ee5658', diff --git a/i18n/en.json b/i18n/en.json index 13fc965b65..473bd6f37b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,6 +18,7 @@ "add_a_title": "Add a title", "add_action": "Add action", "add_action_description": "Click to add an action to perform", + "add_assets": "Add assets", "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", @@ -478,6 +479,7 @@ "album_summary": "Album summary", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", + "album_upload_assets": "Upload assets from your computer and add to album", "album_user_left": "Left {album}", "album_user_removed": "Removed {user}", "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index f6ca2b610e..f950208a16 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -6,7 +6,6 @@ import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte'; import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte'; import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; - import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; import { AlbumFilter, @@ -21,14 +20,8 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils'; import type { ContextMenuPosition } from '$lib/utils/context-menu'; - import { handleError } from '$lib/utils/handle-error'; import { normalizeSearchString } from '$lib/utils/string-utils'; - import { - addUsersToAlbum, - type AlbumResponseDto, - type AlbumUserAddDto, - type SharedLinkResponseDto, - } from '@immich/sdk'; + import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js'; import { groupBy } from 'lodash-es'; @@ -205,18 +198,7 @@ } case 'share': { - const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum }); - switch (result?.action) { - case 'sharedUsers': { - await handleAddUsers(selectedAlbum, result.data); - break; - } - - case 'sharedLink': { - await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id }); - break; - } - } + await modalManager.show(AlbumShareModal, { album: selectedAlbum }); break; } @@ -251,20 +233,6 @@ sharedAlbums = findAndUpdate(sharedAlbums, album); }; - const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => { - try { - const updatedAlbum = await addUsersToAlbum({ - id: album.id, - addUsersDto: { - albumUsers, - }, - }); - onUpdate(updatedAlbum); - } catch (error) { - handleError(error, $t('errors.unable_to_add_album_users')); - } - }; - const onAlbumUpdate = (album: AlbumResponseDto) => { onUpdate(album); userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index ef4cc61daf..8325977ddc 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -33,8 +33,10 @@ export type Events = { AssetUpdate: [AssetResponseDto]; AssetReplace: [{ oldAssetId: string; newAssetId: string }]; + AlbumAddAssets: []; AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; + AlbumShare: []; PersonUpdate: [PersonResponseDto]; diff --git a/web/src/lib/modals/AlbumShareModal.svelte b/web/src/lib/modals/AlbumShareModal.svelte index 8a4995efc4..4ee612af06 100644 --- a/web/src/lib/modals/AlbumShareModal.svelte +++ b/web/src/lib/modals/AlbumShareModal.svelte @@ -2,16 +2,17 @@ import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte'; import { AppRoute } from '$lib/constants'; import Dropdown from '$lib/elements/Dropdown.svelte'; + import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; + import { handleAddUsersToAlbum } from '$lib/services/album.service'; import { AlbumUserRole, getAllSharedLinks, searchUsers, type AlbumResponseDto, - type AlbumUserAddDto, type SharedLinkResponseDto, type UserResponseDto, } from '@immich/sdk'; - import { Button, Icon, Link, Modal, ModalBody, Stack, Text } from '@immich/ui'; + import { Button, Icon, Link, Modal, ModalBody, modalManager, Stack, Text } from '@immich/ui'; import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -19,7 +20,7 @@ interface Props { album: AlbumResponseDto; - onClose: (result?: { action: 'sharedLink' } | { action: 'sharedUsers'; data: AlbumUserAddDto[] }) => void; + onClose: () => void; } let { album, onClose }: Props = $props(); @@ -62,6 +63,21 @@ selectedUsers[user.id].role = role; } }; + + const onShareUser = async () => { + const success = await handleAddUsersToAlbum( + album, + Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), + ); + if (success) { + onClose(); + } + }; + + const onShareLink = () => { + void modalManager.show(SharedLinkCreateModal, { albumId: album.id }); + onClose(); + }; @@ -145,12 +161,10 @@ fullWidth shape="round" disabled={Object.keys(selectedUsers).length === 0} - onclick={() => - onClose({ - action: 'sharedUsers', - data: Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), - })}>{$t('add')} + {$t('add')} + {/if} @@ -170,13 +184,9 @@ {/if} - + diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 7f0abc424e..e1ad368cf7 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -2,11 +2,89 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { AppRoute } from '$lib/constants'; import { eventManager } from '$lib/managers/event-manager.svelte'; +import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; +import { user } from '$lib/stores/user.store'; import { downloadArchive } from '$lib/utils/asset-utils'; +import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; -import { deleteAlbum, updateAlbumInfo, type AlbumResponseDto, type UpdateAlbumDto } from '@immich/sdk'; -import { modalManager, toastManager } from '@immich/ui'; +import { + addAssetsToAlbum, + addUsersToAlbum, + deleteAlbum, + updateAlbumInfo, + type AlbumResponseDto, + type AlbumUserAddDto, + type UpdateAlbumDto, +} from '@immich/sdk'; +import { modalManager, toastManager, type ActionItem } from '@immich/ui'; +import { mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js'; +import { type MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => { + const isOwned = get(user).id === album.ownerId; + + const Share: ActionItem = { + title: $t('share'), + type: $t('command'), + icon: mdiShareVariantOutline, + $if: () => isOwned, + onAction: () => modalManager.show(AlbumShareModal, { album }), + }; + + return { Share }; +}; + +export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => { + const AddAssets: ActionItem = { + title: $t('add_assets'), + type: $t('command'), + icon: mdiPlusBoxOutline, + $if: () => assets.length > 0, + onAction: () => addAssets(album, assets), + }; + + const Upload: ActionItem = { + title: $t('select_from_computer'), + description: $t('album_upload_assets'), + type: $t('command'), + icon: mdiUpload, + onAction: () => void openFileUploadDialog({ albumId: album.id }), + }; + + return { AddAssets, Upload }; +}; + +const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => { + const $t = await getFormatter(); + const assetIds = assets.map(({ id }) => id); + + try { + const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } }); + + const count = results.filter(({ success }) => success).length; + toastManager.success($t('assets_added_count', { values: { count } })); + eventManager.emit('AlbumAddAssets'); + } catch (error) { + handleError(error, $t('errors.error_adding_assets_to_album')); + } +}; + +export const handleAddUsersToAlbum = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => { + const $t = await getFormatter(); + + try { + await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers } }); + eventManager.emit('AlbumShare'); + return true; + } catch (error) { + handleError(error, $t('errors.error_adding_users_to_album')); + } + + return false; +}; export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => { const $t = await getFormatter(); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5d31bc2229..afd0ace65b 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,6 +8,7 @@ import AlbumTitle from '$lib/components/album-page/album-title.svelte'; import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte'; + import HeaderActionButton from '$lib/components/HeaderActionButton.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; @@ -38,7 +39,12 @@ import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte'; import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; - import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service'; + import { + getAlbumActions, + getAlbumAssetsActions, + handleDeleteAlbum, + handleDownloadAlbum, + } from '$lib/services/album.service'; import { getGlobalActions } from '$lib/services/app.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -46,7 +52,6 @@ import { preferences, user } from '$lib/stores/user.store'; import { handlePromiseError } from '$lib/utils'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { isAlbumsRoute, @@ -59,14 +64,11 @@ AlbumUserRole, AssetOrder, AssetVisibility, - addAssetsToAlbum, - addUsersToAlbum, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto, - type AlbumUserAddDto, } from '@immich/sdk'; - import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; + import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; import { mdiAccountEye, mdiAccountEyeOutline, @@ -80,8 +82,6 @@ mdiLink, mdiPlus, mdiPresentationPlay, - mdiShareVariantOutline, - mdiUpload, } from '@mdi/js'; import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; @@ -101,7 +101,6 @@ let backUrl: string = $state(AppRoute.ALBUMS); let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW); - let isCreatingSharedAlbum = $state(false); let albumOrder: AssetOrder | undefined = $state(data.album.order); let timelineManager = $state() as TimelineManager; @@ -124,9 +123,7 @@ backUrl = url || AppRoute.ALBUMS; - if (backUrl === AppRoute.SHARING && album.albumUsers.length === 0 && !album.hasSharedLink) { - isCreatingSharedAlbum = true; - } else if (backUrl === AppRoute.SHARED_LINKS) { + if (backUrl === AppRoute.SHARED_LINKS) { backUrl = history.state?.backUrl || AppRoute.ALBUMS; } }); @@ -177,26 +174,6 @@ const refreshAlbum = async () => { album = await getAlbumInfo({ id: album.id, withoutAssets: true }); }; - const handleAddAssets = async () => { - const assetIds = timelineInteraction.selectedAssets.map((asset) => asset.id); - - try { - const results = await addAssetsToAlbum({ - id: album.id, - bulkIdsDto: { ids: assetIds }, - }); - - const count = results.filter(({ success }) => success).length; - toastManager.success($t('assets_added_count', { values: { count } })); - - await refreshAlbum(); - - timelineInteraction.clearMultiselect(); - await setModeToView(); - } catch (error) { - handleError(error, $t('errors.error_adding_assets_to_album')); - } - }; const setModeToView = async () => { timelineManager.suspendTransitions = true; @@ -213,28 +190,6 @@ await setModeToView(); }; - const handleSelectFromComputer = async () => { - await openFileUploadDialog({ albumId: album.id }); - timelineInteraction.clearMultiselect(); - await setModeToView(); - }; - - const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { - try { - await addUsersToAlbum({ - id: album.id, - addUsersDto: { - albumUsers, - }, - }); - await refreshAlbum(); - - viewMode = AlbumPageViewMode.VIEW; - } catch (error) { - handleError(error, $t('errors.error_adding_users_to_album')); - } - }; - const handleSetVisibility = (assetIds: string[]) => { timelineManager.removeAssets(assetIds); assetInteraction.clearMultiselect(); @@ -353,22 +308,6 @@ viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction, ); - const handleShare = async () => { - const result = await modalManager.show(AlbumShareModal, { album }); - - switch (result?.action) { - case 'sharedLink': { - await handleShareLink(); - return; - } - - case 'sharedUsers': { - await handleAddUsers(result.data); - return; - } - } - }; - const onSharedLinkCreate = async () => { await refreshAlbum(); }; @@ -380,10 +319,6 @@ } }; - const handleShareLink = async () => { - await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); - }; - const handleEditUsers = async () => { const changed = await modalManager.show(AlbumUsersModal, { album }); @@ -405,7 +340,7 @@ break; } case 'shareUser': { - await handleShare(); + await modalManager.show(AlbumShareModal, { album }); break; } case 'refreshAlbum': { @@ -415,10 +350,24 @@ } }; + const onAlbumAddAssets = async () => { + await refreshAlbum(); + timelineInteraction.clearMultiselect(); + await setModeToView(); + }; + + const onAlbumShare = async () => { + await refreshAlbum(); + await setModeToView(); + }; + const { Cast } = $derived(getGlobalActions($t)); + const { Share } = $derived(getAlbumActions($t, album)); + const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets)); - + +
@@ -463,7 +412,7 @@ size="medium" shape="round" icon={mdiLink} - onclick={handleShareLink} + onclick={() => modalManager.show(SharedLinkCreateModal, { albumId: album.id })} /> {/if} @@ -491,16 +440,7 @@ /> {/if} - {#if isOwned} - - {/if} +
{/if} @@ -616,16 +556,7 @@ /> {/if} - {#if isOwned} - - {/if} + {#if featureFlagsManager.value.map} @@ -682,12 +613,6 @@ {/if} {/if} - - {#if isCreatingSharedAlbum && album.albumUsers.length === 0} - - {/if} {/snippet} {/if} @@ -705,10 +630,8 @@ {/snippet} {#snippet trailing()} - - + + {/snippet} {/if}