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;
 | 
			
		||||
 | 
			
		||||
  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,
 | 
			
		||||
  };
 | 
			
		||||
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 {
 | 
			
		||||
        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