浏览代码

refactor(web): asset store (#3528)

* refactor(web): asset store

* chore: remove TODO
Jason Rasmussen 1 年之前
父节点
当前提交
5617b57b26

+ 4 - 4
web/src/lib/components/album-page/asset-selection.svelte

@@ -1,19 +1,19 @@
 <script lang="ts">
 <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 { locale } from '$lib/stores/preferences.store';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
-  import type { AssetResponseDto } from '@api';
+  import { TimeGroupEnum, type AssetResponseDto } from '@api';
   import { createEventDispatcher, onMount } from 'svelte';
   import { createEventDispatcher, onMount } from 'svelte';
   import { quintOut } from 'svelte/easing';
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import { fly } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
   import Button from '../elements/buttons/button.svelte';
   import AssetGrid from '../photos-page/asset-grid.svelte';
   import AssetGrid from '../photos-page/asset-grid.svelte';
   import ControlAppBar from '../shared-components/control-app-bar.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 dispatch = createEventDispatcher();
 
 
-  const assetStore = createAssetStore();
+  const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month });
   const assetInteractionStore = createAssetInteractionStore();
   const assetInteractionStore = createAssetInteractionStore();
   const { selectedAssets, assetsInAlbumState } = assetInteractionStore;
   const { selectedAssets, assetsInAlbumState } = assetInteractionStore;
 
 

+ 2 - 3
web/src/lib/components/asset-viewer/intersection-observer.svelte

@@ -1,7 +1,6 @@
 <script lang="ts">
 <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 once = false;
   export let top = 0;
   export let top = 0;

+ 4 - 5
web/src/lib/components/photos-page/actions/select-all-assets.svelte

@@ -1,12 +1,11 @@
 <script lang="ts">
 <script lang="ts">
-  import { get } from 'svelte/store';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   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 SelectAll from 'svelte-material-icons/SelectAll.svelte';
   import TimerSand from 'svelte-material-icons/TimerSand.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 assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
   export let assetInteractionStore: AssetInteractionStore;

+ 5 - 9
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -13,12 +13,13 @@
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import type { AssetStore } from '$lib/stores/assets.store';
   import type { AssetStore } from '$lib/stores/assets.store';
   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
+  import type { Viewport } from '$lib/stores/assets.store';
 
 
   export let assets: AssetResponseDto[];
   export let assets: AssetResponseDto[];
   export let bucketDate: string;
   export let bucketDate: string;
   export let bucketHeight: number;
   export let bucketHeight: number;
   export let isAlbumSelectionMode = false;
   export let isAlbumSelectionMode = false;
-  export let viewportWidth: number;
+  export let viewport: Viewport;
 
 
   export let assetStore: AssetStore;
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
   export let assetInteractionStore: AssetInteractionStore;
@@ -45,7 +46,7 @@
     for (let group of assetsGroupByDate) {
     for (let group of assetsGroupByDate) {
       const justifiedLayoutResult = justifiedLayout(group.map(getAssetRatio), {
       const justifiedLayoutResult = justifiedLayout(group.map(getAssetRatio), {
         boxSpacing: 2,
         boxSpacing: 2,
-        containerWidth: Math.floor(viewportWidth),
+        containerWidth: Math.floor(viewport.width),
         containerPadding: 0,
         containerPadding: 0,
         targetRowHeightTolerance: 0.15,
         targetRowHeightTolerance: 0.15,
         targetRowHeight: 235,
         targetRowHeight: 235,
@@ -59,7 +60,7 @@
   })();
   })();
 
 
   $: {
   $: {
-    if (actualBucketHeight && actualBucketHeight != 0 && actualBucketHeight != bucketHeight) {
+    if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
       const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
       const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
       if (heightDelta !== 0) {
       if (heightDelta !== 0) {
         scrollTimeline(heightDelta);
         scrollTimeline(heightDelta);
@@ -143,12 +144,7 @@
   };
   };
 </script>
 </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)}
   {#each assetsGroupByDate as assetsInDateGroup, groupIndex (assetsInDateGroup[0].id)}
     {@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))}
     {@const dateGroupTitle = formatGroupTitle(DateTime.fromISO(assetsInDateGroup[0].fileCreatedAt).startOf('day'))}
     <!-- Asset Group By Date -->
     <!-- Asset Group By Date -->

+ 9 - 23
web/src/lib/components/photos-page/asset-grid.svelte

@@ -1,10 +1,8 @@
 <script lang="ts">
 <script lang="ts">
-  import { BucketPosition } from '$lib/models/asset-grid-state';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { locale } from '$lib/stores/preferences.store';
   import { locale } from '$lib/stores/preferences.store';
   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
   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 { DateTime } from 'luxon';
   import { onDestroy, onMount } from 'svelte';
   import { onDestroy, onMount } from 'svelte';
   import AssetViewer from '../asset-viewer/asset-viewer.svelte';
   import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -21,11 +19,10 @@
   import { goto } from '$app/navigation';
   import { goto } from '$app/navigation';
   import { AppRoute } from '$lib/constants';
   import { AppRoute } from '$lib/constants';
   import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
   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 { isSearchEnabled } from '$lib/stores/search.store';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
 
 
-  export let user: UserResponseDto | undefined = undefined;
   export let isAlbumSelectionMode = false;
   export let isAlbumSelectionMode = false;
   export let showMemoryLane = false;
   export let showMemoryLane = false;
 
 
@@ -36,8 +33,7 @@
 
 
   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
   let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 
 
-  let viewportHeight = 0;
-  let viewportWidth = 0;
+  const viewport: Viewport = { width: 0, height: 0 };
   let assetGridElement: HTMLElement;
   let assetGridElement: HTMLElement;
   let showShortcuts = false;
   let showShortcuts = false;
 
 
@@ -45,23 +41,13 @@
 
 
   onMount(async () => {
   onMount(async () => {
     document.addEventListener('keydown', onKeyboardPress);
     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(() => {
   onDestroy(() => {
     if (browser) {
     if (browser) {
       document.removeEventListener('keydown', onKeyboardPress);
       document.removeEventListener('keydown', onKeyboardPress);
     }
     }
-
-    assetStore.init({ width: 0, height: 0 }, [], undefined);
   });
   });
 
 
   const handleKeyboardPress = (event: KeyboardEvent) => {
   const handleKeyboardPress = (event: KeyboardEvent) => {
@@ -292,10 +278,10 @@
   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
   <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
 {/if}
 {/if}
 
 
-{#if viewportHeight && $assetStore.initialized && $assetStore.timelineHeight > viewportHeight}
+{#if $assetStore.timelineHeight > viewport.height}
   <Scrollbar
   <Scrollbar
     {assetStore}
     {assetStore}
-    scrollbarHeight={viewportHeight}
+    scrollbarHeight={viewport.height}
     scrollTop={lastScrollPosition}
     scrollTop={lastScrollPosition}
     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
     on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
     on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
     on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
@@ -306,8 +292,8 @@
 <section
 <section
   id="asset-grid"
   id="asset-grid"
   class="scrollbar-hidden mb-4 ml-4 mr-[60px] overflow-y-auto"
   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}
   bind:this={assetGridElement}
   on:scroll={handleTimelineScroll}
   on:scroll={handleTimelineScroll}
 >
 >
@@ -337,7 +323,7 @@
                 assets={bucket.assets}
                 assets={bucket.assets}
                 bucketDate={bucket.bucketDate}
                 bucketDate={bucket.bucketDate}
                 bucketHeight={bucket.bucketHeight}
                 bucketHeight={bucket.bucketHeight}
-                {viewportWidth}
+                {viewport}
               />
               />
             {/if}
             {/if}
           </div>
           </div>

+ 0 - 248
web/src/lib/models/asset-grid-state.ts

@@ -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);
-  }
-}

+ 238 - 30
web/src/lib/stores/assets.store.ts

@@ -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;
+export enum BucketPosition {
+  Above = 'above',
+  Below = 'below',
+  Visible = 'visible',
+  Unknown = 'unknown',
+}
 
 
-  // bucket
-  loadBucket: (bucket: string, position: BucketPosition) => Promise<void>;
-  updateBucket: (bucket: string, actualBucketHeight: number) => number;
-  cancelBucket: (bucket: AssetBucket) => void;
+export type AssetStoreOptions = GetAssetCountByTimeBucketDto;
 
 
-  // asset
-  removeAsset: (assetId: string) => void;
-  updateAsset: (assetId: string, isFavorite: boolean) => void;
+export interface Viewport {
+  width: number;
+  height: number;
+}
 
 
-  // asset navigation
-  getNextAssetId: (assetId: string) => Promise<string | null>;
-  getPreviousAssetId: (assetId: string) => Promise<string | null>;
+interface AssetLookup {
+  bucket: AssetBucket;
+  bucketIndex: number;
+  assetIndex: number;
+}
 
 
-  // store
-  subscribe: (run: (value: AssetGridState) => void, invalidate?: (value?: AssetGridState) => void) => () => void;
+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;
 }
 }
 
 
-export function createAssetStore(): AssetStore {
-  const store = new AssetGridState();
-
-  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,
-  };
+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 {
+        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);
+  }
 }
 }

+ 6 - 5
web/src/routes/(user)/partners/[userId]/+page.svelte

@@ -8,16 +8,17 @@
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
   import { AppRoute } from '$lib/constants';
   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 { onDestroy } from 'svelte';
   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import type { PageData } from './$types';
   import type { PageData } from './$types';
-  import { createAssetStore } from '$lib/stores/assets.store';
-  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
 
 
   export let data: PageData;
   export let data: PageData;
 
 
-  const assetStore = createAssetStore();
+  const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month, userId: data.partner.id });
   const assetInteractionStore = createAssetInteractionStore();
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
 
@@ -39,12 +40,12 @@
   {:else}
   {:else}
     <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(AppRoute.SHARING)}>
     <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(AppRoute.SHARING)}>
       <svelte:fragment slot="leading">
       <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.firstName}
           {data.partner.lastName}'s photos
           {data.partner.lastName}'s photos
         </p>
         </p>
       </svelte:fragment>
       </svelte:fragment>
     </ControlAppBar>
     </ControlAppBar>
   {/if}
   {/if}
-  <AssetGrid {assetStore} {assetInteractionStore} user={data.partner} />
+  <AssetGrid {assetStore} {assetInteractionStore} />
 </main>
 </main>

+ 4 - 4
web/src/routes/(user)/photos/+page.svelte

@@ -11,10 +11,10 @@
   import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
   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 AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
   import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.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 { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
   import { openFileUploadDialog } from '$lib/utils/file-uploader';
-  import { api } from '@api';
+  import { TimeGroupEnum, api } from '@api';
   import { onDestroy, onMount } from 'svelte';
   import { onDestroy, onMount } from 'svelte';
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
@@ -23,7 +23,7 @@
   export let data: PageData;
   export let data: PageData;
   let assetCount = 1;
   let assetCount = 1;
 
 
-  const assetStore = createAssetStore();
+  const assetStore = new AssetStore({ timeGroup: TimeGroupEnum.Month });
   const assetInteractionStore = createAssetInteractionStore();
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
 
@@ -53,7 +53,7 @@
           <AddToAlbum />
           <AddToAlbum />
           <AddToAlbum shared />
           <AddToAlbum shared />
         </AssetSelectContextMenu>
         </AssetSelectContextMenu>
-        <DeleteAssets onAssetDelete={assetStore.removeAsset} />
+        <DeleteAssets onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
         <AssetSelectContextMenu icon={DotsVertical} title="Menu">
         <AssetSelectContextMenu icon={DotsVertical} title="Menu">
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <DownloadAction menuItem />
           <DownloadAction menuItem />