mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(web): asset grid stores (#3464)
* Refactor asset grid stores * Iterate over buckets with for..of loop * Rebase on top of main branch changes
This commit is contained in:
		@@ -43,13 +43,15 @@
 | 
			
		||||
  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
 | 
			
		||||
  import { handleError } from '../../utils/handle-error';
 | 
			
		||||
  import { downloadArchive } from '../../utils/asset-utils';
 | 
			
		||||
  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 | 
			
		||||
 | 
			
		||||
  export let album: AlbumResponseDto;
 | 
			
		||||
  export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
  const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
 | 
			
		||||
 | 
			
		||||
  let { isViewing: showAssetViewer } = assetViewingStore;
 | 
			
		||||
 | 
			
		||||
  let isShowAssetSelection = false;
 | 
			
		||||
 | 
			
		||||
  let isShowShareLinkModal = false;
 | 
			
		||||
@@ -141,7 +143,7 @@
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleKeyboardPress = (event: KeyboardEvent) => {
 | 
			
		||||
    if (!$isViewingAssetStoreState) {
 | 
			
		||||
    if (!$showAssetViewer) {
 | 
			
		||||
      switch (event.key) {
 | 
			
		||||
        case 'Escape':
 | 
			
		||||
          if (isMultiSelectionMode) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { assetInteractionStore, assetsInAlbumStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { locale } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
			
		||||
  import type { AssetResponseDto } from '@api';
 | 
			
		||||
@@ -9,14 +8,20 @@
 | 
			
		||||
  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 assetInteractionStore = createAssetInteractionStore();
 | 
			
		||||
  const { selectedAssets, assetsInAlbumState } = assetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  export let albumId: string;
 | 
			
		||||
  export let assetsInAlbum: AssetResponseDto[];
 | 
			
		||||
 | 
			
		||||
  onMount(() => {
 | 
			
		||||
    $assetsInAlbumStoreState = assetsInAlbum;
 | 
			
		||||
    $assetsInAlbumState = assetsInAlbum;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const addSelectedAssets = async () => {
 | 
			
		||||
@@ -64,6 +69,6 @@
 | 
			
		||||
    </svelte:fragment>
 | 
			
		||||
  </ControlAppBar>
 | 
			
		||||
  <section class="grid h-screen bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
 | 
			
		||||
    <AssetGrid isAlbumSelectionMode={true} />
 | 
			
		||||
    <AssetGrid {assetStore} {assetInteractionStore} isAlbumSelectionMode={true} />
 | 
			
		||||
  </section>
 | 
			
		||||
</section>
 | 
			
		||||
 
 | 
			
		||||
@@ -17,13 +17,14 @@
 | 
			
		||||
  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
 | 
			
		||||
  import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
 | 
			
		||||
 | 
			
		||||
  import { assetStore } from '$lib/stores/assets.store';
 | 
			
		||||
  import { isShowDetail } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
 | 
			
		||||
  import NavigationArea from './navigation-area.svelte';
 | 
			
		||||
  import { browser } from '$app/environment';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import type { AssetStore } from '$lib/stores/assets.store';
 | 
			
		||||
 | 
			
		||||
  export let assetStore: AssetStore | null = null;
 | 
			
		||||
  export let asset: AssetResponseDto;
 | 
			
		||||
  export let publicSharedKey = '';
 | 
			
		||||
  export let showNavigation = true;
 | 
			
		||||
@@ -134,7 +135,7 @@
 | 
			
		||||
 | 
			
		||||
      for (const asset of deletedAssets) {
 | 
			
		||||
        if (asset.status == 'SUCCESS') {
 | 
			
		||||
          assetStore.removeAsset(asset.id);
 | 
			
		||||
          assetStore?.removeAsset(asset.id);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@@ -158,7 +159,7 @@
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      asset.isFavorite = data.isFavorite;
 | 
			
		||||
      assetStore.updateAsset(asset.id, data.isFavorite);
 | 
			
		||||
      assetStore?.updateAsset(asset.id, data.isFavorite);
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,30 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { get } from 'svelte/store';
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import SelectAll from 'svelte-material-icons/SelectAll.svelte';
 | 
			
		||||
  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
 | 
			
		||||
  import { assetInteractionStore } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetGridState, assetStore } from '$lib/stores/assets.store';
 | 
			
		||||
  import { handleError } from '../../../utils/handle-error';
 | 
			
		||||
  import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
 | 
			
		||||
  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';
 | 
			
		||||
 | 
			
		||||
  export let assetStore: AssetStore;
 | 
			
		||||
  export let assetInteractionStore: AssetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  let selecting = false;
 | 
			
		||||
 | 
			
		||||
  const handleSelectAll = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      selecting = true;
 | 
			
		||||
      let _assetGridState = new AssetGridState();
 | 
			
		||||
      assetGridState.subscribe((state) => {
 | 
			
		||||
        _assetGridState = state;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      for (let i = 0; i < _assetGridState.buckets.length; i++) {
 | 
			
		||||
        await assetStore.getAssetsByBucket(_assetGridState.buckets[i].bucketDate, BucketPosition.Unknown);
 | 
			
		||||
        for (const asset of _assetGridState.buckets[i].assets) {
 | 
			
		||||
      const assetGridState = get(assetStore);
 | 
			
		||||
      for (const bucket of assetGridState.buckets) {
 | 
			
		||||
        await assetStore.getAssetsByBucket(bucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
        for (const asset of bucket.assets) {
 | 
			
		||||
          assetInteractionStore.addAssetToMultiselectGroup(asset);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      selecting = false;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      handleError(e, 'Error selecting all assets');
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,4 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import {
 | 
			
		||||
    assetInteractionStore,
 | 
			
		||||
    assetSelectionCandidates,
 | 
			
		||||
    assetsInAlbumStoreState,
 | 
			
		||||
    isMultiSelectStoreState,
 | 
			
		||||
    selectedAssets,
 | 
			
		||||
    selectedGroup,
 | 
			
		||||
  } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetStore } from '$lib/stores/assets.store';
 | 
			
		||||
  import { locale } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { getAssetRatio } from '$lib/utils/asset-utils';
 | 
			
		||||
  import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
 | 
			
		||||
@@ -19,6 +10,9 @@
 | 
			
		||||
  import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
 | 
			
		||||
  import { fly } from 'svelte/transition';
 | 
			
		||||
  import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
 | 
			
		||||
  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';
 | 
			
		||||
 | 
			
		||||
  export let assets: AssetResponseDto[];
 | 
			
		||||
  export let bucketDate: string;
 | 
			
		||||
@@ -26,6 +20,12 @@
 | 
			
		||||
  export let isAlbumSelectionMode = false;
 | 
			
		||||
  export let viewportWidth: number;
 | 
			
		||||
 | 
			
		||||
  export let assetStore: AssetStore;
 | 
			
		||||
  export let assetInteractionStore: AssetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  const { selectedGroup, selectedAssets, assetsInAlbumState, assetSelectionCandidates, isMultiSelectState } =
 | 
			
		||||
    assetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  const dispatch = createEventDispatcher();
 | 
			
		||||
 | 
			
		||||
  let isMouseOverGroup = false;
 | 
			
		||||
@@ -94,10 +94,10 @@
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if ($isMultiSelectStoreState) {
 | 
			
		||||
    if ($isMultiSelectState) {
 | 
			
		||||
      assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
 | 
			
		||||
    } else {
 | 
			
		||||
      assetInteractionStore.setViewingAsset(asset);
 | 
			
		||||
      assetViewingStore.setAssetId(asset.id);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -137,7 +137,7 @@
 | 
			
		||||
    // Show multi select icon on hover on date group
 | 
			
		||||
    hoveredDateGroup = dateGroupTitle;
 | 
			
		||||
 | 
			
		||||
    if ($isMultiSelectStoreState) {
 | 
			
		||||
    if ($isMultiSelectState) {
 | 
			
		||||
      dispatch('selectAssetCandidates', { asset });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
@@ -207,9 +207,9 @@
 | 
			
		||||
              on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
 | 
			
		||||
              on:select={() => assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle)}
 | 
			
		||||
              on:mouse-event={() => assetMouseEventHandler(dateGroupTitle, asset)}
 | 
			
		||||
              selected={$selectedAssets.has(asset) || $assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
 | 
			
		||||
              selected={$selectedAssets.has(asset) || $assetsInAlbumState.some(({ id }) => id === asset.id)}
 | 
			
		||||
              selectionCandidate={$assetSelectionCandidates.has(asset)}
 | 
			
		||||
              disabled={$assetsInAlbumStoreState.some(({ id }) => id === asset.id)}
 | 
			
		||||
              disabled={$assetsInAlbumState.some(({ id }) => id === asset.id)}
 | 
			
		||||
              thumbnailWidth={box.width}
 | 
			
		||||
              thumbnailHeight={box.height}
 | 
			
		||||
            />
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,6 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { BucketPosition } from '$lib/models/asset-grid-state';
 | 
			
		||||
  import {
 | 
			
		||||
    assetInteractionStore,
 | 
			
		||||
    assetSelectionCandidates,
 | 
			
		||||
    assetSelectionStart,
 | 
			
		||||
    isMultiSelectStoreState,
 | 
			
		||||
    isViewingAssetStoreState,
 | 
			
		||||
    selectedAssets,
 | 
			
		||||
    viewingAssetStoreState,
 | 
			
		||||
  } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
 | 
			
		||||
  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';
 | 
			
		||||
@@ -31,11 +22,20 @@
 | 
			
		||||
  import { browser } from '$app/environment';
 | 
			
		||||
  import { isSearchEnabled } from '$lib/stores/search.store';
 | 
			
		||||
  import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
 | 
			
		||||
  import type { AssetStore } from '$lib/stores/assets.store';
 | 
			
		||||
  import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
 | 
			
		||||
  export let user: UserResponseDto | undefined = undefined;
 | 
			
		||||
  export let isAlbumSelectionMode = false;
 | 
			
		||||
  export let showMemoryLane = false;
 | 
			
		||||
 | 
			
		||||
  export let assetStore: AssetStore;
 | 
			
		||||
  export let assetInteractionStore: AssetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  const { assetSelectionCandidates, assetSelectionStart, selectedAssets, isMultiSelectState } = assetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 | 
			
		||||
 | 
			
		||||
  let viewportHeight = 0;
 | 
			
		||||
  let viewportWidth = 0;
 | 
			
		||||
  let assetGridElement: HTMLElement;
 | 
			
		||||
@@ -61,7 +61,7 @@
 | 
			
		||||
    // Get asset bucket if bucket height is smaller than viewport height
 | 
			
		||||
    let bucketsToFetchInitially: string[] = [];
 | 
			
		||||
    let initialBucketsHeight = 0;
 | 
			
		||||
    $assetGridState.buckets.every((bucket) => {
 | 
			
		||||
    $assetStore.buckets.every((bucket) => {
 | 
			
		||||
      if (initialBucketsHeight < viewportHeight) {
 | 
			
		||||
        initialBucketsHeight += bucket.bucketHeight;
 | 
			
		||||
        bucketsToFetchInitially.push(bucket.bucketDate);
 | 
			
		||||
@@ -89,7 +89,7 @@
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!$isViewingAssetStoreState) {
 | 
			
		||||
    if (!$showAssetViewer) {
 | 
			
		||||
      switch (event.key) {
 | 
			
		||||
        case 'Escape':
 | 
			
		||||
          assetInteractionStore.clearMultiselect();
 | 
			
		||||
@@ -121,12 +121,18 @@
 | 
			
		||||
    assetGridElement.scrollBy(0, event.detail.heightDelta);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const navigateToPreviousAsset = () => {
 | 
			
		||||
    assetInteractionStore.navigateAsset('previous');
 | 
			
		||||
  const navigateToPreviousAsset = async () => {
 | 
			
		||||
    const prevAsset = await assetStore.getAdjacentAsset($viewingAsset.id, 'previous');
 | 
			
		||||
    if (prevAsset) {
 | 
			
		||||
      assetViewingStore.setAssetId(prevAsset);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const navigateToNextAsset = () => {
 | 
			
		||||
    assetInteractionStore.navigateAsset('next');
 | 
			
		||||
  const navigateToNextAsset = async () => {
 | 
			
		||||
    const nextAsset = await assetStore.getAdjacentAsset($viewingAsset.id, 'next');
 | 
			
		||||
    if (nextAsset) {
 | 
			
		||||
      assetViewingStore.setAssetId(nextAsset);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let lastScrollPosition = 0;
 | 
			
		||||
@@ -228,8 +234,8 @@
 | 
			
		||||
    assetInteractionStore.clearAssetSelectionCandidates();
 | 
			
		||||
 | 
			
		||||
    if ($assetSelectionStart && rangeSelection) {
 | 
			
		||||
      let startBucketIndex = $assetGridState.loadedAssets[$assetSelectionStart.id];
 | 
			
		||||
      let endBucketIndex = $assetGridState.loadedAssets[asset.id];
 | 
			
		||||
      let startBucketIndex = $assetStore.loadedAssets[$assetSelectionStart.id];
 | 
			
		||||
      let endBucketIndex = $assetStore.loadedAssets[asset.id];
 | 
			
		||||
 | 
			
		||||
      if (endBucketIndex < startBucketIndex) {
 | 
			
		||||
        [startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
 | 
			
		||||
@@ -237,7 +243,7 @@
 | 
			
		||||
 | 
			
		||||
      // Select/deselect assets in all intermediate buckets
 | 
			
		||||
      for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
 | 
			
		||||
        const bucket = $assetGridState.buckets[bucketIndex];
 | 
			
		||||
        const bucket = $assetStore.buckets[bucketIndex];
 | 
			
		||||
        await assetStore.getAssetsByBucket(bucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
        for (const asset of bucket.assets) {
 | 
			
		||||
          if (deselect) {
 | 
			
		||||
@@ -250,7 +256,7 @@
 | 
			
		||||
 | 
			
		||||
      // Update date group selection
 | 
			
		||||
      for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
 | 
			
		||||
        const bucket = $assetGridState.buckets[bucketIndex];
 | 
			
		||||
        const bucket = $assetStore.buckets[bucketIndex];
 | 
			
		||||
 | 
			
		||||
        // Split bucket into date groups and check each group
 | 
			
		||||
        const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
 | 
			
		||||
@@ -279,18 +285,18 @@
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let start = $assetGridState.assets.indexOf(rangeStart);
 | 
			
		||||
    let end = $assetGridState.assets.indexOf(asset);
 | 
			
		||||
    let start = $assetStore.assets.indexOf(rangeStart);
 | 
			
		||||
    let end = $assetStore.assets.indexOf(asset);
 | 
			
		||||
 | 
			
		||||
    if (start > end) {
 | 
			
		||||
      [start, end] = [end, start];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    assetInteractionStore.setAssetSelectionCandidates($assetGridState.assets.slice(start, end + 1));
 | 
			
		||||
    assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onSelectStart = (e: Event) => {
 | 
			
		||||
    if ($isMultiSelectStoreState && shiftKeyIsDown) {
 | 
			
		||||
    if ($isMultiSelectState && shiftKeyIsDown) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
@@ -302,8 +308,9 @@
 | 
			
		||||
  <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
 | 
			
		||||
{#if bucketInfo && viewportHeight && $assetStore.timelineHeight > viewportHeight}
 | 
			
		||||
  <Scrollbar
 | 
			
		||||
    {assetStore}
 | 
			
		||||
    scrollbarHeight={viewportHeight}
 | 
			
		||||
    scrollTop={lastScrollPosition}
 | 
			
		||||
    on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
 | 
			
		||||
@@ -324,15 +331,12 @@
 | 
			
		||||
    {#if showMemoryLane}
 | 
			
		||||
      <MemoryLane />
 | 
			
		||||
    {/if}
 | 
			
		||||
    <section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
 | 
			
		||||
      {#each $assetGridState.buckets as bucket, bucketIndex (bucketIndex)}
 | 
			
		||||
    <section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
 | 
			
		||||
      {#each $assetStore.buckets as bucket, bucketIndex (bucketIndex)}
 | 
			
		||||
        <IntersectionObserver
 | 
			
		||||
          on:intersected={intersectedHandler}
 | 
			
		||||
          on:hidden={async () => {
 | 
			
		||||
            // If bucket is hidden and in loading state, cancel the request
 | 
			
		||||
            if ($loadingBucketState[bucket.bucketDate]) {
 | 
			
		||||
            await assetStore.cancelBucketRequest(bucket.cancelToken, bucket.bucketDate);
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          let:intersecting
 | 
			
		||||
          top={750}
 | 
			
		||||
@@ -342,6 +346,8 @@
 | 
			
		||||
          <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
 | 
			
		||||
            {#if intersecting}
 | 
			
		||||
              <AssetDateGroup
 | 
			
		||||
                {assetStore}
 | 
			
		||||
                {assetInteractionStore}
 | 
			
		||||
                {isAlbumSelectionMode}
 | 
			
		||||
                on:shift={handleScrollTimeline}
 | 
			
		||||
                on:selectAssetCandidates={handleSelectAssetCandidates}
 | 
			
		||||
@@ -360,13 +366,14 @@
 | 
			
		||||
</section>
 | 
			
		||||
 | 
			
		||||
<Portal target="body">
 | 
			
		||||
  {#if $isViewingAssetStoreState}
 | 
			
		||||
  {#if $showAssetViewer}
 | 
			
		||||
    <AssetViewer
 | 
			
		||||
      asset={$viewingAssetStoreState}
 | 
			
		||||
      {assetStore}
 | 
			
		||||
      asset={$viewingAsset}
 | 
			
		||||
      on:navigate-previous={navigateToPreviousAsset}
 | 
			
		||||
      on:navigate-next={navigateToNextAsset}
 | 
			
		||||
      on:close={() => {
 | 
			
		||||
        assetInteractionStore.setIsViewingAsset(false);
 | 
			
		||||
        assetViewingStore.showAssetViewer(false);
 | 
			
		||||
      }}
 | 
			
		||||
      on:archived={handleArchiveSuccess}
 | 
			
		||||
    />
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@
 | 
			
		||||
  import { flip } from 'svelte/animate';
 | 
			
		||||
  import { archivedAsset } from '$lib/stores/archived-asset.store';
 | 
			
		||||
  import { getThumbnailSize } from '$lib/utils/thumbnail-util';
 | 
			
		||||
  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 | 
			
		||||
 | 
			
		||||
  export let assets: AssetResponseDto[];
 | 
			
		||||
  export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 | 
			
		||||
@@ -20,6 +20,8 @@
 | 
			
		||||
  export let viewFrom: ViewFrom;
 | 
			
		||||
  export let showArchiveIcon = false;
 | 
			
		||||
 | 
			
		||||
  let { isViewing: showAssetViewer } = assetViewingStore;
 | 
			
		||||
 | 
			
		||||
  let selectedAsset: AssetResponseDto;
 | 
			
		||||
  let currentViewAssetIndex = 0;
 | 
			
		||||
 | 
			
		||||
@@ -33,7 +35,7 @@
 | 
			
		||||
 | 
			
		||||
    currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
 | 
			
		||||
    selectedAsset = assets[currentViewAssetIndex];
 | 
			
		||||
    $isViewingAssetStoreState = true;
 | 
			
		||||
    $showAssetViewer = true;
 | 
			
		||||
    pushState(selectedAsset.id);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -81,7 +83,7 @@
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const closeViewer = () => {
 | 
			
		||||
    $isViewingAssetStoreState = false;
 | 
			
		||||
    $showAssetViewer = false;
 | 
			
		||||
    history.pushState(null, '', `${$page.url.pathname}`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -117,7 +119,7 @@
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<!-- Overlay Asset Viewer -->
 | 
			
		||||
{#if $isViewingAssetStoreState}
 | 
			
		||||
{#if $showAssetViewer}
 | 
			
		||||
  <AssetViewer
 | 
			
		||||
    asset={selectedAsset}
 | 
			
		||||
    publicSharedKey={sharedLink?.key}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,15 +19,15 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
 | 
			
		||||
 | 
			
		||||
  import { assetGridState } from '$lib/stores/assets.store';
 | 
			
		||||
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
 | 
			
		||||
  import type { AssetStore } from '$lib/stores/assets.store';
 | 
			
		||||
 | 
			
		||||
  export let scrollTop = 0;
 | 
			
		||||
  export let scrollbarHeight = 0;
 | 
			
		||||
  export let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
  $: timelineHeight = $assetGridState.timelineHeight;
 | 
			
		||||
  $: timelineHeight = $assetStore.timelineHeight;
 | 
			
		||||
  $: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
 | 
			
		||||
 | 
			
		||||
  let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
 | 
			
		||||
@@ -48,7 +48,7 @@
 | 
			
		||||
 | 
			
		||||
  $: {
 | 
			
		||||
    let result: SegmentScrollbarLayout[] = [];
 | 
			
		||||
    for (const bucket of $assetGridState.buckets) {
 | 
			
		||||
    for (const bucket of $assetStore.buckets) {
 | 
			
		||||
      let segmentLayout = new SegmentScrollbarLayout();
 | 
			
		||||
      segmentLayout.count = bucket.assets.length;
 | 
			
		||||
      segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,49 +1,68 @@
 | 
			
		||||
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
 | 
			
		||||
import { api, AssetResponseDto } from '@api';
 | 
			
		||||
import { derived, writable } from 'svelte/store';
 | 
			
		||||
import { assetGridState, assetStore } from './assets.store';
 | 
			
		||||
import type { AssetResponseDto } from '../../api/open-api';
 | 
			
		||||
 | 
			
		||||
// Asset Viewer
 | 
			
		||||
export const viewingAssetStoreState = writable<AssetResponseDto>();
 | 
			
		||||
export const isViewingAssetStoreState = writable<boolean>(false);
 | 
			
		||||
export interface AssetInteractionStore {
 | 
			
		||||
  addAssetToMultiselectGroup: (asset: AssetResponseDto) => void;
 | 
			
		||||
  removeAssetFromMultiselectGroup: (asset: AssetResponseDto) => void;
 | 
			
		||||
  addGroupToMultiselectGroup: (group: string) => void;
 | 
			
		||||
  removeGroupFromMultiselectGroup: (group: string) => void;
 | 
			
		||||
  setAssetSelectionCandidates: (assets: AssetResponseDto[]) => void;
 | 
			
		||||
  clearAssetSelectionCandidates: () => void;
 | 
			
		||||
  setAssetSelectionStart: (asset: AssetResponseDto | null) => void;
 | 
			
		||||
  clearMultiselect: () => void;
 | 
			
		||||
  isMultiSelectState: {
 | 
			
		||||
    subscribe: (run: (value: boolean) => void, invalidate?: (value?: boolean) => void) => () => void;
 | 
			
		||||
  };
 | 
			
		||||
  assetsInAlbumState: {
 | 
			
		||||
    subscribe: (
 | 
			
		||||
      run: (value: AssetResponseDto[]) => void,
 | 
			
		||||
      invalidate?: (value?: AssetResponseDto[]) => void,
 | 
			
		||||
    ) => () => void;
 | 
			
		||||
    set: (value: AssetResponseDto[]) => void;
 | 
			
		||||
  };
 | 
			
		||||
  selectedAssets: {
 | 
			
		||||
    subscribe: (
 | 
			
		||||
      run: (value: Set<AssetResponseDto>) => void,
 | 
			
		||||
      invalidate?: (value?: Set<AssetResponseDto>) => void,
 | 
			
		||||
    ) => () => void;
 | 
			
		||||
  };
 | 
			
		||||
  selectedGroup: {
 | 
			
		||||
    subscribe: (run: (value: Set<string>) => void, invalidate?: (value?: Set<string>) => void) => () => void;
 | 
			
		||||
  };
 | 
			
		||||
  assetSelectionCandidates: {
 | 
			
		||||
    subscribe: (
 | 
			
		||||
      run: (value: Set<AssetResponseDto>) => void,
 | 
			
		||||
      invalidate?: (value?: Set<AssetResponseDto>) => void,
 | 
			
		||||
    ) => () => void;
 | 
			
		||||
  };
 | 
			
		||||
  assetSelectionStart: {
 | 
			
		||||
    subscribe: (
 | 
			
		||||
      run: (value: AssetResponseDto | null) => void,
 | 
			
		||||
      invalidate?: (value?: AssetResponseDto | null) => void,
 | 
			
		||||
    ) => () => void;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Multi-selection mode
 | 
			
		||||
 */
 | 
			
		||||
export const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
 | 
			
		||||
// Selected assets
 | 
			
		||||
export const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
 | 
			
		||||
// Selected date groups
 | 
			
		||||
export const selectedGroup = writable<Set<string>>(new Set());
 | 
			
		||||
// If any asset selected
 | 
			
		||||
export const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Range selection
 | 
			
		||||
 */
 | 
			
		||||
// Candidates for the range selection. This set includes only loaded assets, so it improves highlight
 | 
			
		||||
// performance. From the user's perspective, range is highlighted almost immediately
 | 
			
		||||
export const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
 | 
			
		||||
// The beginning of the selection range
 | 
			
		||||
export const assetSelectionStart = writable<AssetResponseDto | null>(null);
 | 
			
		||||
 | 
			
		||||
function createAssetInteractionStore() {
 | 
			
		||||
  let _assetGridState = new AssetGridState();
 | 
			
		||||
  let _viewingAssetStoreState: AssetResponseDto;
 | 
			
		||||
export function createAssetInteractionStore(): AssetInteractionStore {
 | 
			
		||||
  let _selectedAssets: Set<AssetResponseDto>;
 | 
			
		||||
  let _selectedGroup: Set<string>;
 | 
			
		||||
  let _assetsInAlbums: AssetResponseDto[];
 | 
			
		||||
  let _assetSelectionCandidates: Set<AssetResponseDto>;
 | 
			
		||||
  let _assetSelectionStart: AssetResponseDto | null;
 | 
			
		||||
 | 
			
		||||
  // Subscriber
 | 
			
		||||
  assetGridState.subscribe((state) => {
 | 
			
		||||
    _assetGridState = state;
 | 
			
		||||
  });
 | 
			
		||||
  const assetsInAlbumStoreState = writable<AssetResponseDto[]>([]);
 | 
			
		||||
  // Selected assets
 | 
			
		||||
  const selectedAssets = writable<Set<AssetResponseDto>>(new Set());
 | 
			
		||||
  // Selected date groups
 | 
			
		||||
  const selectedGroup = writable<Set<string>>(new Set());
 | 
			
		||||
  // If any asset selected
 | 
			
		||||
  const isMultiSelectStoreState = derived(selectedAssets, ($selectedAssets) => $selectedAssets.size > 0);
 | 
			
		||||
 | 
			
		||||
  viewingAssetStoreState.subscribe((asset) => {
 | 
			
		||||
    _viewingAssetStoreState = asset;
 | 
			
		||||
  });
 | 
			
		||||
  // Candidates for the range selection. This set includes only loaded assets, so it improves highlight
 | 
			
		||||
  // performance. From the user's perspective, range is highlighted almost immediately
 | 
			
		||||
  const assetSelectionCandidates = writable<Set<AssetResponseDto>>(new Set());
 | 
			
		||||
  // The beginning of the selection range
 | 
			
		||||
  const assetSelectionStart = writable<AssetResponseDto | null>(null);
 | 
			
		||||
 | 
			
		||||
  selectedAssets.subscribe((assets) => {
 | 
			
		||||
    _selectedAssets = assets;
 | 
			
		||||
@@ -64,89 +83,7 @@ function createAssetInteractionStore() {
 | 
			
		||||
  assetSelectionStart.subscribe((asset) => {
 | 
			
		||||
    _assetSelectionStart = asset;
 | 
			
		||||
  });
 | 
			
		||||
  // Methods
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Asset Viewer
 | 
			
		||||
   */
 | 
			
		||||
  const setViewingAsset = async (asset: AssetResponseDto) => {
 | 
			
		||||
    setViewingAssetId(asset.id);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const setViewingAssetId = async (id: string) => {
 | 
			
		||||
    const { data } = await api.assetApi.getAssetById({ id });
 | 
			
		||||
    viewingAssetStoreState.set(data);
 | 
			
		||||
    isViewingAssetStoreState.set(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const setIsViewingAsset = (isViewing: boolean) => {
 | 
			
		||||
    isViewingAssetStoreState.set(isViewing);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getNextAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
 | 
			
		||||
    const currentBucket = _assetGridState.buckets[currentBucketIndex];
 | 
			
		||||
    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
 | 
			
		||||
    if (assetIndex === -1) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (assetIndex + 1 < currentBucket.assets.length) {
 | 
			
		||||
      return currentBucket.assets[assetIndex + 1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextBucketIndex = currentBucketIndex + 1;
 | 
			
		||||
    if (nextBucketIndex >= _assetGridState.buckets.length) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextBucket = _assetGridState.buckets[nextBucketIndex];
 | 
			
		||||
    await assetStore.getAssetsByBucket(nextBucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
    return nextBucket.assets[0] ?? null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getPrevAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
 | 
			
		||||
    const currentBucket = _assetGridState.buckets[currentBucketIndex];
 | 
			
		||||
    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
 | 
			
		||||
    if (assetIndex === -1) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (assetIndex > 0) {
 | 
			
		||||
      return currentBucket.assets[assetIndex - 1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevBucketIndex = currentBucketIndex - 1;
 | 
			
		||||
    if (prevBucketIndex < 0) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevBucket = _assetGridState.buckets[prevBucketIndex];
 | 
			
		||||
    await assetStore.getAssetsByBucket(prevBucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
    return prevBucket.assets[prevBucket.assets.length - 1] ?? null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const navigateAsset = async (direction: 'next' | 'previous') => {
 | 
			
		||||
    const currentAssetId = _viewingAssetStoreState.id;
 | 
			
		||||
    const currentBucketIndex = _assetGridState.loadedAssets[currentAssetId];
 | 
			
		||||
    if (currentBucketIndex < 0 || currentBucketIndex >= _assetGridState.buckets.length) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const asset =
 | 
			
		||||
      direction === 'next'
 | 
			
		||||
        ? await getNextAsset(currentBucketIndex, currentAssetId)
 | 
			
		||||
        : await getPrevAsset(currentBucketIndex, currentAssetId);
 | 
			
		||||
 | 
			
		||||
    if (asset) {
 | 
			
		||||
      setViewingAsset(asset);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Multiselect
 | 
			
		||||
   */
 | 
			
		||||
  const addAssetToMultiselectGroup = (asset: AssetResponseDto) => {
 | 
			
		||||
    // Not select if in album already
 | 
			
		||||
    if (_assetsInAlbums.find((a) => a.id === asset.id)) {
 | 
			
		||||
@@ -205,10 +142,6 @@ function createAssetInteractionStore() {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    setViewingAsset,
 | 
			
		||||
    setViewingAssetId,
 | 
			
		||||
    setIsViewingAsset,
 | 
			
		||||
    navigateAsset,
 | 
			
		||||
    addAssetToMultiselectGroup,
 | 
			
		||||
    removeAssetFromMultiselectGroup,
 | 
			
		||||
    addGroupToMultiselectGroup,
 | 
			
		||||
@@ -217,7 +150,24 @@ function createAssetInteractionStore() {
 | 
			
		||||
    clearAssetSelectionCandidates,
 | 
			
		||||
    setAssetSelectionStart,
 | 
			
		||||
    clearMultiselect,
 | 
			
		||||
    isMultiSelectState: {
 | 
			
		||||
      subscribe: isMultiSelectStoreState.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
    assetsInAlbumState: {
 | 
			
		||||
      subscribe: assetsInAlbumStoreState.subscribe,
 | 
			
		||||
      set: assetsInAlbumStoreState.set,
 | 
			
		||||
    },
 | 
			
		||||
    selectedAssets: {
 | 
			
		||||
      subscribe: selectedAssets.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
    selectedGroup: {
 | 
			
		||||
      subscribe: selectedGroup.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
    assetSelectionCandidates: {
 | 
			
		||||
      subscribe: assetSelectionCandidates.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
    assetSelectionStart: {
 | 
			
		||||
      subscribe: assetSelectionStart.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const assetInteractionStore = createAssetInteractionStore();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										31
									
								
								web/src/lib/stores/asset-viewing.store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								web/src/lib/stores/asset-viewing.store.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
import { writable } from 'svelte/store';
 | 
			
		||||
import { api, type AssetResponseDto } from '@api';
 | 
			
		||||
 | 
			
		||||
function createAssetViewingStore() {
 | 
			
		||||
  const viewingAssetStoreState = writable<AssetResponseDto>();
 | 
			
		||||
  const viewState = writable<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  const setAssetId = async (id: string) => {
 | 
			
		||||
    const { data } = await api.assetApi.getAssetById({ id });
 | 
			
		||||
    viewingAssetStoreState.set(data);
 | 
			
		||||
    viewState.set(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const showAssetViewer = (show: boolean) => {
 | 
			
		||||
    viewState.set(show);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    asset: {
 | 
			
		||||
      subscribe: viewingAssetStoreState.subscribe,
 | 
			
		||||
    },
 | 
			
		||||
    isViewing: {
 | 
			
		||||
      subscribe: viewState.subscribe,
 | 
			
		||||
      set: viewState.set,
 | 
			
		||||
    },
 | 
			
		||||
    setAssetId,
 | 
			
		||||
    showAssetViewer,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const assetViewingStore = createAssetViewingStore();
 | 
			
		||||
@@ -1,25 +1,34 @@
 | 
			
		||||
import { AssetGridState, BucketPosition } from '$lib/models/asset-grid-state';
 | 
			
		||||
import { api, AssetCountByTimeBucketResponseDto } from '@api';
 | 
			
		||||
import { api, AssetCountByTimeBucketResponseDto, AssetResponseDto } from '@api';
 | 
			
		||||
import { writable } from 'svelte/store';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The state that holds information about the asset grid
 | 
			
		||||
 */
 | 
			
		||||
export const assetGridState = writable<AssetGridState>(new AssetGridState());
 | 
			
		||||
export const loadingBucketState = writable<{ [key: string]: boolean }>({});
 | 
			
		||||
export interface AssetStore {
 | 
			
		||||
  setInitialState: (
 | 
			
		||||
    viewportHeight: number,
 | 
			
		||||
    viewportWidth: number,
 | 
			
		||||
    data: AssetCountByTimeBucketResponseDto,
 | 
			
		||||
    userId: string | undefined,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  getAssetsByBucket: (bucket: string, position: BucketPosition) => Promise<void>;
 | 
			
		||||
  updateBucketHeight: (bucket: string, actualBucketHeight: number) => number;
 | 
			
		||||
  cancelBucketRequest: (token: AbortController, bucketDate: string) => Promise<void>;
 | 
			
		||||
  getAdjacentAsset: (assetId: string, direction: 'next' | 'previous') => Promise<string | null>;
 | 
			
		||||
  removeAsset: (assetId: string) => void;
 | 
			
		||||
  updateAsset: (assetId: string, isFavorite: boolean) => void;
 | 
			
		||||
  subscribe: (run: (value: AssetGridState) => void, invalidate?: (value?: AssetGridState) => void) => () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createAssetStore() {
 | 
			
		||||
export function createAssetStore(): AssetStore {
 | 
			
		||||
  let _loadingBuckets: { [key: string]: boolean } = {};
 | 
			
		||||
  let _assetGridState = new AssetGridState();
 | 
			
		||||
  assetGridState.subscribe((state) => {
 | 
			
		||||
 | 
			
		||||
  const { subscribe, set, update } = writable(new AssetGridState());
 | 
			
		||||
 | 
			
		||||
  subscribe((state) => {
 | 
			
		||||
    _assetGridState = state;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let _loadingBucketState: { [key: string]: boolean } = {};
 | 
			
		||||
  loadingBucketState.subscribe((state) => {
 | 
			
		||||
    _loadingBucketState = state;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const estimateViewportHeight = (assetCount: number, viewportWidth: number): number => {
 | 
			
		||||
  const _estimateViewportHeight = (assetCount: number, viewportWidth: number): number => {
 | 
			
		||||
    // Ideally we would use the average aspect ratio for the photoset, however assume
 | 
			
		||||
    // a normal landscape aspect ratio of 3:2, then discount for the likelihood we
 | 
			
		||||
    // will be scaling down and coalescing.
 | 
			
		||||
@@ -39,25 +48,19 @@ function createAssetStore() {
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Set initial state
 | 
			
		||||
   * @param viewportHeight
 | 
			
		||||
   * @param viewportWidth
 | 
			
		||||
   * @param data
 | 
			
		||||
   */
 | 
			
		||||
  const setInitialState = (
 | 
			
		||||
    viewportHeight: number,
 | 
			
		||||
    viewportWidth: number,
 | 
			
		||||
    data: AssetCountByTimeBucketResponseDto,
 | 
			
		||||
    userId: string | undefined,
 | 
			
		||||
  ) => {
 | 
			
		||||
    assetGridState.set({
 | 
			
		||||
    set({
 | 
			
		||||
      viewportHeight,
 | 
			
		||||
      viewportWidth,
 | 
			
		||||
      timelineHeight: 0,
 | 
			
		||||
      buckets: data.buckets.map((bucket) => ({
 | 
			
		||||
        bucketDate: bucket.timeBucket,
 | 
			
		||||
        bucketHeight: estimateViewportHeight(bucket.count, viewportWidth),
 | 
			
		||||
        bucketHeight: _estimateViewportHeight(bucket.count, viewportWidth),
 | 
			
		||||
        assets: [],
 | 
			
		||||
        cancelToken: new AbortController(),
 | 
			
		||||
        position: BucketPosition.Unknown,
 | 
			
		||||
@@ -67,8 +70,7 @@ function createAssetStore() {
 | 
			
		||||
      userId,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Update timeline height based on calculated bucket height
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      state.timelineHeight = state.buckets.reduce((acc, b) => acc + b.bucketHeight, 0);
 | 
			
		||||
      return state;
 | 
			
		||||
    });
 | 
			
		||||
@@ -78,7 +80,7 @@ function createAssetStore() {
 | 
			
		||||
    try {
 | 
			
		||||
      const currentBucketData = _assetGridState.buckets.find((b) => b.bucketDate === bucket);
 | 
			
		||||
      if (currentBucketData?.assets && currentBucketData.assets.length > 0) {
 | 
			
		||||
        assetGridState.update((state) => {
 | 
			
		||||
        update((state) => {
 | 
			
		||||
          const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
			
		||||
          state.buckets[bucketIndex].position = position;
 | 
			
		||||
          return state;
 | 
			
		||||
@@ -86,10 +88,7 @@ function createAssetStore() {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      loadingBucketState.set({
 | 
			
		||||
        ..._loadingBucketState,
 | 
			
		||||
        [bucket]: true,
 | 
			
		||||
      });
 | 
			
		||||
      _loadingBuckets = { ..._loadingBuckets, [bucket]: true };
 | 
			
		||||
      const { data: assets } = await api.assetApi.getAssetByTimeBucket(
 | 
			
		||||
        {
 | 
			
		||||
          getAssetByTimeBucketDto: {
 | 
			
		||||
@@ -100,13 +99,9 @@ function createAssetStore() {
 | 
			
		||||
        },
 | 
			
		||||
        { signal: currentBucketData?.cancelToken.signal },
 | 
			
		||||
      );
 | 
			
		||||
      loadingBucketState.set({
 | 
			
		||||
        ..._loadingBucketState,
 | 
			
		||||
        [bucket]: false,
 | 
			
		||||
      });
 | 
			
		||||
      _loadingBuckets = { ..._loadingBuckets, [bucket]: false };
 | 
			
		||||
 | 
			
		||||
      // Update assetGridState with assets by time bucket
 | 
			
		||||
      assetGridState.update((state) => {
 | 
			
		||||
      update((state) => {
 | 
			
		||||
        const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
			
		||||
        state.buckets[bucketIndex].assets = assets;
 | 
			
		||||
        state.buckets[bucketIndex].position = position;
 | 
			
		||||
@@ -125,7 +120,7 @@ function createAssetStore() {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const removeAsset = (assetId: string) => {
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      const bucketIndex = state.buckets.findIndex((b) => b.assets.some((a) => a.id === assetId));
 | 
			
		||||
      const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
 | 
			
		||||
      state.buckets[bucketIndex].assets.splice(assetIndex, 1);
 | 
			
		||||
@@ -140,7 +135,7 @@ function createAssetStore() {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const _removeBucket = (bucketDate: string) => {
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
 | 
			
		||||
      state.buckets.splice(bucketIndex, 1);
 | 
			
		||||
      state.assets = state.buckets.flatMap((b) => b.assets);
 | 
			
		||||
@@ -153,7 +148,7 @@ function createAssetStore() {
 | 
			
		||||
    let scrollTimeline = false;
 | 
			
		||||
    let heightDelta = 0;
 | 
			
		||||
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucket);
 | 
			
		||||
      // Update timeline height based on the new bucket height
 | 
			
		||||
      const estimateBucketHeight = state.buckets[bucketIndex].bucketHeight;
 | 
			
		||||
@@ -177,9 +172,13 @@ function createAssetStore() {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const cancelBucketRequest = async (token: AbortController, bucketDate: string) => {
 | 
			
		||||
    if (!_loadingBuckets[bucketDate]) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    token.abort();
 | 
			
		||||
    // set new abort controller for bucket
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      const bucketIndex = state.buckets.findIndex((b) => b.bucketDate === bucketDate);
 | 
			
		||||
      state.buckets[bucketIndex].cancelToken = new AbortController();
 | 
			
		||||
      return state;
 | 
			
		||||
@@ -187,7 +186,7 @@ function createAssetStore() {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const updateAsset = (assetId: string, isFavorite: boolean) => {
 | 
			
		||||
    assetGridState.update((state) => {
 | 
			
		||||
    update((state) => {
 | 
			
		||||
      const bucketIndex = state.buckets.findIndex((b) => b.assets.some((a) => a.id === assetId));
 | 
			
		||||
      const assetIndex = state.buckets[bucketIndex].assets.findIndex((a) => a.id === assetId);
 | 
			
		||||
      state.buckets[bucketIndex].assets[assetIndex].isFavorite = isFavorite;
 | 
			
		||||
@@ -198,14 +197,72 @@ function createAssetStore() {
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const _getNextAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
 | 
			
		||||
    const currentBucket = _assetGridState.buckets[currentBucketIndex];
 | 
			
		||||
    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
 | 
			
		||||
    if (assetIndex === -1) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (assetIndex + 1 < currentBucket.assets.length) {
 | 
			
		||||
      return currentBucket.assets[assetIndex + 1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextBucketIndex = currentBucketIndex + 1;
 | 
			
		||||
    if (nextBucketIndex >= _assetGridState.buckets.length) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const nextBucket = _assetGridState.buckets[nextBucketIndex];
 | 
			
		||||
    await getAssetsByBucket(nextBucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
    return nextBucket.assets[0] ?? null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const _getPrevAsset = async (currentBucketIndex: number, assetId: string): Promise<AssetResponseDto | null> => {
 | 
			
		||||
    const currentBucket = _assetGridState.buckets[currentBucketIndex];
 | 
			
		||||
    const assetIndex = currentBucket.assets.findIndex(({ id }) => id == assetId);
 | 
			
		||||
    if (assetIndex === -1) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (assetIndex > 0) {
 | 
			
		||||
      return currentBucket.assets[assetIndex - 1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevBucketIndex = currentBucketIndex - 1;
 | 
			
		||||
    if (prevBucketIndex < 0) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevBucket = _assetGridState.buckets[prevBucketIndex];
 | 
			
		||||
    await getAssetsByBucket(prevBucket.bucketDate, BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
    return prevBucket.assets[prevBucket.assets.length - 1] ?? null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getAdjacentAsset = async (assetId: string, direction: 'next' | 'previous'): Promise<string | null> => {
 | 
			
		||||
    const currentBucketIndex = _assetGridState.loadedAssets[assetId];
 | 
			
		||||
    if (currentBucketIndex < 0 || currentBucketIndex >= _assetGridState.buckets.length) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const asset =
 | 
			
		||||
      direction === 'next'
 | 
			
		||||
        ? await _getNextAsset(currentBucketIndex, assetId)
 | 
			
		||||
        : await _getPrevAsset(currentBucketIndex, assetId);
 | 
			
		||||
 | 
			
		||||
    return asset?.id ?? null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    setInitialState,
 | 
			
		||||
    getAssetsByBucket,
 | 
			
		||||
    removeAsset,
 | 
			
		||||
    updateBucketHeight,
 | 
			
		||||
    cancelBucketRequest,
 | 
			
		||||
    getAdjacentAsset,
 | 
			
		||||
    updateAsset,
 | 
			
		||||
    subscribe,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const assetStore = createAssetStore();
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,6 @@
 | 
			
		||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
			
		||||
  import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
 | 
			
		||||
  import Portal from '$lib/components/shared-components/portal/portal.svelte';
 | 
			
		||||
  import {
 | 
			
		||||
    assetInteractionStore,
 | 
			
		||||
    isViewingAssetStoreState,
 | 
			
		||||
    viewingAssetStoreState,
 | 
			
		||||
  } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { mapSettings } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { MapMarkerResponseDto, api } from '@api';
 | 
			
		||||
  import { isEqual, omit } from 'lodash-es';
 | 
			
		||||
@@ -15,9 +10,12 @@
 | 
			
		||||
  import Cog from 'svelte-material-icons/Cog.svelte';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
  import { DateTime, Duration } from 'luxon';
 | 
			
		||||
  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 | 
			
		||||
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
 | 
			
		||||
  let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 | 
			
		||||
 | 
			
		||||
  let leaflet: typeof import('$lib/components/shared-components/leaflet');
 | 
			
		||||
  let mapMarkers: MapMarkerResponseDto[] = [];
 | 
			
		||||
  let abortController: AbortController;
 | 
			
		||||
@@ -34,8 +32,7 @@
 | 
			
		||||
    if (abortController) {
 | 
			
		||||
      abortController.abort();
 | 
			
		||||
    }
 | 
			
		||||
    assetInteractionStore.clearMultiselect();
 | 
			
		||||
    assetInteractionStore.setIsViewingAsset(false);
 | 
			
		||||
    assetViewingStore.showAssetViewer(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  async function loadMapMarkers() {
 | 
			
		||||
@@ -83,20 +80,20 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function onViewAssets(assetIds: string[], activeAssetIndex: number) {
 | 
			
		||||
    assetInteractionStore.setViewingAssetId(assetIds[activeAssetIndex]);
 | 
			
		||||
    assetViewingStore.setAssetId(assetIds[activeAssetIndex]);
 | 
			
		||||
    viewingAssets = assetIds;
 | 
			
		||||
    viewingAssetCursor = activeAssetIndex;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function navigateNext() {
 | 
			
		||||
    if (viewingAssetCursor < viewingAssets.length - 1) {
 | 
			
		||||
      assetInteractionStore.setViewingAssetId(viewingAssets[++viewingAssetCursor]);
 | 
			
		||||
      assetViewingStore.setAssetId(viewingAssets[++viewingAssetCursor]);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function navigatePrevious() {
 | 
			
		||||
    if (viewingAssetCursor > 0) {
 | 
			
		||||
      assetInteractionStore.setViewingAssetId(viewingAssets[--viewingAssetCursor]);
 | 
			
		||||
      assetViewingStore.setAssetId(viewingAssets[--viewingAssetCursor]);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
</script>
 | 
			
		||||
@@ -142,14 +139,14 @@
 | 
			
		||||
</UserPageLayout>
 | 
			
		||||
 | 
			
		||||
<Portal target="body">
 | 
			
		||||
  {#if $isViewingAssetStoreState}
 | 
			
		||||
  {#if $showAssetViewer}
 | 
			
		||||
    <AssetViewer
 | 
			
		||||
      asset={$viewingAssetStoreState}
 | 
			
		||||
      asset={$viewingAsset}
 | 
			
		||||
      showNavigation={viewingAssets.length > 1}
 | 
			
		||||
      on:navigate-next={navigateNext}
 | 
			
		||||
      on:navigate-previous={navigatePrevious}
 | 
			
		||||
      on:close={() => {
 | 
			
		||||
        assetInteractionStore.setIsViewingAsset(false);
 | 
			
		||||
        assetViewingStore.showAssetViewer(false);
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  {/if}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,21 +8,26 @@
 | 
			
		||||
  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 { assetInteractionStore, isMultiSelectStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  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 assetInteractionStore = createAssetInteractionStore();
 | 
			
		||||
  const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  onDestroy(() => {
 | 
			
		||||
    assetInteractionStore.clearMultiselect();
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg">
 | 
			
		||||
  {#if $isMultiSelectStoreState}
 | 
			
		||||
  {#if $isMultiSelectState}
 | 
			
		||||
    <AssetSelectControlBar assets={$selectedAssets} clearSelect={assetInteractionStore.clearMultiselect}>
 | 
			
		||||
      <DownloadAction />
 | 
			
		||||
    </AssetSelectControlBar>
 | 
			
		||||
@@ -44,5 +49,5 @@
 | 
			
		||||
      </svelte:fragment>
 | 
			
		||||
    </ControlAppBar>
 | 
			
		||||
  {/if}
 | 
			
		||||
  <AssetGrid user={data.partner} />
 | 
			
		||||
  <AssetGrid {assetStore} {assetInteractionStore} user={data.partner} />
 | 
			
		||||
</main>
 | 
			
		||||
 
 | 
			
		||||
@@ -11,8 +11,8 @@
 | 
			
		||||
  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 { assetInteractionStore, isMultiSelectStoreState, selectedAssets } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetStore } from '$lib/stores/assets.store';
 | 
			
		||||
  import { createAssetStore } 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 { onDestroy, onMount } from 'svelte';
 | 
			
		||||
@@ -23,6 +23,10 @@
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
  let assetCount = 1;
 | 
			
		||||
 | 
			
		||||
  const assetStore = createAssetStore();
 | 
			
		||||
  const assetInteractionStore = createAssetInteractionStore();
 | 
			
		||||
  const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const { data: stats } = await api.assetApi.getAssetStats();
 | 
			
		||||
    assetCount = stats.total;
 | 
			
		||||
@@ -39,12 +43,12 @@
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<UserPageLayout user={data.user} hideNavbar={$isMultiSelectStoreState} showUploadButton>
 | 
			
		||||
<UserPageLayout user={data.user} hideNavbar={$isMultiSelectState} showUploadButton>
 | 
			
		||||
  <svelte:fragment slot="header">
 | 
			
		||||
    {#if $isMultiSelectStoreState}
 | 
			
		||||
    {#if $isMultiSelectState}
 | 
			
		||||
      <AssetSelectControlBar assets={$selectedAssets} clearSelect={assetInteractionStore.clearMultiselect}>
 | 
			
		||||
        <CreateSharedLink />
 | 
			
		||||
        <SelectAllAssets />
 | 
			
		||||
        <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
			
		||||
        <AssetSelectContextMenu icon={Plus} title="Add">
 | 
			
		||||
          <AddToAlbum />
 | 
			
		||||
          <AddToAlbum shared />
 | 
			
		||||
@@ -60,7 +64,7 @@
 | 
			
		||||
  </svelte:fragment>
 | 
			
		||||
  <svelte:fragment slot="content">
 | 
			
		||||
    {#if assetCount}
 | 
			
		||||
      <AssetGrid showMemoryLane />
 | 
			
		||||
      <AssetGrid {assetStore} {assetInteractionStore} showMemoryLane />
 | 
			
		||||
    {:else}
 | 
			
		||||
      <EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" actionHandler={handleUpload} />
 | 
			
		||||
    {/if}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,10 +25,12 @@
 | 
			
		||||
  import { flip } from 'svelte/animate';
 | 
			
		||||
  import { onDestroy, onMount } from 'svelte';
 | 
			
		||||
  import { browser } from '$app/environment';
 | 
			
		||||
  import { isViewingAssetStoreState } from '$lib/stores/asset-interaction.store';
 | 
			
		||||
  import { assetViewingStore } from '$lib/stores/asset-viewing.store';
 | 
			
		||||
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
 | 
			
		||||
  let { isViewing: showAssetViewer } = assetViewingStore;
 | 
			
		||||
 | 
			
		||||
  // The GalleryViewer pushes it's own history state, which causes weird
 | 
			
		||||
  // behavior for history.back(). To prevent that we store the previous page
 | 
			
		||||
  // manually and navigate back to that.
 | 
			
		||||
@@ -48,7 +50,7 @@
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleKeyboardPress = (event: KeyboardEvent) => {
 | 
			
		||||
    if (!$isViewingAssetStoreState) {
 | 
			
		||||
    if (!$showAssetViewer) {
 | 
			
		||||
      switch (event.key) {
 | 
			
		||||
        case 'Escape':
 | 
			
		||||
          goto(previousRoute);
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user