mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(web): asset store (#3528)
* refactor(web): asset store * chore: remove TODO
This commit is contained in:
		| @@ -1,19 +1,19 @@ | ||||
| <script lang="ts"> | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import type { AssetResponseDto } from '@api'; | ||||
|   import { TimeGroupEnum, type AssetResponseDto } from '@api'; | ||||
|   import { createEventDispatcher, onMount } from 'svelte'; | ||||
|   import { quintOut } from 'svelte/easing'; | ||||
|   import { fly } from 'svelte/transition'; | ||||
|   import Button from '../elements/buttons/button.svelte'; | ||||
|   import AssetGrid from '../photos-page/asset-grid.svelte'; | ||||
|   import ControlAppBar from '../shared-components/control-app-bar.svelte'; | ||||
|   import { createAssetStore } from '$lib/stores/assets.store'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|  | ||||
|   const dispatch = createEventDispatcher(); | ||||
|  | ||||
|   const assetStore = createAssetStore(); | ||||
|   const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { selectedAssets, assetsInAlbumState } = assetInteractionStore; | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   import { BucketPosition } from '$lib/models/asset-grid-state'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|   import { BucketPosition } from '$lib/stores/assets.store'; | ||||
|   import { createEventDispatcher, onMount } from 'svelte'; | ||||
|  | ||||
|   export let once = false; | ||||
|   export let top = 0; | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| <script lang="ts"> | ||||
|   import { get } from 'svelte/store'; | ||||
|   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { BucketPosition, type AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import SelectAll from 'svelte-material-icons/SelectAll.svelte'; | ||||
|   import TimerSand from 'svelte-material-icons/TimerSand.svelte'; | ||||
|   import { handleError } from '../../../utils/handle-error'; | ||||
|   import { BucketPosition } from '$lib/models/asset-grid-state'; | ||||
|   import type { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { get } from 'svelte/store'; | ||||
|  | ||||
|   export let assetStore: AssetStore; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
|   | ||||
| @@ -13,12 +13,13 @@ | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import type { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import type { Viewport } from '$lib/stores/assets.store'; | ||||
|  | ||||
|   export let assets: AssetResponseDto[]; | ||||
|   export let bucketDate: string; | ||||
|   export let bucketHeight: number; | ||||
|   export let isAlbumSelectionMode = false; | ||||
|   export let viewportWidth: number; | ||||
|   export let viewport: Viewport; | ||||
|  | ||||
|   export let assetStore: AssetStore; | ||||
|   export let assetInteractionStore: AssetInteractionStore; | ||||
| @@ -45,7 +46,7 @@ | ||||
|     for (let group of assetsGroupByDate) { | ||||
|       const justifiedLayoutResult = justifiedLayout(group.map(getAssetRatio), { | ||||
|         boxSpacing: 2, | ||||
|         containerWidth: Math.floor(viewportWidth), | ||||
|         containerWidth: Math.floor(viewport.width), | ||||
|         containerPadding: 0, | ||||
|         targetRowHeightTolerance: 0.15, | ||||
|         targetRowHeight: 235, | ||||
| @@ -59,7 +60,7 @@ | ||||
|   })(); | ||||
|  | ||||
|   $: { | ||||
|     if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) { | ||||
|     if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) { | ||||
|       const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight); | ||||
|       if (heightDelta !== 0) { | ||||
|         scrollTimeline(heightDelta); | ||||
| @@ -143,12 +144,7 @@ | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <section | ||||
|   id="asset-group-by-date" | ||||
|   class="flex flex-wrap gap-x-12" | ||||
|   bind:clientHeight={actualBucketHeight} | ||||
|   bind:clientWidth={viewportWidth} | ||||
| > | ||||
| <section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}> | ||||
|   {#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)} | ||||
|     {@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))} | ||||
|     <!-- Asset Group By Date --> | ||||
|   | ||||
| @@ -1,10 +1,8 @@ | ||||
| <script lang="ts"> | ||||
|   import { BucketPosition } from '$lib/models/asset-grid-state'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; | ||||
|   import type { UserResponseDto } from '@api'; | ||||
|   import { AssetResponseDto, TimeGroupEnum, api } from '@api'; | ||||
|   import type { AssetResponseDto } from '@api'; | ||||
|   import { DateTime } from 'luxon'; | ||||
|   import { onDestroy, onMount } from 'svelte'; | ||||
|   import AssetViewer from '../asset-viewer/asset-viewer.svelte'; | ||||
| @@ -21,11 +19,10 @@ | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import type { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store'; | ||||
|   import { isSearchEnabled } from '$lib/stores/search.store'; | ||||
|   import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; | ||||
|  | ||||
|   export let user: UserResponseDto | undefined = undefined; | ||||
|   export let isAlbumSelectionMode = false; | ||||
|   export let showMemoryLane = false; | ||||
|  | ||||
| @@ -36,8 +33,7 @@ | ||||
|  | ||||
|   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore; | ||||
|  | ||||
|   let viewportHeight = 0; | ||||
|   let viewportWidth = 0; | ||||
|   const viewport: Viewport = { width: 0, height: 0 }; | ||||
|   let assetGridElement: HTMLElement; | ||||
|   let showShortcuts = false; | ||||
|  | ||||
| @@ -45,23 +41,13 @@ | ||||
|  | ||||
|   onMount(async () => { | ||||
|     document.addEventListener('keydown', onKeyboardPress); | ||||
|     const { data: timeBuckets } = await api.assetApi.getAssetCountByTimeBucket({ | ||||
|       getAssetCountByTimeBucketDto: { | ||||
|         timeGroup: TimeGroupEnum.Month, | ||||
|         userId: user?.id, | ||||
|         withoutThumbs: true, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     assetStore.init({ width: viewportHeight, height: viewportWidth }, timeBuckets.buckets, user?.id); | ||||
|     await assetStore.init(viewport); | ||||
|   }); | ||||
|  | ||||
|   onDestroy(() => { | ||||
|     if (browser) { | ||||
|       document.removeEventListener('keydown', onKeyboardPress); | ||||
|     } | ||||
|  | ||||
|     assetStore.init({ width: 0, height: 0 }, [], undefined); | ||||
|   }); | ||||
|  | ||||
|   const handleKeyboardPress = (event: KeyboardEvent) => { | ||||
| @@ -292,10 +278,10 @@ | ||||
|   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} /> | ||||
| {/if} | ||||
|  | ||||
| {#if viewportHeight && $assetStore.initialized && $assetStore.timelineHeight > viewportHeight} | ||||
| {#if $assetStore.timelineHeight > viewport.height} | ||||
|   <Scrollbar | ||||
|     {assetStore} | ||||
|     scrollbarHeight={viewportHeight} | ||||
|     scrollbarHeight={viewport.height} | ||||
|     scrollTop={lastScrollPosition} | ||||
|     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)} | ||||
|     on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)} | ||||
| @@ -306,8 +292,8 @@ | ||||
| <section | ||||
|   id="asset-grid" | ||||
|   class="scrollbar-hidden mb-4 ml-4 mr-[60px] overflow-y-auto" | ||||
|   bind:clientHeight={viewportHeight} | ||||
|   bind:clientWidth={viewportWidth} | ||||
|   bind:clientHeight={viewport.height} | ||||
|   bind:clientWidth={viewport.width} | ||||
|   bind:this={assetGridElement} | ||||
|   on:scroll={handleTimelineScroll} | ||||
| > | ||||
| @@ -337,7 +323,7 @@ | ||||
|                 assets={bucket.assets} | ||||
|                 bucketDate={bucket.bucketDate} | ||||
|                 bucketHeight={bucket.bucketHeight} | ||||
|                 {viewportWidth} | ||||
|                 {viewport} | ||||
|               /> | ||||
|             {/if} | ||||
|           </div> | ||||
|   | ||||
| @@ -1,248 +0,0 @@ | ||||
| import { api, AssetCountByTimeBucket, AssetResponseDto } from '@api'; | ||||
| import { writable } from 'svelte/store'; | ||||
| import type { AssetStore } from '../stores/assets.store'; | ||||
| import { handleError } from '../utils/handle-error'; | ||||
|  | ||||
| export enum BucketPosition { | ||||
|   Above = 'above', | ||||
|   Below = 'below', | ||||
|   Visible = 'visible', | ||||
|   Unknown = 'unknown', | ||||
| } | ||||
|  | ||||
| export interface Viewport { | ||||
|   width: number; | ||||
|   height: number; | ||||
| } | ||||
|  | ||||
| interface AssetLookup { | ||||
|   bucket: AssetBucket; | ||||
|   bucketIndex: number; | ||||
|   assetIndex: number; | ||||
| } | ||||
|  | ||||
| export class AssetBucket { | ||||
|   /** | ||||
|    * The DOM height of the bucket in pixel | ||||
|    * This value is first estimated by the number of asset and later is corrected as the user scroll | ||||
|    */ | ||||
|   bucketHeight!: number; | ||||
|   bucketDate!: string; | ||||
|   assets!: AssetResponseDto[]; | ||||
|   cancelToken!: AbortController | null; | ||||
|   position!: BucketPosition; | ||||
| } | ||||
|  | ||||
| const THUMBNAIL_HEIGHT = 235; | ||||
|  | ||||
| export class AssetGridState implements AssetStore { | ||||
|   private store$ = writable(this); | ||||
|   private assetToBucket: Record<string, AssetLookup> = {}; | ||||
|   private viewport: Viewport = { width: 0, height: 0 }; | ||||
|   private userId: string | undefined; | ||||
|  | ||||
|   initialized = false; | ||||
|   timelineHeight = 0; | ||||
|   buckets: AssetBucket[] = []; | ||||
|   assets: AssetResponseDto[] = []; | ||||
|  | ||||
|   subscribe = this.store$.subscribe; | ||||
|  | ||||
|   init(viewport: Viewport, buckets: AssetCountByTimeBucket[], userId: string | undefined) { | ||||
|     this.initialized = false; | ||||
|     this.assets = []; | ||||
|     this.assetToBucket = {}; | ||||
|     this.buckets = []; | ||||
|     this.viewport = viewport; | ||||
|     this.userId = userId; | ||||
|     this.buckets = buckets.map((bucket) => { | ||||
|       const unwrappedWidth = (3 / 2) * bucket.count * THUMBNAIL_HEIGHT * (7 / 10); | ||||
|       const rows = Math.ceil(unwrappedWidth / this.viewport.width); | ||||
|       const height = rows * THUMBNAIL_HEIGHT; | ||||
|  | ||||
|       return { | ||||
|         bucketDate: bucket.timeBucket, | ||||
|         bucketHeight: height, | ||||
|         assets: [], | ||||
|         cancelToken: null, | ||||
|         position: BucketPosition.Unknown, | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     this.timelineHeight = this.buckets.reduce((acc, b) => acc + b.bucketHeight, 0); | ||||
|  | ||||
|     this.emit(false); | ||||
|  | ||||
|     let height = 0; | ||||
|     for (const bucket of this.buckets) { | ||||
|       if (height < this.viewport.height) { | ||||
|         height += bucket.bucketHeight; | ||||
|         this.loadBucket(bucket.bucketDate, BucketPosition.Visible); | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       break; | ||||
|     } | ||||
|  | ||||
|     this.initialized = true; | ||||
|   } | ||||
|  | ||||
|   getBucketByDate(bucketDate: string): AssetBucket | null { | ||||
|     return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; | ||||
|   } | ||||
|  | ||||
|   getBucketInfoForAssetId(assetId: string) { | ||||
|     return this.assetToBucket[assetId] || null; | ||||
|   } | ||||
|  | ||||
|   getBucketIndexByAssetId(assetId: string) { | ||||
|     return this.assetToBucket[assetId]?.bucketIndex ?? null; | ||||
|   } | ||||
|  | ||||
|   async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> { | ||||
|     try { | ||||
|       const bucket = this.getBucketByDate(bucketDate); | ||||
|       if (!bucket) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       bucket.position = position; | ||||
|  | ||||
|       if (bucket.assets.length !== 0) { | ||||
|         this.emit(false); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       bucket.cancelToken = new AbortController(); | ||||
|  | ||||
|       const { data: assets } = await api.assetApi.getAssetByTimeBucket( | ||||
|         { | ||||
|           getAssetByTimeBucketDto: { | ||||
|             timeBucket: [bucketDate], | ||||
|             userId: this.userId, | ||||
|             withoutThumbs: true, | ||||
|           }, | ||||
|         }, | ||||
|         { signal: bucket.cancelToken.signal }, | ||||
|       ); | ||||
|  | ||||
|       bucket.assets = assets; | ||||
|       this.emit(true); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Failed to load assets'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   cancelBucket(bucket: AssetBucket) { | ||||
|     bucket.cancelToken?.abort(); | ||||
|   } | ||||
|  | ||||
|   updateBucket(bucketDate: string, height: number) { | ||||
|     const bucket = this.getBucketByDate(bucketDate); | ||||
|     if (!bucket) { | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     const delta = height - bucket.bucketHeight; | ||||
|     const scrollTimeline = bucket.position == BucketPosition.Above; | ||||
|  | ||||
|     bucket.bucketHeight = height; | ||||
|     bucket.position = BucketPosition.Unknown; | ||||
|  | ||||
|     this.timelineHeight += delta; | ||||
|  | ||||
|     this.emit(false); | ||||
|  | ||||
|     return scrollTimeline ? delta : 0; | ||||
|   } | ||||
|  | ||||
|   updateAsset(assetId: string, isFavorite: boolean) { | ||||
|     const asset = this.assets.find((asset) => asset.id === assetId); | ||||
|     if (!asset) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     asset.isFavorite = isFavorite; | ||||
|     this.emit(false); | ||||
|   } | ||||
|  | ||||
|   removeAsset(assetId: string) { | ||||
|     for (let i = 0; i < this.buckets.length; i++) { | ||||
|       const bucket = this.buckets[i]; | ||||
|       for (let j = 0; j < bucket.assets.length; j++) { | ||||
|         const asset = bucket.assets[j]; | ||||
|         if (asset.id !== assetId) { | ||||
|           continue; | ||||
|         } | ||||
|  | ||||
|         bucket.assets.splice(j, 1); | ||||
|         if (bucket.assets.length === 0) { | ||||
|           this.buckets.splice(i, 1); | ||||
|         } | ||||
|  | ||||
|         this.emit(true); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async getPreviousAssetId(assetId: string): Promise<string | null> { | ||||
|     const info = this.getBucketInfoForAssetId(assetId); | ||||
|     if (!info) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const { bucket, assetIndex, bucketIndex } = info; | ||||
|  | ||||
|     if (assetIndex !== 0) { | ||||
|       return bucket.assets[assetIndex - 1].id; | ||||
|     } | ||||
|  | ||||
|     if (bucketIndex === 0) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const previousBucket = this.buckets[bucketIndex - 1]; | ||||
|     await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); | ||||
|     return previousBucket.assets.at(-1)?.id || null; | ||||
|   } | ||||
|  | ||||
|   async getNextAssetId(assetId: string): Promise<string | null> { | ||||
|     const info = this.getBucketInfoForAssetId(assetId); | ||||
|     if (!info) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const { bucket, assetIndex, bucketIndex } = info; | ||||
|  | ||||
|     if (assetIndex !== bucket.assets.length - 1) { | ||||
|       return bucket.assets[assetIndex + 1].id; | ||||
|     } | ||||
|  | ||||
|     if (bucketIndex === this.buckets.length - 1) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const nextBucket = this.buckets[bucketIndex + 1]; | ||||
|     await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); | ||||
|     return nextBucket.assets[0]?.id || null; | ||||
|   } | ||||
|  | ||||
|   private emit(recalculate: boolean) { | ||||
|     if (recalculate) { | ||||
|       this.assets = this.buckets.flatMap(({ assets }) => assets); | ||||
|  | ||||
|       const assetToBucket: Record<string, AssetLookup> = {}; | ||||
|       for (let i = 0; i < this.buckets.length; i++) { | ||||
|         const bucket = this.buckets[i]; | ||||
|         for (let j = 0; j < bucket.assets.length; j++) { | ||||
|           const asset = bucket.assets[j]; | ||||
|           assetToBucket[asset.id] = { bucket, bucketIndex: i, assetIndex: j }; | ||||
|         } | ||||
|       } | ||||
|       this.assetToBucket = assetToBucket; | ||||
|     } | ||||
|  | ||||
|     this.store$.update(() => this); | ||||
|   } | ||||
| } | ||||
| @@ -1,38 +1,246 @@ | ||||
| import { AssetBucket, AssetGridState, BucketPosition, Viewport } from '$lib/models/asset-grid-state'; | ||||
| import type { AssetCountByTimeBucket } from '@api'; | ||||
| import { api, AssetResponseDto, GetAssetCountByTimeBucketDto } from '@api'; | ||||
| import { writable } from 'svelte/store'; | ||||
| import { handleError } from '../utils/handle-error'; | ||||
|  | ||||
| export interface AssetStore { | ||||
|   init: (viewport: Viewport, data: AssetCountByTimeBucket[], userId: string | undefined) => void; | ||||
|  | ||||
|   // bucket | ||||
|   loadBucket: (bucket: string, position: BucketPosition) => Promise<void>; | ||||
|   updateBucket: (bucket: string, actualBucketHeight: number) => number; | ||||
|   cancelBucket: (bucket: AssetBucket) => void; | ||||
|  | ||||
|   // asset | ||||
|   removeAsset: (assetId: string) => void; | ||||
|   updateAsset: (assetId: string, isFavorite: boolean) => void; | ||||
|  | ||||
|   // asset navigation | ||||
|   getNextAssetId: (assetId: string) => Promise<string | null>; | ||||
|   getPreviousAssetId: (assetId: string) => Promise<string | null>; | ||||
|  | ||||
|   // store | ||||
|   subscribe: (run: (value: AssetGridState) => void, invalidate?: (value?: AssetGridState) => void) => () => void; | ||||
| export enum BucketPosition { | ||||
|   Above = 'above', | ||||
|   Below = 'below', | ||||
|   Visible = 'visible', | ||||
|   Unknown = 'unknown', | ||||
| } | ||||
|  | ||||
| export function createAssetStore(): AssetStore { | ||||
|   const store = new AssetGridState(); | ||||
| export type AssetStoreOptions = GetAssetCountByTimeBucketDto; | ||||
|  | ||||
| export interface Viewport { | ||||
|   width: number; | ||||
|   height: number; | ||||
| } | ||||
|  | ||||
| interface AssetLookup { | ||||
|   bucket: AssetBucket; | ||||
|   bucketIndex: number; | ||||
|   assetIndex: number; | ||||
| } | ||||
|  | ||||
| export class AssetBucket { | ||||
|   /** | ||||
|    * The DOM height of the bucket in pixel | ||||
|    * This value is first estimated by the number of asset and later is corrected as the user scroll | ||||
|    */ | ||||
|   bucketHeight!: number; | ||||
|   bucketDate!: string; | ||||
|   assets!: AssetResponseDto[]; | ||||
|   cancelToken!: AbortController | null; | ||||
|   position!: BucketPosition; | ||||
| } | ||||
|  | ||||
| const THUMBNAIL_HEIGHT = 235; | ||||
|  | ||||
| export class AssetStore { | ||||
|   private store$ = writable(this); | ||||
|   private assetToBucket: Record<string, AssetLookup> = {}; | ||||
|  | ||||
|   timelineHeight = 0; | ||||
|   buckets: AssetBucket[] = []; | ||||
|   assets: AssetResponseDto[] = []; | ||||
|  | ||||
|   constructor(private options: AssetStoreOptions) { | ||||
|     this.store$.set(this); | ||||
|   } | ||||
|  | ||||
|   subscribe = this.store$.subscribe; | ||||
|  | ||||
|   async init(viewport: Viewport) { | ||||
|     const { data } = await api.assetApi.getAssetCountByTimeBucket({ | ||||
|       getAssetCountByTimeBucketDto: { ...this.options, withoutThumbs: true }, | ||||
|     }); | ||||
|  | ||||
|     this.buckets = data.buckets.map((bucket) => { | ||||
|       const unwrappedWidth = (3 / 2) * bucket.count * THUMBNAIL_HEIGHT * (7 / 10); | ||||
|       const rows = Math.ceil(unwrappedWidth / viewport.width); | ||||
|       const height = rows * THUMBNAIL_HEIGHT; | ||||
|  | ||||
|       return { | ||||
|     init: store.init.bind(store), | ||||
|     loadBucket: store.loadBucket.bind(store), | ||||
|     updateBucket: store.updateBucket.bind(store), | ||||
|     cancelBucket: store.cancelBucket.bind(store), | ||||
|     removeAsset: store.removeAsset.bind(store), | ||||
|     updateAsset: store.updateAsset.bind(store), | ||||
|     getNextAssetId: store.getNextAssetId.bind(store), | ||||
|     getPreviousAssetId: store.getPreviousAssetId.bind(store), | ||||
|     subscribe: store.subscribe, | ||||
|         bucketDate: bucket.timeBucket, | ||||
|         bucketHeight: height, | ||||
|         assets: [], | ||||
|         cancelToken: null, | ||||
|         position: BucketPosition.Unknown, | ||||
|       }; | ||||
|     }); | ||||
|  | ||||
|     this.timelineHeight = this.buckets.reduce((acc, b) => acc + b.bucketHeight, 0); | ||||
|  | ||||
|     this.emit(false); | ||||
|  | ||||
|     let height = 0; | ||||
|     for (const bucket of this.buckets) { | ||||
|       if (height < viewport.height) { | ||||
|         height += bucket.bucketHeight; | ||||
|         this.loadBucket(bucket.bucketDate, BucketPosition.Visible); | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> { | ||||
|     try { | ||||
|       const bucket = this.getBucketByDate(bucketDate); | ||||
|       if (!bucket) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       bucket.position = position; | ||||
|  | ||||
|       if (bucket.assets.length !== 0) { | ||||
|         this.emit(false); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       bucket.cancelToken = new AbortController(); | ||||
|  | ||||
|       const { data: assets } = await api.assetApi.getAssetByTimeBucket( | ||||
|         { | ||||
|           getAssetByTimeBucketDto: { | ||||
|             timeBucket: [bucketDate], | ||||
|             ...this.options, | ||||
|             withoutThumbs: true, | ||||
|           }, | ||||
|         }, | ||||
|         { signal: bucket.cancelToken.signal }, | ||||
|       ); | ||||
|  | ||||
|       bucket.assets = assets; | ||||
|       this.emit(true); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Failed to load assets'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   cancelBucket(bucket: AssetBucket) { | ||||
|     bucket.cancelToken?.abort(); | ||||
|   } | ||||
|  | ||||
|   updateBucket(bucketDate: string, height: number) { | ||||
|     const bucket = this.getBucketByDate(bucketDate); | ||||
|     if (!bucket) { | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     const delta = height - bucket.bucketHeight; | ||||
|     const scrollTimeline = bucket.position == BucketPosition.Above; | ||||
|  | ||||
|     bucket.bucketHeight = height; | ||||
|     bucket.position = BucketPosition.Unknown; | ||||
|  | ||||
|     this.timelineHeight += delta; | ||||
|  | ||||
|     this.emit(false); | ||||
|  | ||||
|     return scrollTimeline ? delta : 0; | ||||
|   } | ||||
|  | ||||
|   getBucketByDate(bucketDate: string): AssetBucket | null { | ||||
|     return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; | ||||
|   } | ||||
|  | ||||
|   getBucketInfoForAssetId(assetId: string) { | ||||
|     return this.assetToBucket[assetId] || null; | ||||
|   } | ||||
|  | ||||
|   getBucketIndexByAssetId(assetId: string) { | ||||
|     return this.assetToBucket[assetId]?.bucketIndex ?? null; | ||||
|   } | ||||
|  | ||||
|   updateAsset(assetId: string, isFavorite: boolean) { | ||||
|     const asset = this.assets.find((asset) => asset.id === assetId); | ||||
|     if (!asset) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     asset.isFavorite = isFavorite; | ||||
|     this.emit(false); | ||||
|   } | ||||
|  | ||||
|   removeAsset(assetId: string) { | ||||
|     for (let i = 0; i < this.buckets.length; i++) { | ||||
|       const bucket = this.buckets[i]; | ||||
|       for (let j = 0; j < bucket.assets.length; j++) { | ||||
|         const asset = bucket.assets[j]; | ||||
|         if (asset.id !== assetId) { | ||||
|           continue; | ||||
|         } | ||||
|  | ||||
|         bucket.assets.splice(j, 1); | ||||
|         if (bucket.assets.length === 0) { | ||||
|           this.buckets.splice(i, 1); | ||||
|         } | ||||
|  | ||||
|         this.emit(true); | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async getPreviousAssetId(assetId: string): Promise<string | null> { | ||||
|     const info = this.getBucketInfoForAssetId(assetId); | ||||
|     if (!info) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const { bucket, assetIndex, bucketIndex } = info; | ||||
|  | ||||
|     if (assetIndex !== 0) { | ||||
|       return bucket.assets[assetIndex - 1].id; | ||||
|     } | ||||
|  | ||||
|     if (bucketIndex === 0) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const previousBucket = this.buckets[bucketIndex - 1]; | ||||
|     await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); | ||||
|     return previousBucket.assets.at(-1)?.id || null; | ||||
|   } | ||||
|  | ||||
|   async getNextAssetId(assetId: string): Promise<string | null> { | ||||
|     const info = this.getBucketInfoForAssetId(assetId); | ||||
|     if (!info) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const { bucket, assetIndex, bucketIndex } = info; | ||||
|  | ||||
|     if (assetIndex !== bucket.assets.length - 1) { | ||||
|       return bucket.assets[assetIndex + 1].id; | ||||
|     } | ||||
|  | ||||
|     if (bucketIndex === this.buckets.length - 1) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const nextBucket = this.buckets[bucketIndex + 1]; | ||||
|     await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); | ||||
|     return nextBucket.assets[0]?.id || null; | ||||
|   } | ||||
|  | ||||
|   private emit(recalculate: boolean) { | ||||
|     if (recalculate) { | ||||
|       this.assets = this.buckets.flatMap(({ assets }) => assets); | ||||
|  | ||||
|       const assetToBucket: Record<string, AssetLookup> = {}; | ||||
|       for (let i = 0; i < this.buckets.length; i++) { | ||||
|         const bucket = this.buckets[i]; | ||||
|         for (let j = 0; j < bucket.assets.length; j++) { | ||||
|           const asset = bucket.assets[j]; | ||||
|           assetToBucket[asset.id] = { bucket, bucketIndex: i, assetIndex: j }; | ||||
|         } | ||||
|       } | ||||
|       this.assetToBucket = assetToBucket; | ||||
|     } | ||||
|  | ||||
|     this.store$.update(() => this); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,16 +8,17 @@ | ||||
|   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; | ||||
|   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { TimeGroupEnum } from '@api'; | ||||
|   import { onDestroy } from 'svelte'; | ||||
|   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; | ||||
|   import Plus from 'svelte-material-icons/Plus.svelte'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { createAssetStore } from '$lib/stores/assets.store'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|  | ||||
|   const assetStore = createAssetStore(); | ||||
|   const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month, userId: data.partner.id }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|  | ||||
| @@ -39,12 +40,12 @@ | ||||
|   {:else} | ||||
|     <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(AppRoute.SHARING)}> | ||||
|       <svelte:fragment slot="leading"> | ||||
|         <p class="text-immich-fg dark:text-immich-dark-fg"> | ||||
|         <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg"> | ||||
|           {data.partner.firstName} | ||||
|           {data.partner.lastName}'s photos | ||||
|         </p> | ||||
|       </svelte:fragment> | ||||
|     </ControlAppBar> | ||||
|   {/if} | ||||
|   <AssetGrid {assetStore} {assetInteractionStore} user={data.partner} /> | ||||
|   <AssetGrid {assetStore} {assetInteractionStore} /> | ||||
| </main> | ||||
|   | ||||
| @@ -11,10 +11,10 @@ | ||||
|   import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-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 { createAssetStore } from '$lib/stores/assets.store'; | ||||
|   import { AssetStore } from '$lib/stores/assets.store'; | ||||
|   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; | ||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||
|   import { api } from '@api'; | ||||
|   import { TimeGroupEnum, api } from '@api'; | ||||
|   import { onDestroy, onMount } from 'svelte'; | ||||
|   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; | ||||
|   import Plus from 'svelte-material-icons/Plus.svelte'; | ||||
| @@ -23,7 +23,7 @@ | ||||
|   export let data: PageData; | ||||
|   let assetCount = 1; | ||||
|  | ||||
|   const assetStore = createAssetStore(); | ||||
|   const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month }); | ||||
|   const assetInteractionStore = createAssetInteractionStore(); | ||||
|   const { isMultiSelectState, selectedAssets } = assetInteractionStore; | ||||
|  | ||||
| @@ -53,7 +53,7 @@ | ||||
|           <AddToAlbum /> | ||||
|           <AddToAlbum shared /> | ||||
|         </AssetSelectContextMenu> | ||||
|         <DeleteAssets onAssetDelete={assetStore.removeAsset} /> | ||||
|         <DeleteAssets onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} /> | ||||
|         <AssetSelectContextMenu icon={DotsVertical} title="Menu"> | ||||
|           <FavoriteAction menuItem removeFavorite={isAllFavorite} /> | ||||
|           <DownloadAction menuItem /> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user