diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte index fc3739c4e6..3daa1f423f 100644 --- a/web/src/lib/components/photos-page/actions/archive-action.svelte +++ b/web/src/lib/components/photos-page/actions/archive-action.svelte @@ -1,57 +1,32 @@ diff --git a/web/src/lib/components/photos-page/actions/favorite-action.svelte b/web/src/lib/components/photos-page/actions/favorite-action.svelte index 8ca73958a3..ee38082aa0 100644 --- a/web/src/lib/components/photos-page/actions/favorite-action.svelte +++ b/web/src/lib/components/photos-page/actions/favorite-action.svelte @@ -1,58 +1,33 @@ diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte index 1cd3e0abab..fe3e5826a7 100644 --- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte +++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte @@ -1,10 +1,11 @@ diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 53bc5440d4..7d61a0acba 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -2,7 +2,12 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { AppRoute, AssetAction } from '$lib/constants'; - import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; + import { + isAllUserOwned, + type AssetInteractionStore, + isAllFavorite, + isAllArchived, + } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store'; import { locale, showDeleteModal } from '$lib/stores/preferences.store'; @@ -19,8 +24,11 @@ import AssetDateGroup from './asset-date-group.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; - import { deleteAssets } from '$lib/utils/actions'; + import { archiveAssets, deleteAssets, favoriteAssets, selectAll } from '$lib/utils/actions'; import DeleteAssetDialog from './delete-asset-dialog.svelte'; + import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; + import { user } from '$lib/stores/user.store'; + import { get } from 'svelte/store'; export let isSelectionMode = false; export let singleSelect = false; @@ -47,6 +55,9 @@ $: idsSelectedAssets = Array.from($selectedAssets) .filter((a) => !a.isExternal) .map((a) => a.id); + $: $isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === $user.id); + $: $isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); + $: $isAllArchived = Array.from($selectedAssets).every((asset) => asset.isArchived); const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>(); @@ -70,24 +81,30 @@ assetStore.disconnect(); }); - const trashOrDelete = (force: boolean = false) => { + const trashOrDelete = async (force: boolean = false) => { isShowDeleteConfirmation = false; - deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets); - assetInteractionStore.clearMultiselect(); + if ( + await deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets) + ) { + assetInteractionStore.clearMultiselect(); + } }; - const handleKeyboardPress = (event: KeyboardEvent) => { + const handleKeyboardPress = async (event: KeyboardEvent) => { if ($isSearchEnabled || shouldIgnoreShortcut(event)) { return; } const key = event.key; const shiftKey = event.shiftKey; + const ctrlKey = event.ctrlKey; + const assets = Array.from($selectedAssets); if (!$showAssetViewer) { switch (key) { - case 'Escape': - dispatch('escape'); + case '/': + event.preventDefault(); + goto(AppRoute.EXPLORE); return; case '?': if (event.shiftKey) { @@ -95,9 +112,43 @@ showShortcuts = !showShortcuts; } return; - case '/': - event.preventDefault(); - goto(AppRoute.EXPLORE); + case 'a': + case 'A': + if ($isMultiSelectState) { + if (ctrlKey) { + event.preventDefault(); + selectAll(get(assetStore), assetStore, assetInteractionStore); + return; + } + + if ($isAllUserOwned) { + if ( + await archiveAssets( + !$isAllArchived, + (assetIds) => { + for (const assetId of assetIds) { + assetStore.removeAsset(assetId); + } + }, + assets, + ) + ) { + assetInteractionStore.clearMultiselect(); + } + } + } + return; + case 'd': + case 'D': + if ($isMultiSelectState) { + assetInteractionStore.clearMultiselect(); + if (assets.length === 1) { + await downloadFile(assets[0]); + return; + } + + await downloadArchive('immich.zip', { assetIds: assets.map((asset) => asset.id) }); + } return; case 'Delete': if ($isMultiSelectState) { @@ -113,6 +164,16 @@ trashOrDelete(force); } return; + case 'Escape': + dispatch('escape'); + return; + case 'f': + if ($isMultiSelectState && $isAllUserOwned) { + if (await favoriteAssets(!$isAllFavorite, undefined, assets)) { + assetInteractionStore.clearMultiselect(); + } + } + return; } } }; diff --git a/web/src/lib/stores/asset-interaction.store.ts b/web/src/lib/stores/asset-interaction.store.ts index 8b36861579..8a39401585 100644 --- a/web/src/lib/stores/asset-interaction.store.ts +++ b/web/src/lib/stores/asset-interaction.store.ts @@ -147,3 +147,7 @@ export function createAssetInteractionStore(): AssetInteractionStore { }, }; } + +export const isAllUserOwned = writable(); +export const isAllFavorite = writable(); +export const isAllArchived = writable(); diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 3148143c0e..bd15963ed3 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -1,6 +1,9 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; -import { api } from '@api'; +import { api, AssetResponseDto } from '@api'; import { handleError } from './handle-error'; +import { isSelectAllCancelled, type AssetStore, BucketPosition } from '$lib/stores/assets.store'; +import { get } from 'svelte/store'; +import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; export type OnDelete = (assetId: string) => void; export type OnRestore = (ids: string[]) => void; @@ -8,7 +11,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (ids: string[]) => void; -export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { +export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]): Promise => { try { await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids, force } }); for (const id of ids) { @@ -19,7 +22,92 @@ export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: message: `${force ? 'Permanently deleted' : 'Trashed'} ${ids.length} assets`, type: NotificationType.Info, }); + return true; } catch (e) { handleError(e, 'Error deleting assets'); } + return false; +}; + +export const favoriteAssets = async ( + isFavorite: boolean, + onFavorite: OnFavorite | undefined, + assets: AssetResponseDto[], +): Promise => { + try { + const ids = assets.map(({ id }) => id); + + if (ids.length > 0) { + await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } }); + } + + for (const asset of assets) { + asset.isFavorite = isFavorite; + } + + onFavorite?.(ids, isFavorite); + + notificationController.show({ + message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`, + type: NotificationType.Info, + }); + + return true; + } catch (error) { + handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`); + } + return false; +}; + +export const archiveAssets = async ( + isArchived: boolean, + onArchive: OnArchive | undefined, + assets: AssetResponseDto[], +) => { + try { + const ids = assets.map(({ id }) => id); + + if (ids.length > 0) { + await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } }); + } + + for (const asset of assets) { + asset.isArchived = isArchived; + } + + onArchive?.(ids, isArchived); + + notificationController.show({ + message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`, + type: NotificationType.Info, + }); + + return true; + } catch (error) { + handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`); + } + return false; +}; + +export const selectAll = async ( + assetGridState: AssetStore, + assetStore: AssetStore, + assetInteractionStore: AssetInteractionStore, +): Promise => { + isSelectAllCancelled.set(false); + try { + for (const bucket of assetGridState.buckets) { + if (get(isSelectAllCancelled)) { + break; + } + await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + for (const asset of bucket.assets) { + assetInteractionStore.selectAsset(asset); + } + } + return true; + } catch (e) { + handleError(e, 'Error selecting all assets'); + } + return false; }; diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index c878b98f6e..8547764655 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -29,7 +29,7 @@ } from '$lib/components/shared-components/notification/notification'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import { AppRoute, dateFormats } from '$lib/constants'; - import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; + import { createAssetInteractionStore, isAllUserOwned } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetStore } from '$lib/stores/assets.store'; @@ -110,8 +110,6 @@ const { selectedAssets: timelineSelected } = timelineInteractionStore; $: isOwned = $user.id == album.ownerId; - $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === $user.id); - $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); $: { if (isShowActivity) { assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460); @@ -440,15 +438,15 @@ - {#if isAllUserOwned} - + {#if $isAllUserOwned} + + {/if} - - {#if isOwned || isAllUserOwned} + {#if isOwned || $isAllUserOwned} handleRemoveAssets(assetIds)} /> {/if} - {#if isAllUserOwned} + {#if $isAllUserOwned} assetStore.removeAsset(assetId)} /> diff --git a/web/src/routes/(user)/archive/+page.svelte b/web/src/routes/(user)/archive/+page.svelte index 0d3714a021..293420f780 100644 --- a/web/src/routes/(user)/archive/+page.svelte +++ b/web/src/routes/(user)/archive/+page.svelte @@ -23,13 +23,11 @@ const assetStore = new AssetStore({ isArchived: true }); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); {#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> - assetStore.removeAssets(ids)} /> + assetStore.removeAssets(ids)} /> @@ -39,7 +37,7 @@ assetStore.removeAsset(assetId)} /> - + {/if} diff --git a/web/src/routes/(user)/favorites/+page.svelte b/web/src/routes/(user)/favorites/+page.svelte index 4005a5d81c..39261be23a 100644 --- a/web/src/routes/(user)/favorites/+page.svelte +++ b/web/src/routes/(user)/favorites/+page.svelte @@ -25,14 +25,12 @@ const assetStore = new AssetStore({ isFavorite: true }); const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - - $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); {#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> - assetStore.removeAssets(ids)} /> + assetStore.removeAssets(ids)} /> @@ -42,7 +40,7 @@ assetStore.removeAsset(assetId)} /> - + diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index 6d7bd77865..8fb922fdb1 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -108,8 +108,6 @@ isSearchingPeople = false; }; - $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); - $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); $: $onPersonThumbnail === data.person.id && (thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`); @@ -394,8 +392,8 @@ $assetStore.removeAsset(assetId)} /> - - $assetStore.removeAssets(ids)} /> + + $assetStore.removeAssets(ids)} /> diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 913c4d038e..0eae81c185 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -31,8 +31,6 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); - const handleEscape = () => { if ($showAssetViewer) { return; @@ -65,7 +63,7 @@ onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} /> - + assetStore.removeAssets(ids)} /> {#if $selectedAssets.size > 1} diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index e8bc0c08d4..f05d01db45 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -96,8 +96,6 @@ let selectedAssets: Set = new Set(); $: isMultiSelectionMode = selectedAssets.size > 0; - $: isAllArchived = Array.from(selectedAssets).every((asset) => asset.isArchived); - $: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite); $: searchResultAssets = data.results?.assets.items; const onAssetDelete = (assetId: string) => { @@ -122,8 +120,8 @@ - - + +