refactor: add to album (#26366)

This commit is contained in:
Jason Rasmussen
2026-02-19 15:27:30 -05:00
committed by GitHub
parent 8eec3c810e
commit 1d11106dd0
23 changed files with 202 additions and 242 deletions
@@ -1,6 +1,6 @@
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
@@ -8,7 +8,6 @@ type ActionMap = {
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
@@ -1,44 +0,0 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiImageAlbum } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset, onAction }: Props = $props();
const onClick = async () => {
const albums = await modalManager.show(AlbumPickerModal, {});
if (!albums || albums.length === 0) {
return;
}
if (albums.length === 1) {
const album = albums[0];
await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
} else {
await addAssetsToAlbums(
albums.map((a) => a.id),
[asset.id],
);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
}
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'l' }, onShortcut: onClick }} />
<MenuOption icon={mdiImageAlbum} text={$t('add_to_album')} {onClick} />
@@ -3,7 +3,6 @@
import ActionButton from '$lib/components/ActionButton.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
@@ -102,6 +101,7 @@
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
AddToAlbum,
ZoomIn,
ZoomOut,
Copy,
@@ -129,6 +129,7 @@
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
AddToAlbum,
ZoomIn,
ZoomOut,
Copy,
@@ -181,14 +182,12 @@
<ActionMenuItem action={Download} />
<ActionMenuItem action={DownloadOriginal} />
{#if !isLocked}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
{/if}
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={AddToAlbum} />
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
@@ -167,9 +167,7 @@
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
await onAlbumAddAssets();
});
onDestroy(() => {
@@ -182,7 +180,7 @@
syncAssetViewerOpenClass(false);
});
const handleGetAllAlbums = async () => {
const onAlbumAddAssets = async () => {
if (authManager.isSharedLink) {
return;
}
@@ -303,10 +301,6 @@
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
break;
}
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
@@ -369,7 +363,7 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
await onAlbumAddAssets();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
@@ -441,7 +435,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} />
<svelte:document bind:fullscreenElement />
@@ -10,7 +10,6 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
@@ -25,6 +24,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
@@ -34,7 +34,7 @@
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
@@ -328,6 +328,7 @@
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
>
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CreateSharedLink />
<IconButton
shape="round"
@@ -338,7 +339,7 @@
onclick={handleSelectAll}
/>
<AddToAlbum />
<ActionButton action={Actions.AddToAlbum} />
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
@@ -1,54 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import type { OnAddToAlbum } from '$lib/utils/actions';
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { IconButton, modalManager } from '@immich/ui';
import { mdiImageAlbum, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onAddToAlbum?: OnAddToAlbum;
menuItem?: boolean;
}
let { onAddToAlbum = () => {}, menuItem = false }: Props = $props();
const { getAssets } = getAssetControlContext();
const onClick = async () => {
const albums = await modalManager.show(AlbumPickerModal, {});
if (!albums || albums.length === 0) {
return;
}
const assetIds = [...getAssets()].map(({ id }) => id);
if (albums.length === 1) {
const album = albums[0];
await addAssetsToAlbum(album.id, assetIds);
onAddToAlbum(assetIds, album.id);
} else {
await addAssetsToAlbums(
albums.map(({ id }) => id),
assetIds,
);
onAddToAlbum(assetIds, albums[0].id);
}
};
</script>
{#if menuItem}
<MenuOption {onClick} text={$t('add_to_album')} icon={mdiImageAlbum} shortcut={{ key: 'l' }} />
{/if}
{#if !menuItem}
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiPlus}
aria-label={$t('add_to_album')}
onclick={onClick}
/>
{/if}
-1
View File
@@ -6,7 +6,6 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
ADD_TO_ALBUM = 'add-to-album',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
+1 -1
View File
@@ -39,7 +39,7 @@ export type Events = {
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
@@ -0,0 +1,27 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { type AlbumResponseDto } from '@immich/sdk';
type Props = {
assetIds: string[];
onClose: () => void;
};
const { assetIds, onClose }: Props = $props();
const handleClose = async (albums?: AlbumResponseDto[]) => {
const albumIds = (albums ?? []).map(({ id }) => id);
if (albumIds.length === 0) {
onClose();
return;
}
const success = await addAssetsToAlbums(albumIds, assetIds, { notify: true });
if (success) {
onClose();
}
};
</script>
<AlbumPickerModal onClose={handleClose} />
+74 -8
View File
@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -11,17 +12,22 @@ import { user } from '$lib/stores/user.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
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 {
addAssetsToAlbum,
addAssetsToAlbum as addToAlbum,
addAssetsToAlbums as addToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
deleteAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type BulkIdResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
@@ -86,7 +92,12 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
color: 'primary',
icon: mdiPlusBoxOutline,
$if: () => assets.length > 0,
onAction: () => addAssets(album, assets),
onAction: () =>
addAssetsToAlbums(
[album.id],
assets.map(({ id }) => id),
{ notify: true },
).then(() => undefined),
};
const Upload: ActionItem = {
@@ -100,18 +111,73 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
return { AddAssets, Upload };
};
const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => {
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => {
const $t = await getFormatter();
const assetIds = assets.map(({ id }) => id);
try {
const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } });
if (albumIds.length === 1) {
const albumId = albumIds[0];
const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } });
if (notify) {
notifyAddToAlbum($t, albumId, assetIds, results);
}
}
const count = results.filter(({ success }) => success).length;
toastManager.success($t('assets_added_count', { values: { count } }));
eventManager.emit('AlbumAddAssets');
if (albumIds.length > 1) {
const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } });
if (notify) {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_assets_to_album'));
return false;
}
};
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => {
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (successCount > 0) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (duplicateCount > 0) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) },
},
},
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.success(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
+19 -1
View File
@@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
@@ -42,6 +43,7 @@ import {
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiPlus,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
@@ -59,6 +61,13 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte
ctx.clearSelect();
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -84,7 +93,7 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte
$if: () => isAllVideos,
};
return { RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
@@ -161,6 +170,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'f' }],
};
const AddToAlbum: ActionItem = {
title: $t('add_to_album'),
icon: mdiPlus,
shortcuts: [{ key: 'l' }],
$if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed,
onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }),
};
const Offline: ActionItem = {
title: $t('asset_offline'),
icon: mdiAlertOutline,
@@ -260,6 +277,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
AddToAlbum,
ZoomIn,
ZoomOut,
Copy,
@@ -1,5 +1,6 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { user } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store';
@@ -22,6 +23,14 @@ export class AssetInteraction {
private user = fromStore<UserAdminResponseDto | undefined>(user);
private userId = $derived(this.user.current?.id);
asControlContext(): AssetControlContext {
return {
getOwnedAssets: () => this.selectedAssets.filter((asset) => asset.ownerId === this.userId),
getAssets: () => this.selectedAssets,
clearSelect: () => this.clearMultiselect(),
};
}
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
-76
View File
@@ -1,10 +1,8 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, withError } from '$lib/utils';
@@ -13,10 +11,7 @@ import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import {
addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
AssetVisibility,
BulkIdErrorReason,
bulkTagAssets,
createStack,
deleteAssets,
@@ -41,77 +36,6 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => {
const result = await addAssets({
...authManager.params,
id: albumId,
bulkIdsDto: {
ids: assetIds,
},
});
const count = result.filter(({ success }) => success).length;
const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length;
const $t = get(t);
if (showNotification) {
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (count > 0) {
description = $t('assets_added_to_album_count', { values: { count } });
} else if (duplicateErrorCount > 0) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateErrorCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: {
text: $t('view_album'),
color: 'primary',
onClick() {
return goto(Route.viewAlbum({ id: albumId }));
},
},
},
},
{ timeout: 5000 },
);
}
};
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
const result = await addToAlbums({
...authManager.params,
albumsAddAssetsDto: {
albumIds,
assetIds,
},
});
if (!showNotification) {
return result;
}
if (showNotification) {
const $t = get(t);
if (result.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
return result;
}
if (result.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
return result;
}
toastManager.success(
$t('assets_added_to_albums_count', { values: { albumTotal: albumIds.length, assetTotal: assetIds.length } }),
);
return result;
}
};
export const tagAssets = async ({
assetIds,
tagIds,
+2 -2
View File
@@ -1,10 +1,10 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { uploadAssetsStore } from '$lib/stores/upload';
import { user } from '$lib/stores/user.store';
import { UploadState } from '$lib/types';
import { uploadRequest } from '$lib/utils';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';
import { asQueryString } from '$lib/utils/shared-links';
import {
@@ -213,7 +213,7 @@ async function fileUploader({
if (albumId) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbum(albumId, [responseData.id], false);
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
}