mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04:00 
			
		
		
		
	refactor(web): asset interaction (#14662)
* refactor(web): asset interaction * feedback
This commit is contained in:
		
							parent
							
								
									525840b040
								
							
						
					
					
						commit
						b5022d80d6
					
				| @ -4,7 +4,6 @@ | ||||
|   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; | ||||
|   import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; | ||||
|   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; | ||||
| @ -20,6 +19,7 @@ | ||||
|   import AlbumSummary from './album-summary.svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     sharedLink: SharedLinkResponseDto; | ||||
| @ -34,8 +34,7 @@ | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
| 
 | ||||
|   const assetStore = new AssetStore({ albumId: album.id, order: album.order }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   dragAndDropFilesStore.subscribe((value) => { | ||||
|     if (value.isDragging && value.files.length > 0) { | ||||
| @ -52,8 +51,8 @@ | ||||
|   use:shortcut={{ | ||||
|     shortcut: { key: 'Escape' }, | ||||
|     onShortcut: () => { | ||||
|       if (!$showAssetViewer && $isMultiSelectState) { | ||||
|         cancelMultiselect(assetInteractionStore); | ||||
|       if (!$showAssetViewer && assetInteraction.selectionActive) { | ||||
|         cancelMultiselect(assetInteraction); | ||||
|       } | ||||
|     }, | ||||
|   }} | ||||
| @ -61,13 +60,13 @@ | ||||
| /> | ||||
| 
 | ||||
| <header> | ||||
|   {#if $isMultiSelectState} | ||||
|   {#if assetInteraction.selectionActive} | ||||
|     <AssetSelectControlBar | ||||
|       ownerId={user?.id} | ||||
|       assets={$selectedAssets} | ||||
|       clearSelect={() => assetInteractionStore.clearMultiselect()} | ||||
|       assets={assetInteraction.selectedAssets} | ||||
|       clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|     > | ||||
|       <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|       <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|       {#if sharedLink.allowDownload} | ||||
|         <DownloadAction filename="{album.albumName}.zip" /> | ||||
|       {/if} | ||||
| @ -102,7 +101,7 @@ | ||||
| </header> | ||||
| 
 | ||||
| <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> | ||||
|   <AssetGrid enableRouting={true} {album} {assetStore} {assetInteractionStore}> | ||||
|   <AssetGrid enableRouting={true} {album} {assetStore} {assetInteraction}> | ||||
|     <section class="pt-8 md:pt-24"> | ||||
|       <!-- ALBUM TITLE --> | ||||
|       <h1 | ||||
|  | ||||
| @ -18,7 +18,6 @@ | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AppRoute, QueryParameter } from '$lib/constants'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { type Viewport } from '$lib/stores/assets.store'; | ||||
| @ -46,8 +45,9 @@ | ||||
|   import { tweened } from 'svelte/motion'; | ||||
|   import { derived as storeDerived } from 'svelte/store'; | ||||
|   import { fade } from 'svelte/transition'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   type MemoryIndex = { | ||||
|     memoryIndex: number; | ||||
| @ -73,8 +73,7 @@ | ||||
| 
 | ||||
|   const { isViewing } = assetViewingStore; | ||||
|   const viewport: Viewport = $state({ width: 0, height: 0 }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const progressBarController = tweened<number>(0, { | ||||
|     duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), | ||||
|   }); | ||||
| @ -130,7 +129,7 @@ | ||||
|   const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); | ||||
|   const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); | ||||
|   const handleEscape = async () => goto(AppRoute.PHOTOS); | ||||
|   const handleSelectAll = () => assetInteractionStore.selectAssets(current?.memory.assets || []); | ||||
|   const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); | ||||
|   const handleAction = async (action: 'reset' | 'pause' | 'play') => { | ||||
|     switch (action) { | ||||
|       case 'play': { | ||||
| @ -212,10 +211,6 @@ | ||||
|     current = loadFromParams($memories, target); | ||||
|   }); | ||||
| 
 | ||||
|   let isMultiSelectionMode = $derived($selectedAssets.size > 0); | ||||
|   let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     handlePromiseError(handleProgress($progressBarController)); | ||||
|   }); | ||||
| @ -223,7 +218,6 @@ | ||||
|   $effect(() => { | ||||
|     handlePromiseError(handleAction(galleryInView ? 'pause' : 'play')); | ||||
|   }); | ||||
|   let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:window | ||||
| @ -238,9 +232,12 @@ | ||||
|       ]} | ||||
| /> | ||||
| 
 | ||||
| {#if isMultiSelectionMode} | ||||
| {#if assetInteraction.selectionActive} | ||||
|   <div class="sticky top-0 z-[90]"> | ||||
|     <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => cancelMultiselect(assetInteractionStore)}> | ||||
|     <AssetSelectControlBar | ||||
|       assets={assetInteraction.selectedAssets} | ||||
|       clearSelect={() => cancelMultiselect(assetInteraction)} | ||||
|     > | ||||
|       <CreateSharedLink /> | ||||
|       <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> | ||||
| 
 | ||||
| @ -249,14 +246,14 @@ | ||||
|         <AddToAlbum shared /> | ||||
|       </ButtonContextMenu> | ||||
| 
 | ||||
|       <FavoriteAction removeFavorite={isAllFavorite} onFavorite={handleUpdate} /> | ||||
|       <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={handleUpdate} /> | ||||
| 
 | ||||
|       <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}> | ||||
|         <DownloadAction menuItem /> | ||||
|         <ChangeDate menuItem /> | ||||
|         <ChangeLocation menuItem /> | ||||
|         <ArchiveAction menuItem unarchive={isAllArchived} onArchive={handleRemove} /> | ||||
|         {#if $preferences.tags.enabled && isAllUserOwned} | ||||
|         <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleRemove} /> | ||||
|         {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} | ||||
|           <TagAction menuItem /> | ||||
|         {/if} | ||||
|         <DeleteAssets menuItem onAssetDelete={handleRemove} /> | ||||
| @ -490,7 +487,7 @@ | ||||
|           onPrevious={handlePreviousAsset} | ||||
|           assets={current.memory.assets} | ||||
|           {viewport} | ||||
|           {assetInteractionStore} | ||||
|           {assetInteraction} | ||||
|         /> | ||||
|       </div> | ||||
|     </section> | ||||
|  | ||||
| @ -1,24 +1,24 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets.store'; | ||||
|   import { mdiSelectAll, mdiSelectRemove } from '@mdi/js'; | ||||
|   import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     assetStore: AssetStore; | ||||
|     assetInteractionStore: AssetInteractionStore; | ||||
|     assetInteraction: AssetInteraction; | ||||
|   } | ||||
| 
 | ||||
|   let { assetStore, assetInteractionStore }: Props = $props(); | ||||
|   let { assetStore, assetInteraction }: Props = $props(); | ||||
| 
 | ||||
|   const handleSelectAll = async () => { | ||||
|     await selectAllAssets(assetStore, assetInteractionStore); | ||||
|     await selectAllAssets(assetStore, assetInteraction); | ||||
|   }; | ||||
| 
 | ||||
|   const handleCancel = () => { | ||||
|     cancelMultiselect(assetInteractionStore); | ||||
|     cancelMultiselect(assetInteraction); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
|  | ||||
| @ -2,7 +2,6 @@ | ||||
|   import { intersectionObserver } from '$lib/actions/intersection-observer'; | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
|   import Skeleton from '$lib/components/photos-page/skeleton.svelte'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store'; | ||||
|   import { navigate } from '$lib/utils/navigation'; | ||||
|   import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; | ||||
| @ -13,6 +12,7 @@ | ||||
|   import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; | ||||
|   import { TUNABLES } from '$lib/utils/tunables'; | ||||
|   import { generateId } from '$lib/utils/generate-id'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   export let element: HTMLElement | undefined = undefined; | ||||
|   export let isSelectionMode = false; | ||||
| @ -25,7 +25,7 @@ | ||||
|   export let renderThumbsAtTopMargin: string | undefined = undefined; | ||||
|   export let assetStore: AssetStore; | ||||
|   export let bucket: AssetBucket; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|   export let assetInteraction: AssetInteraction; | ||||
| 
 | ||||
|   export let onScrollTarget: ScrollTargetListener | undefined = undefined; | ||||
|   export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; | ||||
| @ -43,13 +43,11 @@ | ||||
|   /* TODO figure out a way to calculate this*/ | ||||
|   const TITLE_HEIGHT = 51; | ||||
| 
 | ||||
|   const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; | ||||
| 
 | ||||
|   let isMouseOverGroup = false; | ||||
|   let hoveredDateGroup = ''; | ||||
| 
 | ||||
|   const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { | ||||
|     if (isSelectionMode || $isMultiSelectState) { | ||||
|     if (isSelectionMode || assetInteraction.selectionActive) { | ||||
|       assetSelectHandler(asset, assets, groupTitle); | ||||
|       return; | ||||
|     } | ||||
| @ -69,13 +67,15 @@ | ||||
|     onSelectAssets(asset); | ||||
| 
 | ||||
|     // Check if all assets are selected in a group to toggle the group selection's icon | ||||
|     let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; | ||||
|     let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => | ||||
|       assetInteraction.selectedAssets.has(asset), | ||||
|     ).length; | ||||
| 
 | ||||
|     // if all assets are selected in a group, add the group to selected group | ||||
|     if (selectedAssetsInGroupCount == assetsInDateGroup.length) { | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(groupTitle); | ||||
|       assetInteraction.addGroupToMultiselectGroup(groupTitle); | ||||
|     } else { | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(groupTitle); | ||||
|       assetInteraction.removeGroupFromMultiselectGroup(groupTitle); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| @ -83,7 +83,7 @@ | ||||
|     // Show multi select icon on hover on date group | ||||
|     hoveredDateGroup = groupTitle; | ||||
| 
 | ||||
|     if ($isMultiSelectState) { | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       onSelectAssetCandidates(asset); | ||||
|     } | ||||
|   }; | ||||
| @ -151,14 +151,14 @@ | ||||
|             class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" | ||||
|             style:width={dateGroup.geometry.containerWidth + 'px'} | ||||
|           > | ||||
|             {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} | ||||
|             {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} | ||||
|               <div | ||||
|                 transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} | ||||
|                 class="inline-block px-2 hover:cursor-pointer" | ||||
|                 on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} | ||||
|                 on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} | ||||
|               > | ||||
|                 {#if $selectedGroup.has(dateGroup.groupTitle)} | ||||
|                 {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} | ||||
|                   <Icon path={mdiCheckCircle} size="24" color="#4250af" /> | ||||
|                 {:else} | ||||
|                   <Icon path={mdiCircleOutline} size="24" color="#757575" /> | ||||
| @ -212,8 +212,8 @@ | ||||
|                   onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} | ||||
|                   onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} | ||||
|                   onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} | ||||
|                   selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} | ||||
|                   selectionCandidate={$assetSelectionCandidates.has(asset)} | ||||
|                   selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} | ||||
|                   selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} | ||||
|                   disabled={$assetStore.albumAssets.has(asset.id)} | ||||
|                   thumbnailWidth={box.width} | ||||
|                   thumbnailHeight={box.height} | ||||
|  | ||||
| @ -3,7 +3,6 @@ | ||||
|   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; | ||||
|   import type { Action } from '$lib/components/asset-viewer/actions/action'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store'; | ||||
|   import { locale, showDeleteModal } from '$lib/stores/preferences.store'; | ||||
| @ -37,6 +36,7 @@ | ||||
|   import type { UpdatePayload } from 'vite'; | ||||
|   import { generateId } from '$lib/utils/generate-id'; | ||||
|   import { isTimelineScrolling } from '$lib/stores/timeline.store'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     isSelectionMode?: boolean; | ||||
| @ -46,7 +46,7 @@ | ||||
|    additionally, update the page location/url with the asset as the asset-grid is scrolled */ | ||||
|     enableRouting: boolean; | ||||
|     assetStore: AssetStore; | ||||
|     assetInteractionStore: AssetInteractionStore; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; | ||||
|     withStacked?: boolean; | ||||
|     showArchiveIcon?: boolean; | ||||
| @ -64,7 +64,7 @@ | ||||
|     singleSelect = false, | ||||
|     enableRouting, | ||||
|     assetStore = $bindable(), | ||||
|     assetInteractionStore, | ||||
|     assetInteraction, | ||||
|     removeAction = null, | ||||
|     withStacked = false, | ||||
|     showArchiveIcon = false, | ||||
| @ -78,8 +78,6 @@ | ||||
|   }: Props = $props(); | ||||
| 
 | ||||
|   let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; | ||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = | ||||
|     assetInteractionStore; | ||||
| 
 | ||||
|   const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); | ||||
|   const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); | ||||
| @ -437,11 +435,11 @@ | ||||
|       (assetIds) => $assetStore.removeAssets(assetIds), | ||||
|       idsSelectedAssets, | ||||
|     ); | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     assetInteraction.clearMultiselect(); | ||||
|   }; | ||||
| 
 | ||||
|   const onDelete = () => { | ||||
|     const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); | ||||
|     const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); | ||||
| 
 | ||||
|     if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { | ||||
|       isShowDeleteConfirmation = true; | ||||
| @ -459,7 +457,7 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const onStackAssets = async () => { | ||||
|     const ids = await stackAssets(Array.from($selectedAssets)); | ||||
|     const ids = await stackAssets(assetInteraction.selectedAssetsArray); | ||||
|     if (ids) { | ||||
|       $assetStore.removeAssets(ids); | ||||
|       onEscape(); | ||||
| @ -467,7 +465,7 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const toggleArchive = async () => { | ||||
|     const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); | ||||
|     const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); | ||||
|     if (ids) { | ||||
|       $assetStore.removeAssets(ids); | ||||
|       deselectAllAssets(); | ||||
| @ -482,7 +480,7 @@ | ||||
| 
 | ||||
|   const handleSelectAsset = (asset: AssetResponseDto) => { | ||||
|     if (!$assetStore.albumAssets.has(asset.id)) { | ||||
|       assetInteractionStore.selectAsset(asset); | ||||
|       assetInteraction.selectAsset(asset); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| @ -573,7 +571,7 @@ | ||||
|   let shiftKeyIsDown = $state(false); | ||||
| 
 | ||||
|   const deselectAllAssets = () => { | ||||
|     cancelMultiselect(assetInteractionStore); | ||||
|     cancelMultiselect(assetInteraction); | ||||
|   }; | ||||
| 
 | ||||
|   const onKeyDown = (event: KeyboardEvent) => { | ||||
| @ -606,13 +604,13 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => { | ||||
|     if ($selectedGroup.has(group)) { | ||||
|       assetInteractionStore.removeGroupFromMultiselectGroup(group); | ||||
|     if (assetInteraction.selectedGroup.has(group)) { | ||||
|       assetInteraction.removeGroupFromMultiselectGroup(group); | ||||
|       for (const asset of assets) { | ||||
|         assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|         assetInteraction.removeAssetFromMultiselectGroup(asset); | ||||
|       } | ||||
|     } else { | ||||
|       assetInteractionStore.addGroupToMultiselectGroup(group); | ||||
|       assetInteraction.addGroupToMultiselectGroup(group); | ||||
|       for (const asset of assets) { | ||||
|         handleSelectAsset(asset); | ||||
|       } | ||||
| @ -631,26 +629,26 @@ | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const rangeSelection = $assetSelectionCandidates.size > 0; | ||||
|     const deselect = $selectedAssets.has(asset); | ||||
|     const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; | ||||
|     const deselect = assetInteraction.selectedAssets.has(asset); | ||||
| 
 | ||||
|     // Select/deselect already loaded assets | ||||
|     if (deselect) { | ||||
|       for (const candidate of $assetSelectionCandidates || []) { | ||||
|         assetInteractionStore.removeAssetFromMultiselectGroup(candidate); | ||||
|       for (const candidate of assetInteraction.assetSelectionCandidates) { | ||||
|         assetInteraction.removeAssetFromMultiselectGroup(candidate); | ||||
|       } | ||||
|       assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|       assetInteraction.removeAssetFromMultiselectGroup(asset); | ||||
|     } else { | ||||
|       for (const candidate of $assetSelectionCandidates || []) { | ||||
|       for (const candidate of assetInteraction.assetSelectionCandidates) { | ||||
|         handleSelectAsset(candidate); | ||||
|       } | ||||
|       handleSelectAsset(asset); | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|     assetInteraction.clearAssetSelectionCandidates(); | ||||
| 
 | ||||
|     if ($assetSelectionStart && rangeSelection) { | ||||
|       let startBucketIndex = $assetStore.getBucketIndexByAssetId($assetSelectionStart.id); | ||||
|     if (assetInteraction.assetSelectionStart && rangeSelection) { | ||||
|       let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); | ||||
|       let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id); | ||||
| 
 | ||||
|       if (startBucketIndex === null || endBucketIndex === null) { | ||||
| @ -667,7 +665,7 @@ | ||||
|         await $assetStore.loadBucket(bucket.bucketDate); | ||||
|         for (const asset of bucket.assets) { | ||||
|           if (deselect) { | ||||
|             assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|             assetInteraction.removeAssetFromMultiselectGroup(asset); | ||||
|           } else { | ||||
|             handleSelectAsset(asset); | ||||
|           } | ||||
| @ -682,16 +680,16 @@ | ||||
|         const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); | ||||
|         for (const dateGroup of assetsGroupByDate) { | ||||
|           const dateGroupTitle = formatGroupTitle(dateGroup.date); | ||||
|           if (dateGroup.assets.every((a) => $selectedAssets.has(a))) { | ||||
|             assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); | ||||
|           if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) { | ||||
|             assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); | ||||
|           } else { | ||||
|             assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); | ||||
|             assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); | ||||
|     assetInteraction.setAssetSelectionStart(deselect ? null : asset); | ||||
|   }; | ||||
| 
 | ||||
|   const selectAssetCandidates = (endAsset: AssetResponseDto) => { | ||||
| @ -699,7 +697,7 @@ | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const startAsset = $assetSelectionStart; | ||||
|     const startAsset = assetInteraction.assetSelectionStart; | ||||
|     if (!startAsset) { | ||||
|       return; | ||||
|     } | ||||
| @ -711,11 +709,11 @@ | ||||
|       [start, end] = [end, start]; | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); | ||||
|     assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); | ||||
|   }; | ||||
| 
 | ||||
|   const onSelectStart = (e: Event) => { | ||||
|     if ($isMultiSelectState && shiftKeyIsDown) { | ||||
|     if (assetInteraction.selectionActive && shiftKeyIsDown) { | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
| @ -724,12 +722,11 @@ | ||||
|   }); | ||||
|   let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); | ||||
|   let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0); | ||||
|   let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); | ||||
|   let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     if (isEmpty) { | ||||
|       assetInteractionStore.clearMultiselect(); | ||||
|       assetInteraction.clearMultiselect(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
| @ -760,12 +757,12 @@ | ||||
|         { shortcut: { key: 'Escape' }, onShortcut: onEscape }, | ||||
|         { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, | ||||
|         { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, | ||||
|         { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, | ||||
|         { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteraction) }, | ||||
|         { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, | ||||
|         { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, | ||||
|       ]; | ||||
| 
 | ||||
|       if ($isMultiSelectState) { | ||||
|       if (assetInteraction.selectionActive) { | ||||
|         shortcuts.push( | ||||
|           { shortcut: { key: 'Delete' }, onShortcut: onDelete }, | ||||
|           { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete }, | ||||
| @ -781,13 +778,13 @@ | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     if (!lastAssetMouseEvent) { | ||||
|       assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|       assetInteraction.clearAssetSelectionCandidates(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     if (!shiftKeyIsDown) { | ||||
|       assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|       assetInteraction.clearAssetSelectionCandidates(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
| @ -889,7 +886,7 @@ | ||||
|             {withStacked} | ||||
|             {showArchiveIcon} | ||||
|             {assetStore} | ||||
|             {assetInteractionStore} | ||||
|             {assetInteraction} | ||||
|             {isSelectionMode} | ||||
|             {singleSelect} | ||||
|             {onScrollTarget} | ||||
|  | ||||
| @ -15,11 +15,11 @@ | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|   import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; | ||||
|   import { NotificationType, notificationController } from '../shared-components/notification/notification'; | ||||
|   import type { Viewport } from '$lib/stores/assets.store'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     sharedLink: SharedLinkResponseDto; | ||||
| @ -29,12 +29,10 @@ | ||||
|   let { sharedLink = $bindable(), isOwned }: Props = $props(); | ||||
| 
 | ||||
|   const viewport: Viewport = $state({ width: 0, height: 0 }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   let innerWidth: number = $state(0); | ||||
| 
 | ||||
|   let assets = $derived(sharedLink.assets); | ||||
|   let isMultiSelectionMode = $derived($selectedAssets.size > 0); | ||||
| 
 | ||||
|   dragAndDropFilesStore.subscribe((value) => { | ||||
|     if (value.isDragging && value.files.length > 0) { | ||||
| @ -73,15 +71,18 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectAll = () => { | ||||
|     assetInteractionStore.selectAssets(assets); | ||||
|     assetInteraction.selectAssets(assets); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:window bind:innerWidth /> | ||||
| 
 | ||||
| <section class="bg-immich-bg dark:bg-immich-dark-bg"> | ||||
|   {#if isMultiSelectionMode} | ||||
|     <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => cancelMultiselect(assetInteractionStore)}> | ||||
|   {#if assetInteraction.selectionActive} | ||||
|     <AssetSelectControlBar | ||||
|       assets={assetInteraction.selectedAssets} | ||||
|       clearSelect={() => cancelMultiselect(assetInteraction)} | ||||
|     > | ||||
|       <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> | ||||
|       {#if sharedLink?.allowDownload} | ||||
|         <DownloadAction filename="immich-shared.zip" /> | ||||
| @ -112,6 +113,6 @@ | ||||
|     </ControlAppBar> | ||||
|   {/if} | ||||
|   <section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}> | ||||
|     <GalleryViewer {assets} {assetInteractionStore} {viewport} /> | ||||
|     <GalleryViewer {assets} {assetInteraction} {viewport} /> | ||||
|   </section> | ||||
| </section> | ||||
|  | ||||
| @ -5,7 +5,6 @@ | ||||
|   import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import type { Viewport } from '$lib/stores/assets.store'; | ||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||
|   import { deleteAssets } from '$lib/utils/actions'; | ||||
| @ -22,10 +21,11 @@ | ||||
|   import Portal from '../portal/portal.svelte'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     assets: AssetResponseDto[]; | ||||
|     assetInteractionStore: AssetInteractionStore; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     disableAssetSelect?: boolean; | ||||
|     showArchiveIcon?: boolean; | ||||
|     viewport: Viewport; | ||||
| @ -38,7 +38,7 @@ | ||||
| 
 | ||||
|   let { | ||||
|     assets = $bindable(), | ||||
|     assetInteractionStore = $bindable(), | ||||
|     assetInteraction, | ||||
|     disableAssetSelect = false, | ||||
|     showArchiveIcon = false, | ||||
|     viewport, | ||||
| @ -51,11 +51,8 @@ | ||||
| 
 | ||||
|   let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; | ||||
| 
 | ||||
|   const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||
| 
 | ||||
|   let showShortcuts = $state(false); | ||||
|   let currentViewAssetIndex = 0; | ||||
|   let isMultiSelectionMode = $derived($selectedAssets.size > 0); | ||||
|   let shiftKeyIsDown = $state(false); | ||||
|   let lastAssetMouseEvent: AssetResponseDto | null = $state(null); | ||||
| 
 | ||||
| @ -66,11 +63,11 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const selectAllAssets = () => { | ||||
|     assetInteractionStore.selectAssets(assets); | ||||
|     assetInteraction.selectAssets(assets); | ||||
|   }; | ||||
| 
 | ||||
|   const deselectAllAssets = () => { | ||||
|     cancelMultiselect(assetInteractionStore); | ||||
|     cancelMultiselect(assetInteraction); | ||||
|   }; | ||||
| 
 | ||||
|   const onKeyDown = (event: KeyboardEvent) => { | ||||
| @ -91,23 +88,23 @@ | ||||
|     if (!asset) { | ||||
|       return; | ||||
|     } | ||||
|     const deselect = $selectedAssets.has(asset); | ||||
|     const deselect = assetInteraction.selectedAssets.has(asset); | ||||
| 
 | ||||
|     // Select/deselect already loaded assets | ||||
|     if (deselect) { | ||||
|       for (const candidate of $assetSelectionCandidates || []) { | ||||
|         assetInteractionStore.removeAssetFromMultiselectGroup(candidate); | ||||
|       for (const candidate of assetInteraction.assetSelectionCandidates) { | ||||
|         assetInteraction.removeAssetFromMultiselectGroup(candidate); | ||||
|       } | ||||
|       assetInteractionStore.removeAssetFromMultiselectGroup(asset); | ||||
|       assetInteraction.removeAssetFromMultiselectGroup(asset); | ||||
|     } else { | ||||
|       for (const candidate of $assetSelectionCandidates || []) { | ||||
|         assetInteractionStore.selectAsset(candidate); | ||||
|       for (const candidate of assetInteraction.assetSelectionCandidates) { | ||||
|         assetInteraction.selectAsset(candidate); | ||||
|       } | ||||
|       assetInteractionStore.selectAsset(asset); | ||||
|       assetInteraction.selectAsset(asset); | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|     assetInteractionStore.setAssetSelectionStart(deselect ? null : asset); | ||||
|     assetInteraction.clearAssetSelectionCandidates(); | ||||
|     assetInteraction.setAssetSelectionStart(deselect ? null : asset); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { | ||||
| @ -122,7 +119,7 @@ | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const startAsset = $assetSelectionStart; | ||||
|     const startAsset = assetInteraction.assetSelectionStart; | ||||
|     if (!startAsset) { | ||||
|       return; | ||||
|     } | ||||
| @ -134,17 +131,17 @@ | ||||
|       [start, end] = [end, start]; | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.setAssetSelectionCandidates(assets.slice(start, end + 1)); | ||||
|     assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); | ||||
|   }; | ||||
| 
 | ||||
|   const onSelectStart = (e: Event) => { | ||||
|     if ($isMultiSelectState && shiftKeyIsDown) { | ||||
|     if (assetInteraction.selectionActive && shiftKeyIsDown) { | ||||
|       e.preventDefault(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const onDelete = () => { | ||||
|     const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed); | ||||
|     const hasTrashedAsset = assetInteraction.selectedAssetsArray.some((asset) => asset.isTrashed); | ||||
| 
 | ||||
|     if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) { | ||||
|       isShowDeleteConfirmation = true; | ||||
| @ -168,11 +165,11 @@ | ||||
|       (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), | ||||
|       idsSelectedAssets, | ||||
|     ); | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     assetInteraction.clearMultiselect(); | ||||
|   }; | ||||
| 
 | ||||
|   const toggleArchive = async () => { | ||||
|     const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); | ||||
|     const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); | ||||
|     if (ids) { | ||||
|       assets.filter((asset) => !ids.includes(asset.id)); | ||||
|       deselectAllAssets(); | ||||
| @ -191,7 +188,7 @@ | ||||
|         { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() }, | ||||
|       ]; | ||||
| 
 | ||||
|       if ($isMultiSelectState) { | ||||
|       if (assetInteraction.selectionActive) { | ||||
|         shortcuts.push( | ||||
|           { shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets }, | ||||
|           { shortcut: { key: 'Delete' }, onShortcut: onDelete }, | ||||
| @ -266,14 +263,13 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const assetMouseEventHandler = (asset: AssetResponseDto | null) => { | ||||
|     if ($isMultiSelectState) { | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       handleSelectAssetCandidates(asset); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); | ||||
|   let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id)); | ||||
|   let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); | ||||
| 
 | ||||
|   let geometry = $derived( | ||||
|     (() => { | ||||
| @ -297,13 +293,13 @@ | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     if (!lastAssetMouseEvent) { | ||||
|       assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|       assetInteraction.clearAssetSelectionCandidates(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     if (!shiftKeyIsDown) { | ||||
|       assetInteractionStore.clearAssetSelectionCandidates(); | ||||
|       assetInteraction.clearAssetSelectionCandidates(); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
| @ -318,7 +314,7 @@ | ||||
| 
 | ||||
| {#if isShowDeleteConfirmation} | ||||
|   <DeleteAssetDialog | ||||
|     size={idsSelectedAssets.length} | ||||
|     size={assetInteraction.selectedAssets.size} | ||||
|     onCancel={() => (isShowDeleteConfirmation = false)} | ||||
|     onConfirm={() => handlePromiseError(trashOrDelete(true))} | ||||
|   /> | ||||
| @ -340,7 +336,7 @@ | ||||
|         <Thumbnail | ||||
|           readonly={disableAssetSelect} | ||||
|           onClick={(asset) => { | ||||
|             if (isMultiSelectionMode) { | ||||
|             if (assetInteraction.selectionActive) { | ||||
|               handleSelectAssets(asset); | ||||
|               return; | ||||
|             } | ||||
| @ -351,8 +347,8 @@ | ||||
|           onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} | ||||
|           {showArchiveIcon} | ||||
|           {asset} | ||||
|           selected={$selectedAssets.has(asset)} | ||||
|           selectionCandidate={$assetSelectionCandidates.has(asset)} | ||||
|           selected={assetInteraction.selectedAssets.has(asset)} | ||||
|           selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} | ||||
|           thumbnailWidth={geometry.boxes[i].width} | ||||
|           thumbnailHeight={geometry.boxes[i].height} | ||||
|         /> | ||||
|  | ||||
| @ -1,86 +0,0 @@ | ||||
| import type { AssetResponseDto } from '@immich/sdk'; | ||||
| import { derived, readonly, writable } from 'svelte/store'; | ||||
| 
 | ||||
| export type AssetInteractionStore = ReturnType<typeof createAssetInteractionStore>; | ||||
| 
 | ||||
| export function createAssetInteractionStore() { | ||||
|   const selectedAssets = writable(new Set<AssetResponseDto>()); | ||||
|   const selectedGroup = writable(new Set<string>()); | ||||
|   const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0); | ||||
| 
 | ||||
|   // Candidates for the range selection. This set includes only loaded assets, so it improves highlight
 | ||||
|   // performance. From the user's perspective, range is highlighted almost immediately
 | ||||
|   const assetSelectionCandidates = writable(new Set<AssetResponseDto>()); | ||||
|   // The beginning of the selection range
 | ||||
|   const assetSelectionStart = writable<AssetResponseDto | null>(null); | ||||
| 
 | ||||
|   const selectAsset = (asset: AssetResponseDto) => { | ||||
|     selectedAssets.update(($selectedAssets) => $selectedAssets.add(asset)); | ||||
|   }; | ||||
| 
 | ||||
|   const selectAssets = (assets: AssetResponseDto[]) => { | ||||
|     selectedAssets.update(($selectedAssets) => { | ||||
|       for (const asset of assets) { | ||||
|         $selectedAssets.add(asset); | ||||
|       } | ||||
|       return $selectedAssets; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => { | ||||
|     selectedAssets.update(($selectedAssets) => { | ||||
|       $selectedAssets.delete(asset); | ||||
|       return $selectedAssets; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const addGroupToMultiselectGroup = (group: string) => { | ||||
|     selectedGroup.update(($selectedGroup) => $selectedGroup.add(group)); | ||||
|   }; | ||||
| 
 | ||||
|   const removeGroupFromMultiselectGroup = (group: string) => { | ||||
|     selectedGroup.update(($selectedGroup) => { | ||||
|       $selectedGroup.delete(group); | ||||
|       return $selectedGroup; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const setAssetSelectionStart = (asset: AssetResponseDto | null) => { | ||||
|     assetSelectionStart.set(asset); | ||||
|   }; | ||||
| 
 | ||||
|   const setAssetSelectionCandidates = (assets: AssetResponseDto[]) => { | ||||
|     assetSelectionCandidates.set(new Set(assets)); | ||||
|   }; | ||||
| 
 | ||||
|   const clearAssetSelectionCandidates = () => { | ||||
|     assetSelectionCandidates.set(new Set()); | ||||
|   }; | ||||
| 
 | ||||
|   const clearMultiselect = () => { | ||||
|     // Multi-selection
 | ||||
|     selectedAssets.set(new Set()); | ||||
|     selectedGroup.set(new Set()); | ||||
| 
 | ||||
|     // Range selection
 | ||||
|     assetSelectionCandidates.set(new Set()); | ||||
|     assetSelectionStart.set(null); | ||||
|   }; | ||||
| 
 | ||||
|   return { | ||||
|     selectAsset, | ||||
|     selectAssets, | ||||
|     removeAssetFromMultiselectGroup, | ||||
|     addGroupToMultiselectGroup, | ||||
|     removeGroupFromMultiselectGroup, | ||||
|     setAssetSelectionCandidates, | ||||
|     clearAssetSelectionCandidates, | ||||
|     setAssetSelectionStart, | ||||
|     clearMultiselect, | ||||
|     isMultiSelectState: readonly(isMultiSelectStoreState), | ||||
|     selectedAssets: readonly(selectedAssets), | ||||
|     selectedGroup: readonly(selectedGroup), | ||||
|     assetSelectionCandidates: readonly(assetSelectionCandidates), | ||||
|     assetSelectionStart: readonly(assetSelectionStart), | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										40
									
								
								web/src/lib/stores/asset-interaction.svelte.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/src/lib/stores/asset-interaction.svelte.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| import { resetSavedUser, user } from '$lib/stores/user.store'; | ||||
| import { assetFactory } from '@test-data/factories/asset-factory'; | ||||
| import { userAdminFactory } from '@test-data/factories/user-factory'; | ||||
| 
 | ||||
| describe('AssetInteraction', () => { | ||||
|   let assetInteraction: AssetInteraction; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     assetInteraction = new AssetInteraction(); | ||||
|   }); | ||||
| 
 | ||||
|   it('calculates derived values from selection', () => { | ||||
|     assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true })); | ||||
|     assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false })); | ||||
| 
 | ||||
|     expect(assetInteraction.selectionActive).toBe(true); | ||||
|     expect(assetInteraction.isAllTrashed).toBe(false); | ||||
|     expect(assetInteraction.isAllArchived).toBe(false); | ||||
|     expect(assetInteraction.isAllFavorite).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it('updates isAllUserOwned when the active user changes', () => { | ||||
|     const [user1, user2] = userAdminFactory.buildList(2); | ||||
|     assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id })); | ||||
| 
 | ||||
|     const cleanup = $effect.root(() => { | ||||
|       expect(assetInteraction.isAllUserOwned).toBe(false); | ||||
| 
 | ||||
|       user.set(user1); | ||||
|       expect(assetInteraction.isAllUserOwned).toBe(true); | ||||
| 
 | ||||
|       user.set(user2); | ||||
|       expect(assetInteraction.isAllUserOwned).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     cleanup(); | ||||
|     resetSavedUser(); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										66
									
								
								web/src/lib/stores/asset-interaction.svelte.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								web/src/lib/stores/asset-interaction.svelte.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | ||||
| import { user } from '$lib/stores/user.store'; | ||||
| import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; | ||||
| import { SvelteSet } from 'svelte/reactivity'; | ||||
| import { fromStore } from 'svelte/store'; | ||||
| 
 | ||||
| export class AssetInteraction { | ||||
|   readonly selectedAssets = new SvelteSet<AssetResponseDto>(); | ||||
|   readonly selectedGroup = new SvelteSet<string>(); | ||||
|   assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>()); | ||||
|   assetSelectionStart = $state<AssetResponseDto | null>(null); | ||||
| 
 | ||||
|   selectionActive = $derived(this.selectedAssets.size > 0); | ||||
|   selectedAssetsArray = $derived([...this.selectedAssets]); | ||||
| 
 | ||||
|   private user = fromStore<UserAdminResponseDto | undefined>(user); | ||||
|   private userId = $derived(this.user.current?.id); | ||||
| 
 | ||||
|   isAllTrashed = $derived(this.selectedAssetsArray.every((asset) => asset.isTrashed)); | ||||
|   isAllArchived = $derived(this.selectedAssetsArray.every((asset) => asset.isArchived)); | ||||
|   isAllFavorite = $derived(this.selectedAssetsArray.every((asset) => asset.isFavorite)); | ||||
|   isAllUserOwned = $derived(this.selectedAssetsArray.every((asset) => asset.ownerId === this.userId)); | ||||
| 
 | ||||
|   selectAsset(asset: AssetResponseDto) { | ||||
|     this.selectedAssets.add(asset); | ||||
|   } | ||||
| 
 | ||||
|   selectAssets(assets: AssetResponseDto[]) { | ||||
|     for (const asset of assets) { | ||||
|       this.selectedAssets.add(asset); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   removeAssetFromMultiselectGroup(asset: AssetResponseDto) { | ||||
|     this.selectedAssets.delete(asset); | ||||
|   } | ||||
| 
 | ||||
|   addGroupToMultiselectGroup(group: string) { | ||||
|     this.selectedGroup.add(group); | ||||
|   } | ||||
| 
 | ||||
|   removeGroupFromMultiselectGroup(group: string) { | ||||
|     this.selectedGroup.delete(group); | ||||
|   } | ||||
| 
 | ||||
|   setAssetSelectionStart(asset: AssetResponseDto | null) { | ||||
|     this.assetSelectionStart = asset; | ||||
|   } | ||||
| 
 | ||||
|   setAssetSelectionCandidates(assets: AssetResponseDto[]) { | ||||
|     this.assetSelectionCandidates = new SvelteSet(assets); | ||||
|   } | ||||
| 
 | ||||
|   clearAssetSelectionCandidates() { | ||||
|     this.assetSelectionCandidates.clear(); | ||||
|   } | ||||
| 
 | ||||
|   clearMultiselect() { | ||||
|     // Multi-selection
 | ||||
|     this.selectedAssets.clear(); | ||||
|     this.selectedGroup.clear(); | ||||
| 
 | ||||
|     // Range selection
 | ||||
|     this.assetSelectionCandidates.clear(); | ||||
|     this.assetSelectionStart = null; | ||||
|   } | ||||
| } | ||||
| @ -2,7 +2,7 @@ import { goto } from '$app/navigation'; | ||||
| import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; | ||||
| import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; | ||||
| import { AppRoute } from '$lib/constants'; | ||||
| import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
| import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
| import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; | ||||
| import { downloadManager } from '$lib/stores/download'; | ||||
| @ -460,7 +460,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { | ||||
| export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => { | ||||
|   if (get(isSelectingAllAssets)) { | ||||
|     // Selection is already ongoing
 | ||||
|     return; | ||||
| @ -474,7 +474,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt | ||||
|       if (!get(isSelectingAllAssets)) { | ||||
|         break; // Cancelled
 | ||||
|       } | ||||
|       assetInteractionStore.selectAssets(bucket.assets); | ||||
|       assetInteraction.selectAssets(bucket.assets); | ||||
| 
 | ||||
|       // We use setTimeout to allow the UI to update. Otherwise, this may
 | ||||
|       // cause a long delay between the start of 'select all' and the
 | ||||
| @ -489,9 +489,9 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const cancelMultiselect = (assetInteractionStore: AssetInteractionStore) => { | ||||
| export const cancelMultiselect = (assetInteraction: AssetInteraction) => { | ||||
|   isSelectingAllAssets.set(false); | ||||
|   assetInteractionStore.clearMultiselect(); | ||||
|   assetInteraction.clearMultiselect(); | ||||
| }; | ||||
| 
 | ||||
| export const toggleArchive = async (asset: AssetResponseDto) => { | ||||
|  | ||||
| @ -35,7 +35,6 @@ | ||||
|   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; | ||||
|   import { AppRoute, AlbumPageViewMode } from '$lib/constants'; | ||||
|   import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; | ||||
| @ -87,6 +86,7 @@ | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { confirmAlbumDelete } from '$lib/utils/album-utils'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -107,11 +107,8 @@ | ||||
|   let reactions: ActivityResponseDto[] = $state([]); | ||||
|   let albumOrder: AssetOrder | undefined = $state(data.album.order); | ||||
| 
 | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
| 
 | ||||
|   const timelineInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets: timelineSelected } = timelineInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const timelineInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   afterNavigate(({ from }) => { | ||||
|     let url: string | undefined = from?.url?.pathname; | ||||
| @ -234,8 +231,8 @@ | ||||
|     if ($showAssetViewer) { | ||||
|       return; | ||||
|     } | ||||
|     if ($isMultiSelectState) { | ||||
|       cancelMultiselect(assetInteractionStore); | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       cancelMultiselect(assetInteraction); | ||||
|       return; | ||||
|     } | ||||
|     await goto(backUrl); | ||||
| @ -245,9 +242,8 @@ | ||||
|   const refreshAlbum = async () => { | ||||
|     album = await getAlbumInfo({ id: album.id, withoutAssets: true }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleAddAssets = async () => { | ||||
|     const assetIds = [...$timelineSelected].map((asset) => asset.id); | ||||
|     const assetIds = timelineInteraction.selectedAssetsArray.map((asset) => asset.id); | ||||
| 
 | ||||
|     try { | ||||
|       const results = await addAssetsToAlbum({ | ||||
| @ -263,7 +259,7 @@ | ||||
| 
 | ||||
|       await refreshAlbum(); | ||||
| 
 | ||||
|       timelineInteractionStore.clearMultiselect(); | ||||
|       timelineInteraction.clearMultiselect(); | ||||
|       await setModeToView(); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.error_adding_assets_to_album')); | ||||
| @ -284,13 +280,13 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const handleCloseSelectAssets = async () => { | ||||
|     timelineInteractionStore.clearMultiselect(); | ||||
|     timelineInteraction.clearMultiselect(); | ||||
|     await setModeToView(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectFromComputer = async () => { | ||||
|     await openFileUploadDialog({ albumId: album.id }); | ||||
|     timelineInteractionStore.clearMultiselect(); | ||||
|     timelineInteraction.clearMultiselect(); | ||||
|     await setModeToView(); | ||||
|   }; | ||||
| 
 | ||||
| @ -359,16 +355,16 @@ | ||||
|     } | ||||
| 
 | ||||
|     viewMode = AlbumPageViewMode.VIEW; | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     assetInteraction.clearMultiselect(); | ||||
| 
 | ||||
|     await updateThumbnail(assetId); | ||||
|   }; | ||||
| 
 | ||||
|   const updateThumbnailUsingCurrentSelection = async () => { | ||||
|     if ($selectedAssets.size === 1) { | ||||
|       const assetId = [...$selectedAssets][0].id; | ||||
|       assetInteractionStore.clearMultiselect(); | ||||
|       await updateThumbnail(assetId); | ||||
|     if (assetInteraction.selectedAssets.size === 1) { | ||||
|       const [firstAsset] = assetInteraction.selectedAssets; | ||||
|       assetInteraction.clearMultiselect(); | ||||
|       await updateThumbnail(firstAsset.id); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| @ -410,9 +406,6 @@ | ||||
|   let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); | ||||
| 
 | ||||
|   let isOwned = $derived($user.id == album.ownerId); | ||||
|   let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); | ||||
|   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); | ||||
|   let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
| 
 | ||||
|   let showActivityStatus = $derived( | ||||
|     album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), | ||||
| @ -433,40 +426,50 @@ | ||||
| 
 | ||||
| <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}> | ||||
|   <div class="relative w-full shrink"> | ||||
|     {#if $isMultiSelectState} | ||||
|       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
|     {#if assetInteraction.selectionActive} | ||||
|       <AssetSelectControlBar | ||||
|         assets={assetInteraction.selectedAssets} | ||||
|         clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|       > | ||||
|         <CreateSharedLink /> | ||||
|         <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|         <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|         <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|           <AddToAlbum /> | ||||
|           <AddToAlbum shared /> | ||||
|         </ButtonContextMenu> | ||||
|         {#if isAllUserOwned} | ||||
|           <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|         {#if assetInteraction.isAllUserOwned} | ||||
|           <FavoriteAction | ||||
|             removeFavorite={assetInteraction.isAllFavorite} | ||||
|             onFavorite={() => assetStore.triggerUpdate()} | ||||
|           /> | ||||
|         {/if} | ||||
|         <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> | ||||
|           <DownloadAction menuItem filename="{album.albumName}.zip" /> | ||||
|           {#if isAllUserOwned} | ||||
|           {#if assetInteraction.isAllUserOwned} | ||||
|             <ChangeDate menuItem /> | ||||
|             <ChangeLocation menuItem /> | ||||
|             {#if $selectedAssets.size === 1} | ||||
|             {#if assetInteraction.selectedAssets.size === 1} | ||||
|               <MenuOption | ||||
|                 text={$t('set_as_album_cover')} | ||||
|                 icon={mdiImageOutline} | ||||
|                 onClick={() => updateThumbnailUsingCurrentSelection()} | ||||
|               /> | ||||
|             {/if} | ||||
|             <ArchiveAction menuItem unarchive={isAllArchived} onArchive={() => assetStore.triggerUpdate()} /> | ||||
|             <ArchiveAction | ||||
|               menuItem | ||||
|               unarchive={assetInteraction.isAllArchived} | ||||
|               onArchive={() => assetStore.triggerUpdate()} | ||||
|             /> | ||||
|           {/if} | ||||
| 
 | ||||
|           {#if $preferences.tags.enabled && isAllUserOwned} | ||||
|           {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} | ||||
|             <TagAction menuItem /> | ||||
|           {/if} | ||||
| 
 | ||||
|           {#if isOwned || isAllUserOwned} | ||||
|           {#if isOwned || assetInteraction.isAllUserOwned} | ||||
|             <RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} /> | ||||
|           {/if} | ||||
|           {#if isAllUserOwned} | ||||
|           {#if assetInteraction.isAllUserOwned} | ||||
|             <DeleteAssets menuItem onAssetDelete={handleRemoveAssets} /> | ||||
|           {/if} | ||||
|         </ButtonContextMenu> | ||||
| @ -540,10 +543,10 @@ | ||||
|         <ControlAppBar onClose={handleCloseSelectAssets}> | ||||
|           {#snippet leading()} | ||||
|             <p class="text-lg dark:text-immich-dark-fg"> | ||||
|               {#if $timelineSelected.size === 0} | ||||
|               {#if !timelineInteraction.selectionActive} | ||||
|                 {$t('add_to_album')} | ||||
|               {:else} | ||||
|                 {$t('selected_count', { values: { count: $timelineSelected.size } })} | ||||
|                 {$t('selected_count', { values: { count: timelineInteraction.selectedAssets.size } })} | ||||
|               {/if} | ||||
|             </p> | ||||
|           {/snippet} | ||||
| @ -556,7 +559,7 @@ | ||||
|             > | ||||
|               {$t('select_from_computer')} | ||||
|             </button> | ||||
|             <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} onclick={handleAddAssets} | ||||
|             <Button size="sm" rounded="lg" disabled={!timelineInteraction.selectionActive} onclick={handleAddAssets} | ||||
|               >{$t('done')}</Button | ||||
|             > | ||||
|           {/snippet} | ||||
| @ -579,7 +582,7 @@ | ||||
|           <AssetGrid | ||||
|             enableRouting={false} | ||||
|             assetStore={timelineStore} | ||||
|             assetInteractionStore={timelineInteractionStore} | ||||
|             assetInteraction={timelineInteraction} | ||||
|             isSelectionMode={true} | ||||
|           /> | ||||
|         {:else} | ||||
| @ -587,7 +590,7 @@ | ||||
|             enableRouting={true} | ||||
|             {album} | ||||
|             {assetStore} | ||||
|             {assetInteractionStore} | ||||
|             {assetInteraction} | ||||
|             isShared={album.albumUsers.length > 0} | ||||
|             isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} | ||||
|             singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} | ||||
|  | ||||
| @ -12,12 +12,12 @@ | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { mdiPlus, mdiDotsVertical } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -26,26 +26,26 @@ | ||||
|   let { data }: Props = $props(); | ||||
| 
 | ||||
|   const assetStore = new AssetStore({ isArchived: true }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
| 
 | ||||
|   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   onDestroy(() => { | ||||
|     assetStore.destroy(); | ||||
|   }); | ||||
| </script> | ||||
| 
 | ||||
| {#if $isMultiSelectState} | ||||
|   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
| {#if assetInteraction.selectionActive} | ||||
|   <AssetSelectControlBar | ||||
|     assets={assetInteraction.selectedAssets} | ||||
|     clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|   > | ||||
|     <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
|     <CreateSharedLink /> | ||||
|     <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|     <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|     <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|       <AddToAlbum /> | ||||
|       <AddToAlbum shared /> | ||||
|     </ButtonContextMenu> | ||||
|     <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|     <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|     <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}> | ||||
|       <DownloadAction menuItem /> | ||||
|       <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
| @ -53,8 +53,8 @@ | ||||
|   </AssetSelectControlBar> | ||||
| {/if} | ||||
| 
 | ||||
| <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> | ||||
| <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteraction} removeAction={AssetAction.UNARCHIVE}> | ||||
|     {#snippet empty()} | ||||
|       <EmptyPlaceholder text={$t('no_archived_assets_message')} /> | ||||
|     {/snippet} | ||||
|  | ||||
| @ -14,7 +14,6 @@ | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { mdiDotsVertical, mdiPlus } from '@mdi/js'; | ||||
| @ -22,6 +21,7 @@ | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -30,10 +30,7 @@ | ||||
|   let { data }: Props = $props(); | ||||
| 
 | ||||
|   const assetStore = new AssetStore({ isFavorite: true }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
| 
 | ||||
|   let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   onDestroy(() => { | ||||
|     assetStore.destroy(); | ||||
| @ -41,11 +38,14 @@ | ||||
| </script> | ||||
| 
 | ||||
| <!-- Multiselection mode app bar --> | ||||
| {#if $isMultiSelectState} | ||||
|   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
| {#if assetInteraction.selectionActive} | ||||
|   <AssetSelectControlBar | ||||
|     assets={assetInteraction.selectedAssets} | ||||
|     clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|   > | ||||
|     <FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
|     <CreateSharedLink /> | ||||
|     <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|     <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|     <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|       <AddToAlbum /> | ||||
|       <AddToAlbum shared /> | ||||
| @ -54,7 +54,11 @@ | ||||
|       <DownloadAction menuItem /> | ||||
|       <ChangeDate menuItem /> | ||||
|       <ChangeLocation menuItem /> | ||||
|       <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
|       <ArchiveAction | ||||
|         menuItem | ||||
|         unarchive={assetInteraction.isAllArchived} | ||||
|         onArchive={(assetIds) => assetStore.removeAssets(assetIds)} | ||||
|       /> | ||||
|       {#if $preferences.tags.enabled} | ||||
|         <TagAction menuItem /> | ||||
|       {/if} | ||||
| @ -63,8 +67,8 @@ | ||||
|   </AssetSelectControlBar> | ||||
| {/if} | ||||
| 
 | ||||
| <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}> | ||||
| <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteraction} removeAction={AssetAction.UNFAVORITE}> | ||||
|     {#snippet empty()} | ||||
|       <EmptyPlaceholder text={$t('no_favorites_message')} /> | ||||
|     {/snippet} | ||||
|  | ||||
| @ -3,7 +3,6 @@ | ||||
|   import { page } from '$app/stores'; | ||||
|   import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; | ||||
|   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; | ||||
|   import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; | ||||
|   import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; | ||||
| @ -17,6 +16,7 @@ | ||||
|   import type { PageData } from './$types'; | ||||
|   import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; | ||||
|   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -31,7 +31,7 @@ | ||||
|   let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); | ||||
|   let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree)); | ||||
| 
 | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     await foldersStore.fetchUniquePaths(); | ||||
| @ -80,7 +80,7 @@ | ||||
|       <div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2"> | ||||
|         <GalleryViewer | ||||
|           assets={data.pathAssets} | ||||
|           {assetInteractionStore} | ||||
|           {assetInteraction} | ||||
|           {viewport} | ||||
|           disableAssetSelect={true} | ||||
|           showAssetName={true} | ||||
|  | ||||
| @ -8,12 +8,12 @@ | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { mdiPlus, mdiArrowLeft } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -22,18 +22,16 @@ | ||||
|   let { data }: Props = $props(); | ||||
| 
 | ||||
|   const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets, clearMultiselect } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   onDestroy(() => { | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     assetStore.destroy(); | ||||
|   }); | ||||
| </script> | ||||
| 
 | ||||
| <main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg"> | ||||
|   {#if $isMultiSelectState} | ||||
|     <AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}> | ||||
|   {#if assetInteraction.selectionActive} | ||||
|     <AssetSelectControlBar assets={assetInteraction.selectedAssets} clearSelect={assetInteraction.clearMultiselect}> | ||||
|       <CreateSharedLink /> | ||||
|       <ButtonContextMenu icon={mdiPlus} title={$t('add')}> | ||||
|         <AddToAlbum /> | ||||
| @ -50,5 +48,5 @@ | ||||
|       {/snippet} | ||||
|     </ControlAppBar> | ||||
|   {/if} | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} /> | ||||
|   <AssetGrid enableRouting={true} {assetStore} {assetInteraction} /> | ||||
| </main> | ||||
|  | ||||
| @ -27,7 +27,6 @@ | ||||
|     notificationController, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { websocketEvents } from '$lib/stores/websocket'; | ||||
| @ -58,8 +57,9 @@ | ||||
|   import { listNavigation } from '$lib/actions/list-navigation'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -78,8 +78,7 @@ | ||||
|     handlePromiseError(assetStore.updateOptions(assetStoreOptions)); | ||||
|   }); | ||||
| 
 | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets, isMultiSelectState } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); | ||||
|   let isEditingName = $state(false); | ||||
| @ -123,8 +122,8 @@ | ||||
|     if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) { | ||||
|       return; | ||||
|     } | ||||
|     if ($isMultiSelectState) { | ||||
|       assetInteractionStore.clearMultiselect(); | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       assetInteraction.clearMultiselect(); | ||||
|       return; | ||||
|     } else { | ||||
|       await goto(previousRoute); | ||||
| @ -149,8 +148,8 @@ | ||||
|   }); | ||||
| 
 | ||||
|   const handleUnmerge = () => { | ||||
|     $assetStore.removeAssets([...$selectedAssets].map((a) => a.id)); | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     $assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); | ||||
|     assetInteraction.clearMultiselect(); | ||||
|     viewMode = PersonPageViewMode.VIEW_ASSETS; | ||||
|   }; | ||||
| 
 | ||||
| @ -194,7 +193,7 @@ | ||||
|       handleError(error, $t('errors.unable_to_set_feature_photo')); | ||||
|     } | ||||
| 
 | ||||
|     assetInteractionStore.clearMultiselect(); | ||||
|     assetInteraction.clearMultiselect(); | ||||
| 
 | ||||
|     viewMode = PersonPageViewMode.VIEW_ASSETS; | ||||
|   }; | ||||
| @ -336,15 +335,11 @@ | ||||
|       handlePromiseError(updateAssetCount()); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); | ||||
|   let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); | ||||
| </script> | ||||
| 
 | ||||
| {#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} | ||||
|   <UnMergeFaceSelector | ||||
|     assetIds={[...$selectedAssets].map((a) => a.id)} | ||||
|     assetIds={assetInteraction.selectedAssetsArray.map((a) => a.id)} | ||||
|     personAssets={person} | ||||
|     onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} | ||||
|     onConfirm={handleUnmerge} | ||||
| @ -375,15 +370,18 @@ | ||||
| {/if} | ||||
| 
 | ||||
| <header> | ||||
|   {#if $isMultiSelectState} | ||||
|     <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
|   {#if assetInteraction.selectionActive} | ||||
|     <AssetSelectControlBar | ||||
|       assets={assetInteraction.selectedAssets} | ||||
|       clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|     > | ||||
|       <CreateSharedLink /> | ||||
|       <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|       <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|       <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|         <AddToAlbum /> | ||||
|         <AddToAlbum shared /> | ||||
|       </ButtonContextMenu> | ||||
|       <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|       <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|       <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}> | ||||
|         <DownloadAction menuItem filename="{person.name || 'immich'}.zip" /> | ||||
|         <MenuOption | ||||
| @ -393,8 +391,12 @@ | ||||
|         /> | ||||
|         <ChangeDate menuItem /> | ||||
|         <ChangeLocation menuItem /> | ||||
|         <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} /> | ||||
|         {#if $preferences.tags.enabled && isAllUserOwned} | ||||
|         <ArchiveAction | ||||
|           menuItem | ||||
|           unarchive={assetInteraction.isAllArchived} | ||||
|           onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} | ||||
|         /> | ||||
|         {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} | ||||
|           <TagAction menuItem /> | ||||
|         {/if} | ||||
|         <DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} /> | ||||
| @ -453,7 +455,7 @@ | ||||
|     <AssetGrid | ||||
|       enableRouting={true} | ||||
|       {assetStore} | ||||
|       {assetInteractionStore} | ||||
|       {assetInteraction} | ||||
|       isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON} | ||||
|       singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON} | ||||
|       onSelect={handleSelectFeaturePhoto} | ||||
|  | ||||
| @ -19,7 +19,7 @@ | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
| @ -32,33 +32,25 @@ | ||||
| 
 | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
|   const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   let isAllFavorite = $state(false); | ||||
|   let isAllOwned = $state(false); | ||||
|   let isAssetStackSelected = $state(false); | ||||
|   let isLinkActionAvailable = $state(false); | ||||
| 
 | ||||
|   $effect(() => { | ||||
|     const selection = [...$selectedAssets]; | ||||
|     isAllOwned = selection.every((asset) => asset.ownerId === $user.id); | ||||
|     isAllFavorite = selection.every((asset) => asset.isFavorite); | ||||
|     isAssetStackSelected = selection.length === 1 && !!selection[0].stack; | ||||
|     const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; | ||||
|   let selectedAssets = $derived(assetInteraction.selectedAssetsArray); | ||||
|   let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); | ||||
|   let isLinkActionAvailable = $derived.by(() => { | ||||
|     const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; | ||||
|     const isLivePhotoCandidate = | ||||
|       selection.length === 2 && | ||||
|       selection.some((asset) => asset.type === AssetTypeEnum.Image) && | ||||
|       selection.some((asset) => asset.type === AssetTypeEnum.Video); | ||||
|     isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); | ||||
|   }); | ||||
|       selectedAssets.length === 2 && | ||||
|       selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && | ||||
|       selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); | ||||
| 
 | ||||
|     return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); | ||||
|   }); | ||||
|   const handleEscape = () => { | ||||
|     if ($showAssetViewer) { | ||||
|       return; | ||||
|     } | ||||
|     if ($isMultiSelectState) { | ||||
|       assetInteractionStore.clearMultiselect(); | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       assetInteraction.clearMultiselect(); | ||||
|       return; | ||||
|     } | ||||
|   }; | ||||
| @ -78,22 +70,22 @@ | ||||
|   }); | ||||
| </script> | ||||
| 
 | ||||
| {#if $isMultiSelectState} | ||||
| {#if assetInteraction.selectionActive} | ||||
|   <AssetSelectControlBar | ||||
|     ownerId={$user.id} | ||||
|     assets={$selectedAssets} | ||||
|     clearSelect={() => assetInteractionStore.clearMultiselect()} | ||||
|     assets={assetInteraction.selectedAssets} | ||||
|     clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|   > | ||||
|     <CreateSharedLink /> | ||||
|     <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
|     <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|     <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|       <AddToAlbum /> | ||||
|       <AddToAlbum shared /> | ||||
|     </ButtonContextMenu> | ||||
|     <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|     <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> | ||||
|     <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> | ||||
|       <DownloadAction menuItem /> | ||||
|       {#if $selectedAssets.size > 1 || isAssetStackSelected} | ||||
|       {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} | ||||
|         <StackAction | ||||
|           unstack={isAssetStackSelected} | ||||
|           onStack={(assetIds) => assetStore.removeAssets(assetIds)} | ||||
| @ -103,7 +95,7 @@ | ||||
|       {#if isLinkActionAvailable} | ||||
|         <LinkLivePhotoAction | ||||
|           menuItem | ||||
|           unlink={[...$selectedAssets].length === 1} | ||||
|           unlink={assetInteraction.selectedAssets.size === 1} | ||||
|           onLink={handleLink} | ||||
|           onUnlink={handleUnlink} | ||||
|         /> | ||||
| @ -121,11 +113,11 @@ | ||||
|   </AssetSelectControlBar> | ||||
| {/if} | ||||
| 
 | ||||
| <UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}> | ||||
| <UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}> | ||||
|   <AssetGrid | ||||
|     enableRouting={true} | ||||
|     {assetStore} | ||||
|     {assetInteractionStore} | ||||
|     {assetInteraction} | ||||
|     removeAction={AssetAction.ARCHIVE} | ||||
|     onEscape={handleEscape} | ||||
|     withStacked | ||||
|  | ||||
| @ -16,7 +16,6 @@ | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; | ||||
|   import { AppRoute, QueryParameter } from '$lib/constants'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
| @ -44,8 +43,9 @@ | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onMount, tick } from 'svelte'; | ||||
|   import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   const MAX_ASSET_COUNT = 5000; | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
| @ -63,14 +63,9 @@ | ||||
|   let scrollY = $state(0); | ||||
|   let scrollYHistory = 0; | ||||
| 
 | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; | ||||
| 
 | ||||
|   let isMultiSelectionMode = $derived($selectedAssets.size > 0); | ||||
|   let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived)); | ||||
|   let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite)); | ||||
|   let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY)); | ||||
| 
 | ||||
|   onMount(() => { | ||||
| @ -86,8 +81,8 @@ | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (isMultiSelectionMode) { | ||||
|       $selectedAssets = new Set(); | ||||
|     if (assetInteraction.selectionActive) { | ||||
|       assetInteraction.selectedAssets.clear(); | ||||
|       return; | ||||
|     } | ||||
|     if (!$preventRaceConditionSearchBar) { | ||||
| @ -131,7 +126,7 @@ | ||||
|     searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); | ||||
|   }; | ||||
|   const handleSelectAll = () => { | ||||
|     assetInteractionStore.selectAssets(searchResultAssets); | ||||
|     assetInteraction.selectAssets(searchResultAssets); | ||||
|   }; | ||||
| 
 | ||||
|   async function onSearchQueryUpdate() { | ||||
| @ -231,29 +226,31 @@ | ||||
|   function getObjectKeys<T extends object>(obj: T): (keyof T)[] { | ||||
|     return Object.keys(obj) as (keyof T)[]; | ||||
|   } | ||||
|   let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id)); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY /> | ||||
| 
 | ||||
| <section> | ||||
|   {#if isMultiSelectionMode} | ||||
|   {#if assetInteraction.selectionActive} | ||||
|     <div class="fixed z-[100] top-0 left-0 w-full"> | ||||
|       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => cancelMultiselect(assetInteractionStore)}> | ||||
|       <AssetSelectControlBar | ||||
|         assets={assetInteraction.selectedAssets} | ||||
|         clearSelect={() => cancelMultiselect(assetInteraction)} | ||||
|       > | ||||
|         <CreateSharedLink /> | ||||
|         <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} /> | ||||
|         <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> | ||||
|           <AddToAlbum {onAddToAlbum} /> | ||||
|           <AddToAlbum shared {onAddToAlbum} /> | ||||
|         </ButtonContextMenu> | ||||
|         <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} /> | ||||
|         <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} /> | ||||
| 
 | ||||
|         <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}> | ||||
|           <DownloadAction menuItem /> | ||||
|           <ChangeDate menuItem /> | ||||
|           <ChangeLocation menuItem /> | ||||
|           <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} /> | ||||
|           {#if $preferences.tags.enabled && isAllUserOwned} | ||||
|           <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} /> | ||||
|           {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} | ||||
|             <TagAction menuItem /> | ||||
|           {/if} | ||||
|           <DeleteAssets menuItem {onAssetDelete} /> | ||||
| @ -333,7 +330,7 @@ | ||||
|       {#if searchResultAssets.length > 0} | ||||
|         <GalleryViewer | ||||
|           assets={searchResultAssets} | ||||
|           {assetInteractionStore} | ||||
|           {assetInteraction} | ||||
|           onIntersected={loadNextPage} | ||||
|           showArchiveIcon={true} | ||||
|           {viewport} | ||||
|  | ||||
| @ -16,7 +16,6 @@ | ||||
|   import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; | ||||
|   import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; | ||||
|   import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; | ||||
|   import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; | ||||
| @ -26,6 +25,7 @@ | ||||
|   import { dialogController } from '$lib/components/shared-components/dialog/dialog'; | ||||
|   import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; | ||||
|   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -36,7 +36,7 @@ | ||||
|   let pathSegments = $derived(data.path ? data.path.split('/') : []); | ||||
|   let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); | ||||
| 
 | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   const buildMap = (tags: TagResponseDto[]) => { | ||||
|     return Object.fromEntries(tags.map((tag) => [tag.value, tag])); | ||||
| @ -198,7 +198,7 @@ | ||||
| 
 | ||||
|   <section class="mt-2 h-full"> | ||||
|     {#if tag} | ||||
|       <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> | ||||
|       <AssetGrid enableRouting={true} {assetStore} {assetInteraction} removeAction={AssetAction.UNARCHIVE}> | ||||
|         {#snippet empty()} | ||||
|           <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} /> | ||||
|         {/snippet} | ||||
|  | ||||
| @ -15,7 +15,6 @@ | ||||
|     notificationController, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
| @ -26,6 +25,7 @@ | ||||
|   import { dialogController } from '$lib/components/shared-components/dialog/dialog'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -39,8 +39,7 @@ | ||||
| 
 | ||||
|   const options = { isTrashed: true }; | ||||
|   const assetStore = new AssetStore(options); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
| 
 | ||||
|   const handleEmptyTrash = async () => { | ||||
|     const isConfirmed = await dialogController.show({ | ||||
| @ -93,25 +92,28 @@ | ||||
|   }); | ||||
| </script> | ||||
| 
 | ||||
| {#if $isMultiSelectState} | ||||
|   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||
|     <SelectAllAssets {assetStore} {assetInteractionStore} /> | ||||
| {#if assetInteraction.selectionActive} | ||||
|   <AssetSelectControlBar | ||||
|     assets={assetInteraction.selectedAssets} | ||||
|     clearSelect={() => assetInteraction.clearMultiselect()} | ||||
|   > | ||||
|     <SelectAllAssets {assetStore} {assetInteraction} /> | ||||
|     <DeleteAssets force onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
|     <RestoreAssets onRestore={(assetIds) => assetStore.removeAssets(assetIds)} /> | ||||
|   </AssetSelectControlBar> | ||||
| {/if} | ||||
| 
 | ||||
| {#if $featureFlags.loaded && $featureFlags.trash} | ||||
|   <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> | ||||
|   <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> | ||||
|     {#snippet buttons()} | ||||
|       <div class="flex place-items-center gap-2"> | ||||
|         <LinkButton onclick={handleRestoreTrash} disabled={$isMultiSelectState}> | ||||
|         <LinkButton onclick={handleRestoreTrash} disabled={assetInteraction.selectionActive}> | ||||
|           <div class="flex place-items-center gap-2 text-sm"> | ||||
|             <Icon path={mdiHistory} size="18" /> | ||||
|             {$t('restore_all')} | ||||
|           </div> | ||||
|         </LinkButton> | ||||
|         <LinkButton onclick={() => handleEmptyTrash()} disabled={$isMultiSelectState}> | ||||
|         <LinkButton onclick={() => handleEmptyTrash()} disabled={assetInteraction.selectionActive}> | ||||
|           <div class="flex place-items-center gap-2 text-sm"> | ||||
|             <Icon path={mdiDeleteForeverOutline} size="18" /> | ||||
|             {$t('empty_trash')} | ||||
| @ -120,7 +122,7 @@ | ||||
|       </div> | ||||
|     {/snippet} | ||||
| 
 | ||||
|     <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}> | ||||
|     <AssetGrid enableRouting={true} {assetStore} {assetInteraction}> | ||||
|       <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4"> | ||||
|         {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })} | ||||
|       </p> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user