mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 02:27:08 -04:00 
			
		
		
		
	feat(web): lighter timeline buckets
This commit is contained in:
		
							parent
							
								
									242a559e0f
								
							
						
					
					
						commit
						5a8f9f3b5c
					
				
							
								
								
									
										23
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -8,7 +8,11 @@ | ||||
|   "[typescript]": { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode", | ||||
|     "editor.tabSize": 2, | ||||
|     "editor.formatOnSave": true | ||||
|     "editor.formatOnSave": true, | ||||
|     "editor.codeActionsOnSave": { | ||||
|       "source.removeUnusedImports": "explicit", | ||||
|       "source.organizeImports": "explicit" | ||||
|     } | ||||
|   }, | ||||
|   "[css]": { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode", | ||||
| @ -17,13 +21,14 @@ | ||||
|   }, | ||||
|   "[svelte]": { | ||||
|     "editor.defaultFormatter": "svelte.svelte-vscode", | ||||
|     "editor.tabSize": 2 | ||||
|     "editor.tabSize": 2, | ||||
|     "editor.codeActionsOnSave": { | ||||
|       "source.removeUnusedImports": "explicit", | ||||
|       "source.organizeImports": "explicit" | ||||
|     } | ||||
|   }, | ||||
|   "svelte.enable-ts-plugin": true, | ||||
|   "eslint.validate": [ | ||||
|     "javascript", | ||||
|     "svelte" | ||||
|   ], | ||||
|   "eslint.validate": ["javascript", "svelte"], | ||||
|   "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|   "[dart]": { | ||||
|     "editor.formatOnSave": true, | ||||
| @ -34,12 +39,10 @@ | ||||
|     "editor.wordBasedSuggestions": "off", | ||||
|     "editor.defaultFormatter": "Dart-Code.dart-code" | ||||
|   }, | ||||
|   "cSpell.words": [ | ||||
|     "immich" | ||||
|   ], | ||||
|   "cSpell.words": ["immich"], | ||||
|   "explorer.fileNesting.enabled": true, | ||||
|   "explorer.fileNesting.patterns": { | ||||
|     "*.ts": "${capture}.spec.ts,${capture}.mock.ts", | ||||
|     "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" | ||||
|   } | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -1,25 +1,25 @@ | ||||
| <script lang="ts"> | ||||
|   import { shortcut } from '$lib/actions/shortcut'; | ||||
|   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; | ||||
|   import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; | ||||
|   import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; | ||||
|   import DownloadAction from '../photos-page/actions/download-action.svelte'; | ||||
|   import AssetGrid from '../photos-page/asset-grid.svelte'; | ||||
|   import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|   import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; | ||||
|   import ThemeButton from '../shared-components/theme-button.svelte'; | ||||
|   import { shortcut } from '$lib/actions/shortcut'; | ||||
|   import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   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; | ||||
| @ -36,7 +36,7 @@ | ||||
|   $effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order })); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   dragAndDropFilesStore.subscribe((value) => { | ||||
|     if (value.isDragging && value.files.length > 0) { | ||||
|  | ||||
| @ -1,18 +1,19 @@ | ||||
| import type { AssetAction } from '$lib/constants'; | ||||
| import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; | ||||
| import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import type { AlbumResponseDto } from '@immich/sdk'; | ||||
| 
 | ||||
| type ActionMap = { | ||||
|   [AssetAction.ARCHIVE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.UNARCHIVE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.FAVORITE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.UNFAVORITE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.TRASH]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.DELETE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.RESTORE]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.ADD]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; | ||||
|   [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; | ||||
|   [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; | ||||
|   [AssetAction.ARCHIVE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.UNARCHIVE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.FAVORITE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.UNFAVORITE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.TRASH]: { asset: TimelineAsset }; | ||||
|   [AssetAction.DELETE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.RESTORE]: { asset: TimelineAsset }; | ||||
|   [AssetAction.ADD]: { asset: TimelineAsset }; | ||||
|   [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto }; | ||||
|   [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; | ||||
|   [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset }; | ||||
| }; | ||||
| 
 | ||||
| export type Action = { | ||||
|  | ||||
| @ -6,6 +6,7 @@ | ||||
|   import Portal from '$lib/components/shared-components/portal/portal.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -24,14 +25,14 @@ | ||||
|     showSelectionModal = false; | ||||
|     const album = await addAssetsToNewAlbum(albumName, [asset.id]); | ||||
|     if (album) { | ||||
|       onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); | ||||
|       onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleAddToAlbum = async (album: AlbumResponseDto) => { | ||||
|     showSelectionModal = false; | ||||
|     await addAssetsToAlbum(album.id, [asset.id]); | ||||
|     onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); | ||||
|     onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album }); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
|  | ||||
| @ -4,6 +4,7 @@ | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { toggleArchive } from '$lib/utils/asset-utils'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import type { AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -18,11 +19,11 @@ | ||||
| 
 | ||||
|   const onArchive = async () => { | ||||
|     if (!asset.isArchived) { | ||||
|       preAction({ type: AssetAction.ARCHIVE, asset }); | ||||
|       preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) }); | ||||
|     } | ||||
|     const updatedAsset = await toggleArchive(asset); | ||||
|     if (updatedAsset) { | ||||
|       onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset }); | ||||
|       onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) }); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||
|   import { featureFlags } from '$lib/stores/server-config.store'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -42,9 +43,9 @@ | ||||
| 
 | ||||
|   const trashAsset = async () => { | ||||
|     try { | ||||
|       preAction({ type: AssetAction.TRASH, asset }); | ||||
|       preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) }); | ||||
|       await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); | ||||
|       onAction({ type: AssetAction.TRASH, asset }); | ||||
|       onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) }); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         message: $t('moved_to_trash'), | ||||
| @ -58,7 +59,7 @@ | ||||
|   const deleteAsset = async () => { | ||||
|     try { | ||||
|       await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } }); | ||||
|       onAction({ type: AssetAction.DELETE, asset }); | ||||
|       onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) }); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         message: $t('permanently_deleted_asset'), | ||||
|  | ||||
| @ -7,6 +7,7 @@ | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { updateAsset, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiHeart, mdiHeartOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -30,7 +31,10 @@ | ||||
| 
 | ||||
|       asset = { ...asset, isFavorite: data.isFavorite }; | ||||
| 
 | ||||
|       onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); | ||||
|       onAction({ | ||||
|         type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, | ||||
|         asset: toTimelineAsset(asset), | ||||
|       }); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|  | ||||
| @ -1,12 +1,13 @@ | ||||
| <script lang="ts"> | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { dialogController } from '$lib/components/shared-components/dialog/dialog'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { keepThisDeleteOthers } from '$lib/utils/asset-utils'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import type { AssetResponseDto, StackResponseDto } from '@immich/sdk'; | ||||
|   import { mdiPinOutline } from '@mdi/js'; | ||||
|   import type { OnAction } from './action'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { dialogController } from '$lib/components/shared-components/dialog/dialog'; | ||||
|   import type { OnAction } from './action'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     stack: StackResponseDto; | ||||
| @ -29,7 +30,7 @@ | ||||
| 
 | ||||
|     const keptAsset = await keepThisDeleteOthers(asset, stack); | ||||
|     if (keptAsset) { | ||||
|       onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] }); | ||||
|       onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] }); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| @ -6,6 +6,7 @@ | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { restoreAssets, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiHistory } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -23,7 +24,7 @@ | ||||
|       await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); | ||||
|       asset.isTrashed = false; | ||||
| 
 | ||||
|       onAction({ type: AssetAction.RESTORE, asset }); | ||||
|       onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) }); | ||||
| 
 | ||||
|       notificationController.show({ | ||||
|         type: NotificationType.Info, | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { deleteStack } from '$lib/utils/asset-utils'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import type { StackResponseDto } from '@immich/sdk'; | ||||
|   import { mdiImageMinusOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -17,7 +18,7 @@ | ||||
|   const handleUnstack = async () => { | ||||
|     const unstackedAssets = await deleteStack([stack.id]); | ||||
|     if (unstackedAssets) { | ||||
|       onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets }); | ||||
|       onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((a) => toTimelineAsset(a)) }); | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| @ -13,8 +13,9 @@ describe('AssetViewerNavBar component', () => { | ||||
|     showDownloadButton: false, | ||||
|     showMotionPlayButton: false, | ||||
|     showShareButton: false, | ||||
|     preAction: () => {}, | ||||
|     onZoomImage: () => {}, | ||||
|     onCopyImage: () => {}, | ||||
|     onCopyImage: async () => {}, | ||||
|     onAction: () => {}, | ||||
|     onRunJob: () => {}, | ||||
|     onPlaySlideshow: () => {}, | ||||
|  | ||||
| @ -15,6 +15,7 @@ | ||||
|   import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { SlideshowHistory } from '$lib/utils/slideshow-history'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { | ||||
|     AssetJobName, | ||||
|     AssetTypeEnum, | ||||
| @ -52,7 +53,7 @@ | ||||
| 
 | ||||
|   interface Props { | ||||
|     asset: AssetResponseDto; | ||||
|     preloadAssets?: AssetResponseDto[]; | ||||
|     preloadAssets?: { id: string }[]; | ||||
|     showNavigation?: boolean; | ||||
|     withStacked?: boolean; | ||||
|     isShared?: boolean; | ||||
| @ -62,7 +63,7 @@ | ||||
|     onAction?: OnAction | undefined; | ||||
|     reactions?: ActivityResponseDto[]; | ||||
|     showCloseButton?: boolean; | ||||
|     onClose: (dto: { asset: AssetResponseDto }) => void; | ||||
|     onClose: (asset: AssetResponseDto) => void; | ||||
|     onNext: () => Promise<HasAsset>; | ||||
|     onPrevious: () => Promise<HasAsset>; | ||||
|     onRandom: () => Promise<AssetResponseDto | undefined>; | ||||
| @ -267,7 +268,7 @@ | ||||
|   }; | ||||
| 
 | ||||
|   const closeViewer = () => { | ||||
|     onClose({ asset }); | ||||
|     onClose(asset); | ||||
|   }; | ||||
| 
 | ||||
|   const closeEditor = () => { | ||||
| @ -605,8 +606,8 @@ | ||||
|               imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} | ||||
|               brokenAssetClass="text-xs" | ||||
|               dimmed={stackedAsset.id !== asset.id} | ||||
|               asset={stackedAsset} | ||||
|               onClick={(stackedAsset) => { | ||||
|               asset={toTimelineAsset(stackedAsset)} | ||||
|               onClick={() => { | ||||
|                 asset = stackedAsset; | ||||
|               }} | ||||
|               onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|   import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; | ||||
|   import { getBoundingBox } from '$lib/utils/people-utils'; | ||||
|   import { getAltText } from '$lib/utils/thumbnail-util'; | ||||
|   import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; | ||||
|   import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; | ||||
|   import { onDestroy, onMount } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { type SwipeCustomEvent, swipe } from 'svelte-gestures'; | ||||
| @ -24,7 +24,7 @@ | ||||
| 
 | ||||
|   interface Props { | ||||
|     asset: AssetResponseDto; | ||||
|     preloadAssets?: AssetResponseDto[] | undefined; | ||||
|     preloadAssets?: { id: string }[] | undefined; | ||||
|     element?: HTMLDivElement | undefined; | ||||
|     haveFadeTransition?: boolean; | ||||
|     sharedLink?: SharedLinkResponseDto | undefined; | ||||
| @ -68,12 +68,10 @@ | ||||
|     $boundingBoxesArray = []; | ||||
|   }); | ||||
| 
 | ||||
|   const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { | ||||
|   const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: { id: string }[]) => { | ||||
|     for (const preloadAsset of preloadAssets || []) { | ||||
|       if (preloadAsset.type === AssetTypeEnum.Image) { | ||||
|         let img = new Image(); | ||||
|         img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); | ||||
|       } | ||||
|       let img = new Image(); | ||||
|       img.src = getAssetUrl(preloadAsset.id, targetSize, null); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|  | ||||
| @ -4,8 +4,8 @@ | ||||
|   import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; | ||||
|   import { getAssetPlaybackUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; | ||||
|   import { timeToSeconds } from '$lib/utils/date-time'; | ||||
|   import { getAltText } from '$lib/utils/thumbnail-util'; | ||||
|   import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; | ||||
|   // import { getAltText } from '$lib/utils/thumbnail-util'; | ||||
|   import { AssetMediaSize } from '@immich/sdk'; | ||||
|   import { | ||||
|     mdiArchiveArrowDownOutline, | ||||
|     mdiCameraBurst, | ||||
| @ -17,22 +17,23 @@ | ||||
|   } from '@mdi/js'; | ||||
| 
 | ||||
|   import { thumbhash } from '$lib/actions/thumbhash'; | ||||
|   import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { mobileDevice } from '$lib/stores/mobile-device.svelte'; | ||||
|   import { getFocusable } from '$lib/utils/focus-util'; | ||||
|   import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; | ||||
|   import { TUNABLES } from '$lib/utils/tunables'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import type { ClassValue } from 'svelte/elements'; | ||||
|   import { fade } from 'svelte/transition'; | ||||
|   import ImageThumbnail from './image-thumbnail.svelte'; | ||||
|   import VideoThumbnail from './video-thumbnail.svelte'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { getFocusable } from '$lib/utils/focus-util'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     asset: AssetResponseDto; | ||||
|     asset: TimelineAsset; | ||||
|     groupIndex?: number; | ||||
|     thumbnailSize?: number | undefined; | ||||
|     thumbnailWidth?: number | undefined; | ||||
|     thumbnailHeight?: number | undefined; | ||||
|     thumbnailSize?: number; | ||||
|     thumbnailWidth?: number; | ||||
|     thumbnailHeight?: number; | ||||
|     selected?: boolean; | ||||
|     focussed?: boolean; | ||||
|     selectionCandidate?: boolean; | ||||
| @ -44,10 +45,10 @@ | ||||
|     imageClass?: ClassValue; | ||||
|     brokenAssetClass?: ClassValue; | ||||
|     dimmed?: boolean; | ||||
|     onClick?: ((asset: AssetResponseDto) => void) | undefined; | ||||
|     onSelect?: ((asset: AssetResponseDto) => void) | undefined; | ||||
|     onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; | ||||
|     handleFocus?: (() => void) | undefined; | ||||
|     onClick?: (asset: TimelineAsset) => void; | ||||
|     onSelect?: (asset: TimelineAsset) => void; | ||||
|     onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; | ||||
|     handleFocus?: () => void; | ||||
|   } | ||||
| 
 | ||||
|   let { | ||||
| @ -331,7 +332,7 @@ | ||||
|           </div> | ||||
|         {/if} | ||||
| 
 | ||||
|         {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} | ||||
|         {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR} | ||||
|           <div class="absolute right-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white"> | ||||
|             <span class="pr-2 pt-2"> | ||||
|               <Icon path={mdiRotate360} size="24" /> | ||||
| @ -344,7 +345,7 @@ | ||||
|           <div | ||||
|             class={[ | ||||
|               'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white', | ||||
|               asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 right-0' : 'top-7 right-1', | ||||
|               asset.isImage && !asset.livePhotoVideoId ? 'top-0 right-0' : 'top-7 right-1', | ||||
|             ]} | ||||
|           > | ||||
|             <span class="pr-2 pt-2 flex place-items-center gap-1"> | ||||
| @ -354,27 +355,28 @@ | ||||
|           </div> | ||||
|         {/if} | ||||
|       </div> | ||||
|       <!-- altText={$getAltText(asset)} --> | ||||
|       <ImageThumbnail | ||||
|         class={imageClass} | ||||
|         {brokenAssetClass} | ||||
|         url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} | ||||
|         altText={$getAltText(asset)} | ||||
|         altText="todo" | ||||
|         widthStyle="{width}px" | ||||
|         heightStyle="{height}px" | ||||
|         curve={selected} | ||||
|         onComplete={(errored) => ((loaded = true), (thumbError = errored))} | ||||
|       /> | ||||
|       {#if asset.type === AssetTypeEnum.Video} | ||||
|       {#if asset.isVideo} | ||||
|         <div class="absolute top-0 h-full w-full"> | ||||
|           <VideoThumbnail | ||||
|             url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} | ||||
|             enablePlayback={mouseOver && $playVideoThumbnailOnHover} | ||||
|             curve={selected} | ||||
|             durationInSeconds={timeToSeconds(asset.duration)} | ||||
|             durationInSeconds={timeToSeconds(asset.duration!)} | ||||
|             playbackOnIconHover={!$playVideoThumbnailOnHover} | ||||
|           /> | ||||
|         </div> | ||||
|       {:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} | ||||
|       {:else if asset.isImage && asset.livePhotoVideoId} | ||||
|         <div class="absolute top-0 h-full w-full"> | ||||
|           <VideoThumbnail | ||||
|             url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} | ||||
|  | ||||
| @ -73,7 +73,7 @@ | ||||
|   const viewport: Viewport = $state({ width: 0, height: 0 }); | ||||
|   // need to include padding in the viewport for gallery | ||||
|   const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 }); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<AssetResponseDto>(); | ||||
|   let progressBarController: Tween<number> | undefined = $state(undefined); | ||||
|   let videoPlayer: HTMLVideoElement | undefined = $state(); | ||||
|   const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; | ||||
|  | ||||
| @ -1,11 +1,11 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import type { OnArchive } from '$lib/utils/actions'; | ||||
|   import { archiveAssets } from '$lib/utils/asset-utils'; | ||||
|   import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|   import { archiveAssets } from '$lib/utils/asset-utils'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     onArchive?: OnArchive; | ||||
|  | ||||
| @ -6,9 +6,10 @@ | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|   import { isTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { AssetJobName, AssetTypeEnum, runAssetJobs, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     jobs?: AssetJobName[]; | ||||
| @ -19,7 +20,11 @@ | ||||
| 
 | ||||
|   const { clearSelect, getOwnedAssets } = getAssetControlContext(); | ||||
| 
 | ||||
|   let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video)); | ||||
|   let isAllVideos = $derived( | ||||
|     [...getOwnedAssets()].every((asset) => | ||||
|       isTimelineAsset(asset) ? asset.isVideo : (asset as AssetResponseDto).type === AssetTypeEnum.Video, | ||||
|     ), | ||||
|   ); | ||||
| 
 | ||||
|   const handleRunJob = async (name: AssetJobName) => { | ||||
|     try { | ||||
|  | ||||
| @ -4,11 +4,11 @@ | ||||
|   import { getSelectedAssets } from '$lib/utils/asset-utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { updateAssets } from '@immich/sdk'; | ||||
|   import { mdiCalendarEditOutline } from '@mdi/js'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|   import { mdiCalendarEditOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   interface Props { | ||||
|     menuItem?: boolean; | ||||
|   } | ||||
|  | ||||
| @ -1,11 +1,14 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   import { shortcut } from '$lib/actions/shortcut'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import { getKey } from '$lib/utils'; | ||||
|   import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; | ||||
|   import { isTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     filename?: string; | ||||
| @ -20,7 +23,11 @@ | ||||
|     const assets = [...getAssets()]; | ||||
|     if (assets.length === 1) { | ||||
|       clearSelect(); | ||||
|       await downloadFile(assets[0]); | ||||
|       let asset: AssetResponseDto = assets[0] as AssetResponseDto; | ||||
|       if (isTimelineAsset(assets[0])) { | ||||
|         asset = await getAssetInfo({ id: assets[0].id, key: getKey() }); | ||||
|       } | ||||
|       await downloadFile(asset); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -1,12 +1,14 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import type { OnLink, OnUnlink } from '$lib/utils/actions'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { getAssetInfo, updateAsset } from '@immich/sdk'; | ||||
|   import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '../asset-select-control-bar.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     onLink: OnLink; | ||||
| @ -28,14 +30,14 @@ | ||||
| 
 | ||||
|   const handleLink = async () => { | ||||
|     let [still, motion] = [...getOwnedAssets()]; | ||||
|     if (still.type === AssetTypeEnum.Video) { | ||||
|     if ((still as TimelineAsset).isVideo) { | ||||
|       [still, motion] = [motion, still]; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       loading = true; | ||||
|       const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } }); | ||||
|       onLink({ still: stillResponse, motion }); | ||||
|       onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset }); | ||||
|       clearSelect(); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_link_motion_video')); | ||||
| @ -46,22 +48,22 @@ | ||||
| 
 | ||||
|   const handleUnlink = async () => { | ||||
|     const [still] = [...getOwnedAssets()]; | ||||
| 
 | ||||
|     const motionId = still?.livePhotoVideoId; | ||||
|     if (!motionId) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     try { | ||||
|       loading = true; | ||||
|       const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } }); | ||||
|       const motionResponse = await getAssetInfo({ id: motionId }); | ||||
|       onUnlink({ still: stillResponse, motion: motionResponse }); | ||||
|       clearSelect(); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_unlink_motion_video')); | ||||
|     } finally { | ||||
|       loading = false; | ||||
|     if (still) { | ||||
|       const motionId = (still as TimelineAsset).livePhotoVideoId; | ||||
|       if (!motionId) { | ||||
|         return; | ||||
|       } | ||||
|       try { | ||||
|         loading = true; | ||||
|         const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } }); | ||||
|         const motionResponse = await getAssetInfo({ id: motionId }); | ||||
|         onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) }); | ||||
|         clearSelect(); | ||||
|       } catch (error) { | ||||
|         handleError(error, $t('errors.unable_to_unlink_motion_video')); | ||||
|       } finally { | ||||
|         loading = false; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| @ -1,14 +1,14 @@ | ||||
| <script lang="ts"> | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; | ||||
|   import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils'; | ||||
|   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; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     assetInteraction: AssetInteraction<BaseInteractionAsset>; | ||||
|   } | ||||
| 
 | ||||
|   let { assetStore, assetInteraction }: Props = $props(); | ||||
|  | ||||
| @ -1,9 +1,10 @@ | ||||
| <script lang="ts"> | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; | ||||
|   import { stackAssets, deleteStack } from '$lib/utils/asset-utils'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import type { OnStack, OnUnstack } from '$lib/utils/actions'; | ||||
|   import { deleteStack, stackAssets } from '$lib/utils/asset-utils'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| 
 | ||||
|   interface Props { | ||||
| @ -34,7 +35,7 @@ | ||||
|     } | ||||
|     const unstackedAssets = await deleteStack([stack.id]); | ||||
|     if (unstackedAssets) { | ||||
|       onUnstack?.(unstackedAssets); | ||||
|       onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a))); | ||||
|     } | ||||
|     clearSelect(); | ||||
|   }; | ||||
|  | ||||
| @ -1,20 +1,20 @@ | ||||
| <script lang="ts"> | ||||
|   import Icon from '$lib/components/elements/icon.svelte'; | ||||
|   import { | ||||
|     type AssetStore, | ||||
|     type AssetBucket, | ||||
|     assetSnapshot, | ||||
|     assetsSnapshot, | ||||
|     type AssetStore, | ||||
|     isSelectingAllAssets, | ||||
|     type TimelineAsset, | ||||
|   } from '$lib/stores/assets-store.svelte'; | ||||
|   import { navigate } from '$lib/utils/navigation'; | ||||
|   import { getDateLocaleString } from '$lib/utils/timeline-util'; | ||||
|   import type { AssetResponseDto } from '@immich/sdk'; | ||||
| 
 | ||||
|   import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; | ||||
|   import { fly } from 'svelte/transition'; | ||||
|   import { fly, scale } from 'svelte/transition'; | ||||
|   import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { scale } from 'svelte/transition'; | ||||
| 
 | ||||
|   import { flip } from 'svelte/animate'; | ||||
| 
 | ||||
| @ -29,11 +29,11 @@ | ||||
|     showArchiveIcon: boolean; | ||||
|     bucket: AssetBucket; | ||||
|     assetStore: AssetStore; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     assetInteraction: AssetInteraction<BaseInteractionAsset>; | ||||
| 
 | ||||
|     onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; | ||||
|     onSelectAssets: (asset: AssetResponseDto) => void; | ||||
|     onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; | ||||
|     onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void; | ||||
|     onSelectAssets: (asset: TimelineAsset) => void; | ||||
|     onSelectAssetCandidates: (asset: TimelineAsset | null) => void; | ||||
|   } | ||||
| 
 | ||||
|   let { | ||||
| @ -54,7 +54,7 @@ | ||||
| 
 | ||||
|   const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150)); | ||||
|   const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); | ||||
|   const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { | ||||
|   const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => { | ||||
|     if (isSelectionMode || assetInteraction.selectionActive) { | ||||
|       assetSelectHandler(assetStore, asset, assets, groupTitle); | ||||
|       return; | ||||
| @ -62,12 +62,12 @@ | ||||
|     void navigate({ targetRoute: 'current', assetId: asset.id }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); | ||||
|   const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets }); | ||||
| 
 | ||||
|   const assetSelectHandler = ( | ||||
|     assetStore: AssetStore, | ||||
|     asset: AssetResponseDto, | ||||
|     assetsInDateGroup: AssetResponseDto[], | ||||
|     asset: TimelineAsset, | ||||
|     assetsInDateGroup: TimelineAsset[], | ||||
|     groupTitle: string, | ||||
|   ) => { | ||||
|     onSelectAssets(asset); | ||||
| @ -91,7 +91,7 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { | ||||
|   const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => { | ||||
|     // Show multi select icon on hover on date group | ||||
|     hoveredDateGroup = groupTitle; | ||||
| 
 | ||||
| @ -100,7 +100,7 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const assetOnFocusHandler = (asset: AssetResponseDto) => { | ||||
|   const assetOnFocusHandler = (asset: TimelineAsset) => { | ||||
|     assetInteraction.focussedAssetId = asset.id; | ||||
|   }; | ||||
|   function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) { | ||||
|  | ||||
| @ -1,10 +1,21 @@ | ||||
| <script lang="ts"> | ||||
|   import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; | ||||
|   import { page } from '$app/stores'; | ||||
|   import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; | ||||
|   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; | ||||
|   import type { Action } from '$lib/components/asset-viewer/actions/action'; | ||||
|   import Skeleton from '$lib/components/photos-page/skeleton.svelte'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; | ||||
|   import { | ||||
|     AssetBucket, | ||||
|     assetsSnapshot, | ||||
|     AssetStore, | ||||
|     isSelectingAllAssets, | ||||
|     type TimelineAsset, | ||||
|   } from '$lib/stores/assets-store.svelte'; | ||||
|   import { mobileDevice } from '$lib/stores/mobile-device.svelte'; | ||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||
|   import { searchStore } from '$lib/stores/search.svelte'; | ||||
|   import { featureFlags } from '$lib/stores/server-config.store'; | ||||
| @ -13,19 +24,14 @@ | ||||
|   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; | ||||
|   import { navigate } from '$lib/utils/navigation'; | ||||
|   import { type ScrubberListener } from '$lib/utils/timeline-util'; | ||||
|   import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; | ||||
|   import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; | ||||
|   import { onMount, type Snippet } from 'svelte'; | ||||
|   import type { UpdatePayload } from 'vite'; | ||||
|   import Portal from '../shared-components/portal/portal.svelte'; | ||||
|   import Scrubber from '../shared-components/scrubber/scrubber.svelte'; | ||||
|   import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; | ||||
|   import AssetDateGroup from './asset-date-group.svelte'; | ||||
|   import DeleteAssetDialog from './delete-asset-dialog.svelte'; | ||||
|   import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; | ||||
|   import Skeleton from '$lib/components/photos-page/skeleton.svelte'; | ||||
|   import { page } from '$app/stores'; | ||||
|   import type { UpdatePayload } from 'vite'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { mobileDevice } from '$lib/stores/mobile-device.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     isSelectionMode?: boolean; | ||||
| @ -35,7 +41,7 @@ | ||||
|      additionally, update the page location/url with the asset as the asset-grid is scrolled */ | ||||
|     enableRouting: boolean; | ||||
|     assetStore: AssetStore; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     assetInteraction: AssetInteraction<TimelineAsset>; | ||||
|     removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; | ||||
|     withStacked?: boolean; | ||||
|     showArchiveIcon?: boolean; | ||||
| @ -43,7 +49,7 @@ | ||||
|     album?: AlbumResponseDto | null; | ||||
|     person?: PersonResponseDto | null; | ||||
|     isShowDeleteConfirmation?: boolean; | ||||
|     onSelect?: (asset: AssetResponseDto) => void; | ||||
|     onSelect?: (asset: TimelineAsset) => void; | ||||
|     onEscape?: () => void; | ||||
|     children?: Snippet; | ||||
|     empty?: Snippet; | ||||
| @ -352,7 +358,7 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectAsset = (asset: AssetResponseDto) => { | ||||
|   const handleSelectAsset = (asset: TimelineAsset) => { | ||||
|     if (!assetStore.albumAssets.has(asset.id)) { | ||||
|       assetInteraction.selectAsset(asset); | ||||
|     } | ||||
| @ -363,7 +369,8 @@ | ||||
| 
 | ||||
|     if (previousAsset) { | ||||
|       const preloadAsset = await assetStore.getPreviousAsset(previousAsset); | ||||
|       assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); | ||||
|       const asset = await getAssetInfo({ id: previousAsset.id }); | ||||
|       assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); | ||||
|       await navigate({ targetRoute: 'current', assetId: previousAsset.id }); | ||||
|     } | ||||
| 
 | ||||
| @ -375,7 +382,8 @@ | ||||
| 
 | ||||
|     if (nextAsset) { | ||||
|       const preloadAsset = await assetStore.getNextAsset(nextAsset); | ||||
|       assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); | ||||
|       const asset = await getAssetInfo({ id: nextAsset.id }); | ||||
|       assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); | ||||
|       await navigate({ targetRoute: 'current', assetId: nextAsset.id }); | ||||
|     } | ||||
| 
 | ||||
| @ -387,14 +395,14 @@ | ||||
| 
 | ||||
|     if (randomAsset) { | ||||
|       const preloadAsset = await assetStore.getNextAsset(randomAsset); | ||||
|       assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []); | ||||
|       const asset = await getAssetInfo({ id: randomAsset.id }); | ||||
|       assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); | ||||
|       await navigate({ targetRoute: 'current', assetId: randomAsset.id }); | ||||
|       return asset; | ||||
|     } | ||||
| 
 | ||||
|     return randomAsset; | ||||
|   }; | ||||
| 
 | ||||
|   const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { | ||||
|   const handleClose = async (asset: { id: string }) => { | ||||
|     assetViewingStore.showAssetViewer(false); | ||||
|     showSkeleton = true; | ||||
|     $gridScrollTarget = { at: asset.id }; | ||||
| @ -410,7 +418,7 @@ | ||||
|       case AssetAction.ARCHIVE: { | ||||
|         // find the next asset to show or close the viewer | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-expressions | ||||
|         (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); | ||||
|         (await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset)); | ||||
| 
 | ||||
|         // delete after find the next one | ||||
|         assetStore.removeAssets([action.asset.id]); | ||||
| @ -439,7 +447,7 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let lastAssetMouseEvent: AssetResponseDto | null = $state(null); | ||||
|   let lastAssetMouseEvent: TimelineAsset | null = $state(null); | ||||
| 
 | ||||
|   let shiftKeyIsDown = $state(false); | ||||
| 
 | ||||
| @ -469,14 +477,14 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { | ||||
|   const handleSelectAssetCandidates = (asset: TimelineAsset | null) => { | ||||
|     if (asset) { | ||||
|       selectAssetCandidates(asset); | ||||
|     } | ||||
|     lastAssetMouseEvent = asset; | ||||
|   }; | ||||
| 
 | ||||
|   const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => { | ||||
|   const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => { | ||||
|     if (assetInteraction.selectedGroup.has(group)) { | ||||
|       assetInteraction.removeGroupFromMultiselectGroup(group); | ||||
|       for (const asset of assets) { | ||||
| @ -496,7 +504,7 @@ | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectAssets = async (asset: AssetResponseDto) => { | ||||
|   const handleSelectAssets = async (asset: TimelineAsset) => { | ||||
|     if (!asset) { | ||||
|       return; | ||||
|     } | ||||
| @ -579,7 +587,7 @@ | ||||
|     assetInteraction.setAssetSelectionStart(deselect ? null : asset); | ||||
|   }; | ||||
| 
 | ||||
|   const selectAssetCandidates = (endAsset: AssetResponseDto) => { | ||||
|   const selectAssetCandidates = (endAsset: TimelineAsset) => { | ||||
|     if (!shiftKeyIsDown) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @ -4,8 +4,8 @@ | ||||
| 
 | ||||
|   export interface AssetControlContext { | ||||
|     // Wrap assets in a function, because context isn't reactive. | ||||
|     getAssets: () => AssetResponseDto[]; // All assets includes partners' assets | ||||
|     getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user | ||||
|     getAssets: () => BaseInteractionAsset[]; // All assets includes partners' assets | ||||
|     getOwnedAssets: () => BaseInteractionAsset[]; // Only assets owned by the user | ||||
|     clearSelect: () => void; | ||||
|   } | ||||
| 
 | ||||
| @ -14,13 +14,13 @@ | ||||
| </script> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
|   import type { AssetResponseDto } from '@immich/sdk'; | ||||
|   import type { BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { mdiClose } from '@mdi/js'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|   import type { Snippet } from 'svelte'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     assets: AssetResponseDto[]; | ||||
|     assets: BaseInteractionAsset[]; | ||||
|     clearSelect: () => void; | ||||
|     ownerId?: string | undefined; | ||||
|     children?: Snippet; | ||||
|  | ||||
| @ -7,7 +7,7 @@ | ||||
|   import { downloadArchive } from '$lib/utils/asset-utils'; | ||||
|   import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; | ||||
|   import { addSharedLinkAssets, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; | ||||
|   import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js'; | ||||
|   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; | ||||
|   import DownloadAction from '../photos-page/actions/download-action.svelte'; | ||||
| @ -31,7 +31,7 @@ | ||||
|   let { sharedLink = $bindable(), isOwned }: Props = $props(); | ||||
| 
 | ||||
|   const viewport: Viewport = $state({ width: 0, height: 0 }); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<AssetResponseDto>(); | ||||
| 
 | ||||
|   let assets = $derived(sharedLink.assets); | ||||
| 
 | ||||
|  | ||||
| @ -1,31 +1,32 @@ | ||||
| <script lang="ts"> | ||||
|   import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut'; | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; | ||||
|   import type { Action } from '$lib/components/asset-viewer/actions/action'; | ||||
|   import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; | ||||
|   import { AppRoute, AssetAction } from '$lib/constants'; | ||||
|   import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import type { Viewport } from '$lib/stores/assets-store.svelte'; | ||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||
|   import { featureFlags } from '$lib/stores/server-config.store'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   import { deleteAssets } from '$lib/utils/actions'; | ||||
|   import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { featureFlags } from '$lib/stores/server-config.store'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; | ||||
|   import { navigate } from '$lib/utils/navigation'; | ||||
|   import { toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
|   import { type AssetResponseDto } from '@immich/sdk'; | ||||
|   import { debounce } from 'lodash-es'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; | ||||
|   import ShowShortcuts from '../show-shortcuts.svelte'; | ||||
|   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'; | ||||
|   import { debounce } from 'lodash-es'; | ||||
|   import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; | ||||
|   import Portal from '../portal/portal.svelte'; | ||||
|   import ShowShortcuts from '../show-shortcuts.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     assets: AssetResponseDto[]; | ||||
|     assetInteraction: AssetInteraction; | ||||
|     assetInteraction: AssetInteraction<AssetResponseDto>; | ||||
|     disableAssetSelect?: boolean; | ||||
|     showArchiveIcon?: boolean; | ||||
|     viewport: Viewport; | ||||
| @ -481,18 +482,18 @@ | ||||
|         > | ||||
|           <Thumbnail | ||||
|             readonly={disableAssetSelect} | ||||
|             onClick={(asset) => { | ||||
|             onClick={() => { | ||||
|               if (assetInteraction.selectionActive) { | ||||
|                 handleSelectAssets(asset); | ||||
|                 return; | ||||
|               } | ||||
|               void viewAssetHandler(asset); | ||||
|             }} | ||||
|             onSelect={(asset) => handleSelectAssets(asset)} | ||||
|             onSelect={() => handleSelectAssets(asset)} | ||||
|             onMouseEvent={() => assetMouseEventHandler(asset)} | ||||
|             handleFocus={() => assetOnFocusHandler(asset)} | ||||
|             {showArchiveIcon} | ||||
|             {asset} | ||||
|             asset={toTimelineAsset(asset)} | ||||
|             selected={assetInteraction.hasSelectedAsset(asset.id)} | ||||
|             selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} | ||||
|             focussed={assetInteraction.isFocussedAsset(asset.id)} | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| import { AssetInteraction, type BaseInteractionAsset } 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; | ||||
|   let assetInteraction: AssetInteraction<BaseInteractionAsset>; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     assetInteraction = new AssetInteraction(); | ||||
|  | ||||
| @ -1,19 +1,27 @@ | ||||
| import { user } from '$lib/stores/user.store'; | ||||
| import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; | ||||
| import type { AssetStackResponseDto, UserAdminResponseDto } from '@immich/sdk'; | ||||
| import { SvelteSet } from 'svelte/reactivity'; | ||||
| import { fromStore } from 'svelte/store'; | ||||
| 
 | ||||
| export class AssetInteraction { | ||||
|   selectedAssets = $state<AssetResponseDto[]>([]); | ||||
| export type BaseInteractionAsset = { | ||||
|   id: string; | ||||
|   isTrashed: boolean; | ||||
|   isArchived: boolean; | ||||
|   isFavorite: boolean; | ||||
|   ownerId: string; | ||||
|   stack?: AssetStackResponseDto | null | undefined; | ||||
| }; | ||||
| export class AssetInteraction<T extends BaseInteractionAsset> { | ||||
|   selectedAssets = $state<T[]>([]); | ||||
|   hasSelectedAsset(assetId: string) { | ||||
|     return this.selectedAssets.some((asset) => asset.id === assetId); | ||||
|   } | ||||
|   selectedGroup = new SvelteSet<string>(); | ||||
|   assetSelectionCandidates = $state<AssetResponseDto[]>([]); | ||||
|   assetSelectionCandidates = $state<T[]>([]); | ||||
|   hasSelectionCandidate(assetId: string) { | ||||
|     return this.assetSelectionCandidates.some((asset) => asset.id === assetId); | ||||
|   } | ||||
|   assetSelectionStart = $state<AssetResponseDto | null>(null); | ||||
|   assetSelectionStart = $state<T | null>(null); | ||||
|   focussedAssetId = $state<string | null>(null); | ||||
|   selectionActive = $derived(this.selectedAssets.length > 0); | ||||
| 
 | ||||
| @ -25,13 +33,13 @@ export class AssetInteraction { | ||||
|   isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); | ||||
|   isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); | ||||
| 
 | ||||
|   selectAsset(asset: AssetResponseDto) { | ||||
|   selectAsset(asset: T) { | ||||
|     if (!this.hasSelectedAsset(asset.id)) { | ||||
|       this.selectedAssets.push(asset); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   selectAssets(assets: AssetResponseDto[]) { | ||||
|   selectAssets(assets: T[]) { | ||||
|     for (const asset of assets) { | ||||
|       this.selectAsset(asset); | ||||
|     } | ||||
| @ -52,11 +60,11 @@ export class AssetInteraction { | ||||
|     this.selectedGroup.delete(group); | ||||
|   } | ||||
| 
 | ||||
|   setAssetSelectionStart(asset: AssetResponseDto | null) { | ||||
|   setAssetSelectionStart(asset: T | null) { | ||||
|     this.assetSelectionStart = asset; | ||||
|   } | ||||
| 
 | ||||
|   setAssetSelectionCandidates(assets: AssetResponseDto[]) { | ||||
|   setAssetSelectionCandidates(assets: T[]) { | ||||
|     this.assetSelectionCandidates = assets; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -5,11 +5,11 @@ import { readonly, writable } from 'svelte/store'; | ||||
| 
 | ||||
| function createAssetViewingStore() { | ||||
|   const viewingAssetStoreState = writable<AssetResponseDto>(); | ||||
|   const preloadAssets = writable<AssetResponseDto[]>([]); | ||||
|   const preloadAssets = writable<{ id: string }[]>([]); | ||||
|   const viewState = writable<boolean>(false); | ||||
|   const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); | ||||
| 
 | ||||
|   const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { | ||||
|   const setAsset = (asset: AssetResponseDto, assetsToPreload: { id: string }[] = []) => { | ||||
|     preloadAssets.set(assetsToPreload); | ||||
|     viewingAssetStoreState.set(asset); | ||||
|     viewState.set(true); | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| import { sdkMock } from '$lib/__mocks__/sdk.mock'; | ||||
| import { AbortError } from '$lib/utils'; | ||||
| import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { assetFactory } from '@test-data/factories/asset-factory'; | ||||
| import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory'; | ||||
| import { AssetStore } from './assets-store.svelte'; | ||||
| 
 | ||||
| describe('AssetStore', () => { | ||||
| @ -149,9 +149,8 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('adds assets to new bucket', () => { | ||||
|       const asset = assetFactory.build({ | ||||
|       const asset = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-20T12:00:00.000Z', | ||||
|         fileCreatedAt: '2024-01-20T12:00:00.000Z', | ||||
|       }); | ||||
|       assetStore.addAssets([asset]); | ||||
| 
 | ||||
| @ -163,9 +162,8 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('adds assets to existing bucket', () => { | ||||
|       const [assetOne, assetTwo] = assetFactory.buildList(2, { | ||||
|       const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { | ||||
|         localDateTime: '2024-01-20T12:00:00.000Z', | ||||
|         fileCreatedAt: '2024-01-20T12:00:00.000Z', | ||||
|       }); | ||||
|       assetStore.addAssets([assetOne]); | ||||
|       assetStore.addAssets([assetTwo]); | ||||
| @ -177,16 +175,13 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('orders assets in buckets by descending date', () => { | ||||
|       const assetOne = assetFactory.build({ | ||||
|         fileCreatedAt: '2024-01-20T12:00:00.000Z', | ||||
|       const assetOne = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-20T12:00:00.000Z', | ||||
|       }); | ||||
|       const assetTwo = assetFactory.build({ | ||||
|         fileCreatedAt: '2024-01-15T12:00:00.000Z', | ||||
|       const assetTwo = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-15T12:00:00.000Z', | ||||
|       }); | ||||
|       const assetThree = assetFactory.build({ | ||||
|         fileCreatedAt: '2024-01-16T12:00:00.000Z', | ||||
|       const assetThree = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-16T12:00:00.000Z', | ||||
|       }); | ||||
|       assetStore.addAssets([assetOne, assetTwo, assetThree]); | ||||
| @ -200,9 +195,9 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('orders buckets by descending date', () => { | ||||
|       const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = assetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' }); | ||||
|       const assetThree = assetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' }); | ||||
|       const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' }); | ||||
|       const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' }); | ||||
|       assetStore.addAssets([assetOne, assetTwo, assetThree]); | ||||
| 
 | ||||
|       expect(assetStore.buckets.length).toEqual(3); | ||||
| @ -213,7 +208,7 @@ describe('AssetStore', () => { | ||||
| 
 | ||||
|     it('updates existing asset', () => { | ||||
|       const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); | ||||
|       const asset = assetFactory.build(); | ||||
|       const asset = timelineAssetFactory.build(); | ||||
|       assetStore.addAssets([asset]); | ||||
| 
 | ||||
|       assetStore.addAssets([asset]); | ||||
| @ -223,8 +218,8 @@ describe('AssetStore', () => { | ||||
| 
 | ||||
|     // disabled due to the wasm Justified Layout import
 | ||||
|     it('ignores trashed assets when isTrashed is true', async () => { | ||||
|       const asset = assetFactory.build({ isTrashed: false }); | ||||
|       const trashedAsset = assetFactory.build({ isTrashed: true }); | ||||
|       const asset = timelineAssetFactory.build({ isTrashed: false }); | ||||
|       const trashedAsset = timelineAssetFactory.build({ isTrashed: true }); | ||||
| 
 | ||||
|       const assetStore = new AssetStore(); | ||||
|       await assetStore.updateOptions({ isTrashed: true }); | ||||
| @ -244,14 +239,14 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('ignores non-existing assets', () => { | ||||
|       assetStore.updateAssets([assetFactory.build()]); | ||||
|       assetStore.updateAssets([timelineAssetFactory.build()]); | ||||
| 
 | ||||
|       expect(assetStore.buckets.length).toEqual(0); | ||||
|       expect(assetStore.getAssets().length).toEqual(0); | ||||
|     }); | ||||
| 
 | ||||
|     it('updates an asset', () => { | ||||
|       const asset = assetFactory.build({ isFavorite: false }); | ||||
|       const asset = timelineAssetFactory.build({ isFavorite: false }); | ||||
|       const updatedAsset = { ...asset, isFavorite: true }; | ||||
| 
 | ||||
|       assetStore.addAssets([asset]); | ||||
| @ -264,7 +259,7 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('asset moves buckets when asset date changes', () => { | ||||
|       const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' }; | ||||
| 
 | ||||
|       assetStore.addAssets([asset]); | ||||
| @ -292,7 +287,7 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('ignores invalid IDs', () => { | ||||
|       assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); | ||||
|       assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); | ||||
|       assetStore.removeAssets(['', 'invalid', '4c7d9acc']); | ||||
| 
 | ||||
|       expect(assetStore.getAssets().length).toEqual(2); | ||||
| @ -301,7 +296,7 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('removes asset from bucket', () => { | ||||
|       const [assetOne, assetTwo] = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       assetStore.addAssets([assetOne, assetTwo]); | ||||
|       assetStore.removeAssets([assetOne.id]); | ||||
| 
 | ||||
| @ -311,7 +306,7 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('does not remove bucket when empty', () => { | ||||
|       const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       assetStore.addAssets(assets); | ||||
|       assetStore.removeAssets(assets.map((asset) => asset.id)); | ||||
| 
 | ||||
| @ -334,12 +329,10 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('populated store returns first asset', () => { | ||||
|       const assetOne = assetFactory.build({ | ||||
|         fileCreatedAt: '2024-01-20T12:00:00.000Z', | ||||
|       const assetOne = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-20T12:00:00.000Z', | ||||
|       }); | ||||
|       const assetTwo = assetFactory.build({ | ||||
|         fileCreatedAt: '2024-01-15T12:00:00.000Z', | ||||
|       const assetTwo = timelineAssetFactory.build({ | ||||
|         localDateTime: '2024-01-15T12:00:00.000Z', | ||||
|       }); | ||||
|       assetStore.addAssets([assetOne, assetTwo]); | ||||
| @ -445,8 +438,8 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('returns the bucket index', () => { | ||||
|       const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); | ||||
|       const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); | ||||
|       assetStore.addAssets([assetOne, assetTwo]); | ||||
| 
 | ||||
|       expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z'); | ||||
| @ -454,8 +447,8 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('ignores removed buckets', () => { | ||||
|       const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); | ||||
|       const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); | ||||
|       assetStore.addAssets([assetOne, assetTwo]); | ||||
| 
 | ||||
|       assetStore.removeAssets([assetTwo.id]); | ||||
|  | ||||
| @ -7,7 +7,7 @@ import { | ||||
|   type CommonLayoutOptions, | ||||
|   type CommonPosition, | ||||
| } from '$lib/utils/layout-utils'; | ||||
| import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util'; | ||||
| import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util'; | ||||
| import { TUNABLES } from '$lib/utils/tunables'; | ||||
| import { | ||||
|   AssetOrder, | ||||
| @ -16,11 +16,11 @@ import { | ||||
|   getTimeBuckets, | ||||
|   TimeBucketSize, | ||||
|   type AssetResponseDto, | ||||
|   type AssetStackResponseDto, | ||||
| } from '@immich/sdk'; | ||||
| import { clamp, debounce, isEqual, throttle } from 'lodash-es'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { t } from 'svelte-i18n'; | ||||
| 
 | ||||
| import { SvelteSet } from 'svelte/reactivity'; | ||||
| import { get, writable, type Unsubscriber } from 'svelte/store'; | ||||
| import { handleError } from '../utils/handle-error'; | ||||
| @ -62,13 +62,30 @@ function updateObject(target: any, source: any): boolean { | ||||
|   return updated; | ||||
| } | ||||
| 
 | ||||
| export function assetSnapshot(asset: AssetResponseDto) { | ||||
|   return $state.snapshot(asset); | ||||
| export function assetSnapshot(asset: TimelineAsset): TimelineAsset { | ||||
|   return $state.snapshot(asset) as TimelineAsset; | ||||
| } | ||||
| 
 | ||||
| export function assetsSnapshot(assets: AssetResponseDto[]) { | ||||
|   return assets.map((a) => $state.snapshot(a)); | ||||
| export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] { | ||||
|   return assets.map((a) => $state.snapshot(a)) as TimelineAsset[]; | ||||
| } | ||||
| 
 | ||||
| export type TimelineAsset = { | ||||
|   id: string; | ||||
|   ownerId: string; | ||||
|   ratio: number; | ||||
|   thumbhash: string | null; | ||||
|   localDateTime: string; | ||||
|   isArchived: boolean; | ||||
|   isFavorite: boolean; | ||||
|   isTrashed: boolean; | ||||
|   isVideo: boolean; | ||||
|   isImage: boolean; | ||||
|   stack: AssetStackResponseDto | null; | ||||
|   duration: string | null; | ||||
|   projectionType: string | null; | ||||
|   livePhotoVideoId: string | null; | ||||
| }; | ||||
| class IntersectingAsset { | ||||
|   // --- public ---
 | ||||
|   readonly #group: AssetDateGroup; | ||||
| @ -92,17 +109,17 @@ class IntersectingAsset { | ||||
|   }); | ||||
| 
 | ||||
|   position: CommonPosition | undefined = $state(); | ||||
|   asset: AssetResponseDto | undefined = $state(); | ||||
|   asset: TimelineAsset | undefined = $state(); | ||||
|   id: string | undefined = $derived(this.asset?.id); | ||||
| 
 | ||||
|   constructor(group: AssetDateGroup, asset: AssetResponseDto) { | ||||
|   constructor(group: AssetDateGroup, asset: TimelineAsset) { | ||||
|     this.#group = group; | ||||
|     this.asset = asset; | ||||
|   } | ||||
| } | ||||
| type AssetOperation = (asset: AssetResponseDto) => { remove: boolean }; | ||||
| type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; | ||||
| 
 | ||||
| type MoveAsset = { asset: AssetResponseDto; year: number; month: number }; | ||||
| type MoveAsset = { asset: TimelineAsset; year: number; month: number }; | ||||
| export class AssetDateGroup { | ||||
|   // --- public
 | ||||
|   readonly bucket: AssetBucket; | ||||
| @ -131,8 +148,8 @@ export class AssetDateGroup { | ||||
| 
 | ||||
|   sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { | ||||
|     this.intersetingAssets.sort((a, b) => { | ||||
|       const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC(); | ||||
|       const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC(); | ||||
|       const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC(); | ||||
|       const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC(); | ||||
| 
 | ||||
|       if (sortOrder === AssetOrder.Asc) { | ||||
|         return aDate.diff(bDate).milliseconds; | ||||
| @ -223,6 +240,25 @@ export type ViewportXY = Viewport & { | ||||
|   y: number; | ||||
| }; | ||||
| 
 | ||||
| class AddContext { | ||||
|   lookupCache: { | ||||
|     [dayOfMonth: number]: AssetDateGroup; | ||||
|   } = {}; | ||||
|   unprocessedAssets: TimelineAsset[] = []; | ||||
|   changedDateGroups = new Set<AssetDateGroup>(); | ||||
|   newDateGroups = new Set<AssetDateGroup>(); | ||||
|   sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) { | ||||
|     for (const group of this.changedDateGroups) { | ||||
|       group.sortAssets(sortOrder); | ||||
|     } | ||||
|     for (const group of this.newDateGroups) { | ||||
|       group.sortAssets(sortOrder); | ||||
|     } | ||||
|     if (this.newDateGroups.size > 0) { | ||||
|       bucket.sortDateGroups(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| export class AssetBucket { | ||||
|   // --- public ---
 | ||||
|   #intersecting: boolean = $state(false); | ||||
| @ -314,7 +350,7 @@ export class AssetBucket { | ||||
|   getAssets() { | ||||
|     // eslint-disable-next-line unicorn/no-array-reduce
 | ||||
|     return this.dateGroups.reduce( | ||||
|       (accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()), | ||||
|       (accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()), | ||||
|       [], | ||||
|     ); | ||||
|   } | ||||
| @ -379,55 +415,51 @@ export class AssetBucket { | ||||
|   } | ||||
| 
 | ||||
|   // note - if the assets are not part of this bucket, they will not be added
 | ||||
|   addAssets(assets: AssetResponseDto[]) { | ||||
|     const lookupCache: { | ||||
|       [dayOfMonth: number]: AssetDateGroup; | ||||
|     } = {}; | ||||
|     const unprocessedAssets: AssetResponseDto[] = []; | ||||
|     const changedDateGroups = new Set<AssetDateGroup>(); | ||||
|     const newDateGroups = new Set<AssetDateGroup>(); | ||||
|     for (const asset of assets) { | ||||
|       const date = DateTime.fromISO(asset.localDateTime).toUTC(); | ||||
|       const month = date.get('month'); | ||||
|       const year = date.get('year'); | ||||
|       if (this.month === month && this.year === year) { | ||||
|         const day = date.get('day'); | ||||
|         let dateGroup: AssetDateGroup | undefined = lookupCache[day]; | ||||
|         if (!dateGroup) { | ||||
|           dateGroup = this.findDateGroupByDay(day); | ||||
|           if (dateGroup) { | ||||
|             lookupCache[day] = dateGroup; | ||||
|           } | ||||
|         } | ||||
|   addAssets(bucketResponse: AssetResponseDto[]) { | ||||
|     const addContext = new AddContext(); | ||||
|     for (const asset of bucketResponse) { | ||||
|       const timelineAsset = toTimelineAsset(asset); | ||||
|       this.addTimelineAsset(timelineAsset, addContext); | ||||
|     } | ||||
| 
 | ||||
|     addContext.sort(this, this.#sortOrder); | ||||
|     return addContext.unprocessedAssets; | ||||
|   } | ||||
| 
 | ||||
|   addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) { | ||||
|     const { id, localDateTime } = timelineAsset; | ||||
|     const date = DateTime.fromISO(localDateTime).toUTC(); | ||||
| 
 | ||||
|     const month = date.get('month'); | ||||
|     const year = date.get('year'); | ||||
| 
 | ||||
|     if (this.month === month && this.year === year) { | ||||
|       const day = date.get('day'); | ||||
|       let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day]; | ||||
|       if (!dateGroup) { | ||||
|         dateGroup = this.findDateGroupByDay(day); | ||||
|         if (dateGroup) { | ||||
|           const intersectingAsset = new IntersectingAsset(dateGroup, asset); | ||||
|           if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) { | ||||
|             console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`); | ||||
|           } else { | ||||
|             dateGroup.intersetingAssets.push(intersectingAsset); | ||||
|             changedDateGroups.add(dateGroup); | ||||
|           } | ||||
|           addContext.lookupCache[day] = dateGroup; | ||||
|         } | ||||
|       } | ||||
|       if (dateGroup) { | ||||
|         const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset); | ||||
|         if (dateGroup.intersetingAssets.some((a) => a.id === id)) { | ||||
|           console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`); | ||||
|         } else { | ||||
|           dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); | ||||
|           dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset)); | ||||
|           this.dateGroups.push(dateGroup); | ||||
|           lookupCache[day] = dateGroup; | ||||
|           newDateGroups.add(dateGroup); | ||||
|           dateGroup.intersetingAssets.push(intersectingAsset); | ||||
|           addContext.changedDateGroups.add(dateGroup); | ||||
|         } | ||||
|       } else { | ||||
|         unprocessedAssets.push(asset); | ||||
|         dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); | ||||
|         dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, timelineAsset)); | ||||
|         this.dateGroups.push(dateGroup); | ||||
|         addContext.lookupCache[day] = dateGroup; | ||||
|         addContext.newDateGroups.add(dateGroup); | ||||
|       } | ||||
|     } else { | ||||
|       addContext.unprocessedAssets.push(timelineAsset); | ||||
|     } | ||||
|     for (const group of changedDateGroups) { | ||||
|       group.sortAssets(this.#sortOrder); | ||||
|     } | ||||
|     for (const group of newDateGroups) { | ||||
|       group.sortAssets(this.#sortOrder); | ||||
|     } | ||||
|     if (newDateGroups.size > 0) { | ||||
|       this.sortDateGroups(); | ||||
|     } | ||||
|     return unprocessedAssets; | ||||
|   } | ||||
|   getRandomDateGroup() { | ||||
|     const random = Math.floor(Math.random() * this.dateGroups.length); | ||||
| @ -514,12 +546,12 @@ const isMismatched = (option: boolean | undefined, value: boolean): boolean => | ||||
| 
 | ||||
| interface AddAsset { | ||||
|   type: 'add'; | ||||
|   values: AssetResponseDto[]; | ||||
|   values: TimelineAsset[]; | ||||
| } | ||||
| 
 | ||||
| interface UpdateAsset { | ||||
|   type: 'update'; | ||||
|   values: AssetResponseDto[]; | ||||
|   values: TimelineAsset[]; | ||||
| } | ||||
| 
 | ||||
| interface DeleteAsset { | ||||
| @ -701,9 +733,13 @@ export class AssetStore { | ||||
| 
 | ||||
|   connect() { | ||||
|     this.#unsubscribers.push( | ||||
|       websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })), | ||||
|       websocketEvents.on('on_upload_success', (asset) => | ||||
|         this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }), | ||||
|       ), | ||||
|       websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })), | ||||
|       websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })), | ||||
|       websocketEvents.on('on_asset_update', (asset) => | ||||
|         this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }), | ||||
|       ), | ||||
|       websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })), | ||||
|     ); | ||||
|   } | ||||
| @ -717,8 +753,8 @@ export class AssetStore { | ||||
| 
 | ||||
|   #getPendingChangeBatches() { | ||||
|     const batch: { | ||||
|       add: AssetResponseDto[]; | ||||
|       update: AssetResponseDto[]; | ||||
|       add: TimelineAsset[]; | ||||
|       update: TimelineAsset[]; | ||||
|       remove: string[]; | ||||
|     } = { | ||||
|       add: [], | ||||
| @ -1042,7 +1078,7 @@ export class AssetStore { | ||||
|         // so no need to load the bucket, it already has assets
 | ||||
|         return; | ||||
|       } | ||||
|       const assets = await getTimeBucket( | ||||
|       const bucketResponse = await getTimeBucket( | ||||
|         { | ||||
|           ...this.#options, | ||||
|           timeBucket: bucketDate, | ||||
| @ -1051,9 +1087,9 @@ export class AssetStore { | ||||
|         }, | ||||
|         { signal }, | ||||
|       ); | ||||
|       if (assets) { | ||||
|       if (bucketResponse) { | ||||
|         if (this.#options.timelineAlbumId) { | ||||
|           const albumAssets = await getTimeBucket( | ||||
|           const bucketAssets = await getTimeBucket( | ||||
|             { | ||||
|               albumId: this.#options.timelineAlbumId, | ||||
|               timeBucket: bucketDate, | ||||
| @ -1062,12 +1098,11 @@ export class AssetStore { | ||||
|             }, | ||||
|             { signal }, | ||||
|           ); | ||||
|           for (const asset of albumAssets) { | ||||
|             this.albumAssets.add(asset.id); | ||||
|           for (const { id } of bucketAssets) { | ||||
|             this.albumAssets.add(id); | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         const unprocessed = bucket.addAssets(assets); | ||||
|         const unprocessed = bucket.addAssets(bucketResponse); | ||||
|         if (unprocessed.length > 0) { | ||||
|           console.error( | ||||
|             `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`, | ||||
| @ -1081,8 +1116,8 @@ export class AssetStore { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   addAssets(assets: AssetResponseDto[]) { | ||||
|     const assetsToUpdate: AssetResponseDto[] = []; | ||||
|   addAssets(assets: TimelineAsset[]) { | ||||
|     const assetsToUpdate: TimelineAsset[] = []; | ||||
| 
 | ||||
|     for (const asset of assets) { | ||||
|       if (this.isExcluded(asset)) { | ||||
| @ -1095,7 +1130,7 @@ export class AssetStore { | ||||
|     this.#addAssetsToBuckets([...notUpdated]); | ||||
|   } | ||||
| 
 | ||||
|   #addAssetsToBuckets(assets: AssetResponseDto[]) { | ||||
|   #addAssetsToBuckets(assets: TimelineAsset[]) { | ||||
|     if (assets.length === 0) { | ||||
|       return; | ||||
|     } | ||||
| @ -1112,7 +1147,9 @@ export class AssetStore { | ||||
|         bucket = new AssetBucket(this, utc, 1, this.#options.order); | ||||
|         this.buckets.push(bucket); | ||||
|       } | ||||
|       bucket.addAssets([asset]); | ||||
|       const addContext = new AddContext(); | ||||
|       bucket.addTimelineAsset(asset, addContext); | ||||
|       addContext.sort(bucket, this.#options.order); | ||||
|       updatedBuckets.add(bucket); | ||||
|     } | ||||
| 
 | ||||
| @ -1138,7 +1175,7 @@ export class AssetStore { | ||||
|     await this.initTask.waitUntilCompletion(); | ||||
|     let bucket = this.#findBucketForAsset(id); | ||||
|     if (!bucket) { | ||||
|       const asset = await getAssetInfo({ id }); | ||||
|       const asset = toTimelineAsset(await getAssetInfo({ id })); | ||||
|       if (!asset || this.isExcluded(asset)) { | ||||
|         return; | ||||
|       } | ||||
| @ -1151,7 +1188,7 @@ export class AssetStore { | ||||
|   } | ||||
| 
 | ||||
|   async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) { | ||||
|     let date = fromLocalDateTime(localDateTime); | ||||
|     let date = DateTime.fromISO(localDateTime).toUTC(); | ||||
|     // Only support TimeBucketSize.Month
 | ||||
|     date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); | ||||
|     const iso = date.toISO()!; | ||||
| @ -1161,7 +1198,7 @@ export class AssetStore { | ||||
|     return this.getBucketByDate(year, month); | ||||
|   } | ||||
| 
 | ||||
|   async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) { | ||||
|   async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) { | ||||
|     const bucketInfo = this.#findBucketForAsset(asset.id); | ||||
|     if (bucketInfo) { | ||||
|       return bucketInfo; | ||||
| @ -1195,7 +1232,7 @@ export class AssetStore { | ||||
|     const changedBuckets = new Set<AssetBucket>(); | ||||
|     let idsToProcess = new Set(ids); | ||||
|     const idsProcessed = new Set<string>(); | ||||
|     const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = []; | ||||
|     const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = []; | ||||
|     for (const bucket of this.buckets) { | ||||
|       if (idsToProcess.size > 0) { | ||||
|         const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation); | ||||
| @ -1238,8 +1275,8 @@ export class AssetStore { | ||||
|     this.#runAssetOperation(new Set(ids), operation); | ||||
|   } | ||||
| 
 | ||||
|   updateAssets(assets: AssetResponseDto[]) { | ||||
|     const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset])); | ||||
|   updateAssets(assets: TimelineAsset[]) { | ||||
|     const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset])); | ||||
|     const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => { | ||||
|       updateObject(asset, lookup.get(asset.id)); | ||||
|       return { remove: false }; | ||||
| @ -1261,11 +1298,11 @@ export class AssetStore { | ||||
|     this.updateIntersections(); | ||||
|   } | ||||
| 
 | ||||
|   getFirstAsset(): AssetResponseDto | undefined { | ||||
|   getFirstAsset(): TimelineAsset | undefined { | ||||
|     return this.buckets[0]?.getFirstAsset(); | ||||
|   } | ||||
| 
 | ||||
|   async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { | ||||
|   async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> { | ||||
|     let bucket = await this.#getBucketInfoForAsset(asset); | ||||
|     if (!bucket) { | ||||
|       return; | ||||
| @ -1308,7 +1345,7 @@ export class AssetStore { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { | ||||
|   async getNextAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> { | ||||
|     let bucket = await this.#getBucketInfoForAsset(asset); | ||||
|     if (!bucket) { | ||||
|       return; | ||||
| @ -1347,7 +1384,7 @@ export class AssetStore { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isExcluded(asset: AssetResponseDto) { | ||||
|   isExcluded(asset: TimelineAsset) { | ||||
|     return ( | ||||
|       isMismatched(this.#options.isArchived, asset.isArchived) || | ||||
|       isMismatched(this.#options.isFavorite, asset.isFavorite) || | ||||
|  | ||||
| @ -1,20 +1,20 @@ | ||||
| import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; | ||||
| import type { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
| import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import type { StackResponse } from '$lib/utils/asset-utils'; | ||||
| import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { deleteAssets as deleteBulk } from '@immich/sdk'; | ||||
| import { t } from 'svelte-i18n'; | ||||
| import { get } from 'svelte/store'; | ||||
| import { handleError } from './handle-error'; | ||||
| 
 | ||||
| export type OnDelete = (assetIds: string[]) => void; | ||||
| export type OnRestore = (ids: string[]) => void; | ||||
| export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; | ||||
| export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; | ||||
| export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; | ||||
| export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; | ||||
| export type OnAddToAlbum = (ids: string[], albumId: string) => void; | ||||
| export type OnArchive = (ids: string[], isArchived: boolean) => void; | ||||
| export type OnFavorite = (ids: string[], favorite: boolean) => void; | ||||
| export type OnStack = (result: StackResponse) => void; | ||||
| export type OnUnstack = (assets: AssetResponseDto[]) => void; | ||||
| export type OnUnstack = (assets: TimelineAsset[]) => void; | ||||
| 
 | ||||
| export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { | ||||
|   const $t = get(t); | ||||
| @ -64,11 +64,11 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to | ||||
|  * @param assetStore - The asset store to update. | ||||
|  * @param assets - The array of asset response DTOs to update in the asset store. | ||||
|  */ | ||||
| export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: AssetResponseDto[]) { | ||||
| export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) { | ||||
|   assetStore.updateAssetOperation( | ||||
|     assets.map((asset) => asset.id), | ||||
|     (asset) => { | ||||
|       asset.stack = undefined; | ||||
|       asset.stack = null; | ||||
|       return { remove: false }; | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
| @ -3,7 +3,7 @@ import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; | ||||
| import type { InterpolationValues } from '$lib/components/i18n/format-message'; | ||||
| import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; | ||||
| import { AppRoute } from '$lib/constants'; | ||||
| import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
| import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte'; | ||||
| import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
| import { downloadManager } from '$lib/stores/download-store.svelte'; | ||||
| import { preferences } from '$lib/stores/user.store'; | ||||
| @ -364,7 +364,7 @@ export const getAssetType = (type: AssetTypeEnum) => { | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => { | ||||
| export const getSelectedAssets = (assets: BaseInteractionAsset[], user: UserResponseDto | null): string[] => { | ||||
|   const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); | ||||
| 
 | ||||
|   const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; | ||||
| @ -383,7 +383,7 @@ export type StackResponse = { | ||||
|   toDeleteIds: string[]; | ||||
| }; | ||||
| 
 | ||||
| export const stackAssets = async (assets: AssetResponseDto[], showNotification = true): Promise<StackResponse> => { | ||||
| export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => { | ||||
|   if (assets.length < 2) { | ||||
|     return { stack: undefined, toDeleteIds: [] }; | ||||
|   } | ||||
| @ -403,9 +403,9 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification = | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     for (const [index, asset] of assets.entries()) { | ||||
|       asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; | ||||
|     } | ||||
|     // for (const [index, asset] of assets.entries()) {
 | ||||
|     //   asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
 | ||||
|     // }
 | ||||
| 
 | ||||
|     return { | ||||
|       stack, | ||||
| @ -467,7 +467,10 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => { | ||||
| export const selectAllAssets = async ( | ||||
|   assetStore: AssetStore, | ||||
|   assetInteraction: AssetInteraction<BaseInteractionAsset>, | ||||
| ) => { | ||||
|   if (get(isSelectingAllAssets)) { | ||||
|     // Selection is already ongoing
 | ||||
|     return; | ||||
| @ -495,7 +498,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const cancelMultiselect = (assetInteraction: AssetInteraction) => { | ||||
| export const cancelMultiselect = (assetInteraction: AssetInteraction<BaseInteractionAsset>) => { | ||||
|   isSelectingAllAssets.set(false); | ||||
|   assetInteraction.clearMultiselect(); | ||||
| }; | ||||
| @ -523,7 +526,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => { | ||||
|   return asset; | ||||
| }; | ||||
| 
 | ||||
| export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => { | ||||
| export const archiveAssets = async (assets: { id: string }[], archive: boolean) => { | ||||
|   const isArchived = archive; | ||||
|   const ids = assets.map(({ id }) => id); | ||||
|   const $t = get(t); | ||||
| @ -533,9 +536,9 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean | ||||
|       await updateAssets({ assetBulkUpdateDto: { ids, isArchived } }); | ||||
|     } | ||||
| 
 | ||||
|     for (const asset of assets) { | ||||
|       asset.isArchived = isArchived; | ||||
|     } | ||||
|     // for (const asset of assets) {
 | ||||
|     //   asset.isArchived = isArchived;
 | ||||
|     // }
 | ||||
| 
 | ||||
|     notificationController.show({ | ||||
|       message: isArchived | ||||
|  | ||||
| @ -1,7 +1,10 @@ | ||||
| import { getAssetRatio } from '$lib/utils/asset-utils'; | ||||
| // import { TUNABLES } from '$lib/utils/tunables';
 | ||||
| // note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
 | ||||
| // import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
 | ||||
| 
 | ||||
| import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import { getAssetRatio } from '$lib/utils/asset-utils'; | ||||
| import { isTimelineAsset } from '$lib/utils/timeline-util'; | ||||
| import type { AssetResponseDto } from '@immich/sdk'; | ||||
| import createJustifiedLayout from 'justified-layout'; | ||||
| 
 | ||||
| @ -26,7 +29,7 @@ export type CommonLayoutOptions = { | ||||
| }; | ||||
| 
 | ||||
| export function getJustifiedLayoutFromAssets( | ||||
|   assets: AssetResponseDto[], | ||||
|   assets: (TimelineAsset | AssetResponseDto)[], | ||||
|   options: CommonLayoutOptions, | ||||
| ): CommonJustifiedLayout { | ||||
|   // if (useWasm) {
 | ||||
| @ -87,7 +90,7 @@ class Adapter { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) { | ||||
| export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) { | ||||
|   const adapter = { | ||||
|     targetRowHeight: options.rowHeight, | ||||
|     containerWidth: options.rowWidth, | ||||
| @ -96,7 +99,7 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou | ||||
|   }; | ||||
| 
 | ||||
|   const result = createJustifiedLayout( | ||||
|     assets.map((g) => getAssetRatio(g)), | ||||
|     assets.map((a) => (isTimelineAsset(a) ? a.ratio : getAssetRatio(a))), | ||||
|     adapter, | ||||
|   ); | ||||
|   return new Adapter(result); | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| import type { AssetBucket } from '$lib/stores/assets-store.svelte'; | ||||
| import type { BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte'; | ||||
| import type { AssetBucket, TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import { locale } from '$lib/stores/preferences.store'; | ||||
| import { getAssetRatio } from '$lib/utils/asset-utils'; | ||||
| import { type CommonJustifiedLayout } from '$lib/utils/layout-utils'; | ||||
| 
 | ||||
| import type { AssetResponseDto } from '@immich/sdk'; | ||||
| import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { memoize } from 'lodash-es'; | ||||
| import { DateTime, type LocaleOptions } from 'luxon'; | ||||
| import { get } from 'svelte/store'; | ||||
| @ -105,3 +107,30 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin | ||||
|   date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); | ||||
| 
 | ||||
| export const formatDateGroupTitle = memoize(formatGroupTitle); | ||||
| 
 | ||||
| export const toTimelineAsset = (unknownAsset: BaseInteractionAsset): TimelineAsset => { | ||||
|   if (isTimelineAsset(unknownAsset)) { | ||||
|     return unknownAsset; | ||||
|   } | ||||
|   const assetResponse = unknownAsset as AssetResponseDto; | ||||
|   const { width, height } = getAssetRatio(assetResponse); | ||||
|   const ratio = width / height; | ||||
|   return { | ||||
|     id: assetResponse.id, | ||||
|     ownerId: assetResponse.ownerId, | ||||
|     ratio, | ||||
|     thumbhash: assetResponse.thumbhash, | ||||
|     localDateTime: assetResponse.localDateTime, | ||||
|     isFavorite: assetResponse.isFavorite, | ||||
|     isArchived: assetResponse.isArchived, | ||||
|     isTrashed: assetResponse.isTrashed, | ||||
|     isVideo: assetResponse.type == AssetTypeEnum.Video, | ||||
|     isImage: assetResponse.type == AssetTypeEnum.Image, | ||||
|     stack: assetResponse.stack || null, | ||||
|     duration: assetResponse.duration || null, | ||||
|     projectionType: assetResponse.exifInfo?.projectionType || null, | ||||
|     livePhotoVideoId: assetResponse.livePhotoVideoId || null, | ||||
|   }; | ||||
| }; | ||||
| export const isTimelineAsset = (arg: BaseInteractionAsset): arg is TimelineAsset => | ||||
|   (arg as TimelineAsset).ratio !== undefined; | ||||
|  | ||||
| @ -22,9 +22,10 @@ | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; | ||||
|   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; | ||||
| @ -33,14 +34,16 @@ | ||||
|     notificationController, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; | ||||
|   import { AppRoute, AlbumPageViewMode } from '$lib/constants'; | ||||
|   import { AlbumPageViewMode, AppRoute } from '$lib/constants'; | ||||
|   import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   import { downloadAlbum, cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { confirmAlbumDelete } from '$lib/utils/album-utils'; | ||||
|   import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { | ||||
| @ -80,13 +83,10 @@ | ||||
|     mdiPresentationPlay, | ||||
|     mdiShareVariantOutline, | ||||
|   } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { fly } from 'svelte/transition'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   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; | ||||
| @ -94,7 +94,7 @@ | ||||
| 
 | ||||
|   let { data = $bindable() }: Props = $props(); | ||||
| 
 | ||||
|   let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; | ||||
|   let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore; | ||||
|   let { slideshowState, slideshowNavigation } = slideshowStore; | ||||
| 
 | ||||
|   let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); | ||||
| @ -107,8 +107,8 @@ | ||||
|   let reactions: ActivityResponseDto[] = $state([]); | ||||
|   let albumOrder: AssetOrder | undefined = $state(data.album.order); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const timelineInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
|   const timelineInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   afterNavigate(({ from }) => { | ||||
|     let url: string | undefined = from?.url?.pathname; | ||||
| @ -207,8 +207,7 @@ | ||||
|         ? await assetStore.getRandomAsset() | ||||
|         : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset; | ||||
|     if (asset) { | ||||
|       setAsset(asset); | ||||
|       $slideshowState = SlideshowState.PlaySlideshow; | ||||
|       handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow))); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|  | ||||
| @ -9,16 +9,16 @@ | ||||
|   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; | ||||
|   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   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 type { PageData } from './$types'; | ||||
|   import { mdiPlus, mdiDotsVertical } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { mdiDotsVertical, mdiPlus } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -29,7 +29,7 @@ | ||||
|   void assetStore.updateOptions({ isArchived: true }); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   const handleEscape = () => { | ||||
|     if (assetInteraction.selectionActive) { | ||||
|  | ||||
| @ -9,19 +9,19 @@ | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   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 { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { mdiDotsVertical, mdiPlus } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   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'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import { mdiDotsVertical, mdiPlus } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -30,10 +30,10 @@ | ||||
|   let { data }: Props = $props(); | ||||
| 
 | ||||
|   const assetStore = new AssetStore(); | ||||
|   void assetStore.updateOptions({ isFavorite: true }); | ||||
|   void assetStore.updateOptions({ isFavorite: true, withStacked: true }); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   const handleEscape = () => { | ||||
|     if (assetInteraction.selectionActive) { | ||||
| @ -76,6 +76,7 @@ | ||||
| <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> | ||||
|   <AssetGrid | ||||
|     enableRouting={true} | ||||
|     withStacked={true} | ||||
|     {assetStore} | ||||
|     {assetInteraction} | ||||
|     removeAction={AssetAction.UNFAVORITE} | ||||
|  | ||||
| @ -1,37 +1,38 @@ | ||||
| <script lang="ts"> | ||||
|   import { afterNavigate, goto, invalidateAll } from '$app/navigation'; | ||||
|   import { page } from '$app/stores'; | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; | ||||
|   import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; | ||||
|   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; | ||||
|   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; | ||||
|   import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; | ||||
|   import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; | ||||
|   import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; | ||||
|   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; | ||||
|   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
|   import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; | ||||
|   import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; | ||||
|   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, QueryParameter } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import type { Viewport } from '$lib/stores/assets-store.svelte'; | ||||
|   import { foldersStore } from '$lib/stores/folders.svelte'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; | ||||
|   import type { AssetResponseDto } from '@immich/sdk'; | ||||
|   import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   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'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; | ||||
|   import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; | ||||
|   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; | ||||
|   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import { cancelMultiselect } from '$lib/utils/asset-utils'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; | ||||
|   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; | ||||
|   import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; | ||||
|   import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -46,7 +47,7 @@ | ||||
|   let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); | ||||
|   let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<AssetResponseDto>(); | ||||
| 
 | ||||
|   onMount(async () => { | ||||
|     await foldersStore.fetchUniquePaths(); | ||||
|  | ||||
| @ -4,16 +4,16 @@ | ||||
|   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; | ||||
|   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; | ||||
|   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   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'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { mdiArrowLeft, mdiPlus } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -24,7 +24,7 @@ | ||||
|   const assetStore = new AssetStore(); | ||||
|   $effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true })); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   const handleEscape = () => { | ||||
|     if (assetInteraction.selectionActive) { | ||||
|  | ||||
| @ -33,20 +33,14 @@ | ||||
|   import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { preferences } from '$lib/stores/user.store'; | ||||
|   import { websocketEvents } from '$lib/stores/websocket'; | ||||
|   import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { isExternalUrl } from '$lib/utils/navigation'; | ||||
|   import { | ||||
|     getPersonStatistics, | ||||
|     mergePerson, | ||||
|     searchPerson, | ||||
|     updatePerson, | ||||
|     type AssetResponseDto, | ||||
|     type PersonResponseDto, | ||||
|   } from '@immich/sdk'; | ||||
|   import { getPersonStatistics, mergePerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; | ||||
|   import { | ||||
|     mdiAccountBoxOutline, | ||||
|     mdiAccountMultipleCheckOutline, | ||||
| @ -59,11 +53,10 @@ | ||||
|     mdiHeartOutline, | ||||
|     mdiPlus, | ||||
|   } from '@mdi/js'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   import { onDestroy, onMount } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { DateTime } from 'luxon'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -78,7 +71,7 @@ | ||||
|   $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id })); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); | ||||
|   let isEditingName = $state(false); | ||||
| @ -202,7 +195,7 @@ | ||||
|     data = { ...data, person }; | ||||
|   }; | ||||
| 
 | ||||
|   const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { | ||||
|   const handleSelectFeaturePhoto = async (asset: TimelineAsset) => { | ||||
|     if (viewMode !== PersonPageViewMode.SELECT_PERSON) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @ -22,7 +22,7 @@ | ||||
|   import { AssetAction } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; | ||||
|   import { preferences, user } from '$lib/stores/user.store'; | ||||
|   import { | ||||
| @ -32,7 +32,7 @@ | ||||
|     type OnUnlink, | ||||
|   } from '$lib/utils/actions'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { AssetTypeEnum } from '@immich/sdk'; | ||||
| 
 | ||||
|   import { mdiDotsVertical, mdiPlus } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
| @ -42,7 +42,7 @@ | ||||
|   void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true }); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   let selectedAssets = $derived(assetInteraction.selectedAssets); | ||||
|   let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); | ||||
| @ -50,8 +50,8 @@ | ||||
|     const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; | ||||
|     const isLivePhotoCandidate = | ||||
|       selectedAssets.length === 2 && | ||||
|       selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && | ||||
|       selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); | ||||
|       selectedAssets.some((asset) => asset.isImage) && | ||||
|       selectedAssets.some((asset) => asset.isVideo); | ||||
| 
 | ||||
|     return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); | ||||
|   }); | ||||
|  | ||||
| @ -63,7 +63,7 @@ | ||||
|   let scrollY = $state(0); | ||||
|   let scrollYHistory = 0; | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<AssetResponseDto>(); | ||||
| 
 | ||||
|   type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; | ||||
|   let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); | ||||
|  | ||||
| @ -17,14 +17,14 @@ | ||||
|   import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; | ||||
|   import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; | ||||
|   import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; | ||||
|   import { Button, HStack, Text } from '@immich/ui'; | ||||
|   import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
| 
 | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
| @ -35,7 +35,7 @@ | ||||
|   let pathSegments = $derived(data.path ? data.path.split('/') : []); | ||||
|   let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   const buildMap = (tags: TagResponseDto[]) => { | ||||
|     return Object.fromEntries(tags.map((tag) => [tag.value, tag])); | ||||
|  | ||||
| @ -15,7 +15,7 @@ | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; | ||||
|   import { AssetStore } from '$lib/stores/assets-store.svelte'; | ||||
|   import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
|   import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; | ||||
|   import { handlePromiseError } from '$lib/utils'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
| @ -40,7 +40,7 @@ | ||||
|   void assetStore.updateOptions({ isTrashed: true }); | ||||
|   onDestroy(() => assetStore.destroy()); | ||||
| 
 | ||||
|   const assetInteraction = new AssetInteraction(); | ||||
|   const assetInteraction = new AssetInteraction<TimelineAsset>(); | ||||
| 
 | ||||
|   const handleEmptyTrash = async () => { | ||||
|     const isConfirmed = await dialogController.show({ | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import { faker } from '@faker-js/faker'; | ||||
| import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { Sync } from 'factory.ts'; | ||||
| @ -25,3 +26,20 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({ | ||||
|   isOffline: Sync.each(() => faker.datatype.boolean()), | ||||
|   hasMetadata: Sync.each(() => faker.datatype.boolean()), | ||||
| }); | ||||
| 
 | ||||
| export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({ | ||||
|   id: Sync.each(() => faker.string.uuid()), | ||||
|   ratio: Sync.each(() => faker.number.int()), | ||||
|   ownerId: Sync.each(() => faker.string.uuid()), | ||||
|   thumbhash: Sync.each(() => faker.string.alphanumeric(28)), | ||||
|   localDateTime: Sync.each(() => faker.date.past().toISOString()), | ||||
|   isFavorite: Sync.each(() => faker.datatype.boolean()), | ||||
|   isArchived: false, | ||||
|   isTrashed: false, | ||||
|   isImage: true, | ||||
|   isVideo: false, | ||||
|   duration: '0:00:00.00000', | ||||
|   stack: null, | ||||
|   projectionType: null, | ||||
|   livePhotoVideoId: Sync.each(() => faker.string.uuid()), | ||||
| }); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user