mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(web): Add keyboard shortcut selection on grid (#16713)
* 15712: Added keyboard shortcuts for opening add to album modal and highlighting/selecting an album to add to. * 15712: Re-factored logic from template code into script. Extracted new album button into separate cmponent. * 15712: Document new keyboard shortucts now that they work everywhere. * 15712: Extract some constants/helper functions. * 15712: Missing comma. * 15712: Pulled logic out into separate unit testable class. * 15712: Added a unit test. * 15712: Move the modal back up to keep the github PR happy. * 15712: PR feedback - renamed typescript files and switch to class bind directive. * 15712:Move selection modal into correct package. * 15712: Better naming of module and files. * 15712: Add asset highlight using arrow keys. * 15172: Add escape behaviour everywhere. * 15712: Don't allow highlighting past start or end. * 15712: Clear the highlight on changes to the component state. * 15712: Use focus to track highlighted element. * 15712: Rename highlight -> focussed. * 15712: Better naming. * 15712: Cleanup. * 15712: Cleanup & simplify. * 15712: bugfix for clicking on button. * 15712: Cleanup. * 15712: Rollback unnecessary changes. * 15712: Add unit test. * 15712: Add thumbnail unit test. * 15712: Prettier. * 15712: Fix merge issue. * 15712: Add shortcut info. * 15712: Fix linter.
This commit is contained in:
		
							parent
							
								
									c80afea468
								
							
						
					
					
						commit
						b8acae2f21
					
				@ -1150,6 +1150,7 @@
 | 
			
		||||
  "second": "Second",
 | 
			
		||||
  "see_all_people": "See all people",
 | 
			
		||||
  "select_album_cover": "Select album cover",
 | 
			
		||||
  "select": "Select",
 | 
			
		||||
  "select_all": "Select all",
 | 
			
		||||
  "select_all_duplicates": "Select all duplicates",
 | 
			
		||||
  "select_avatar_color": "Select avatar color",
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,63 @@
 | 
			
		||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
 | 
			
		||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
 | 
			
		||||
import { assetFactory } from '@test-data/factories/asset-factory';
 | 
			
		||||
import { fireEvent, render, screen } from '@testing-library/svelte';
 | 
			
		||||
 | 
			
		||||
describe('Thumbnail component', () => {
 | 
			
		||||
  beforeAll(() => {
 | 
			
		||||
    vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should only contain a single tabbable element (the container)', () => {
 | 
			
		||||
    const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
 | 
			
		||||
    render(Thumbnail, {
 | 
			
		||||
      asset,
 | 
			
		||||
      focussed: false,
 | 
			
		||||
      overrideDisplayForTest: true,
 | 
			
		||||
      selected: true,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const container = screen.getByTestId('container-with-tabindex');
 | 
			
		||||
    expect(container.getAttribute('tabindex')).toBe('0');
 | 
			
		||||
 | 
			
		||||
    // This isn't capturing all tabbable elements, but should be the most likely ones. Mainly guarding against
 | 
			
		||||
    // inserting extra tabbable elments in future in <Thumbnail/>
 | 
			
		||||
    let allTabbableElements = screen.queryAllByRole('link');
 | 
			
		||||
    allTabbableElements = allTabbableElements.concat(screen.queryAllByRole('checkbox'));
 | 
			
		||||
    expect(allTabbableElements.length).toBeGreaterThan(0);
 | 
			
		||||
    for (const tabbableElement of allTabbableElements) {
 | 
			
		||||
      const testIdValue = tabbableElement.dataset.testid;
 | 
			
		||||
      if (testIdValue === null || testIdValue !== 'container-with-tabindex') {
 | 
			
		||||
        expect(tabbableElement.getAttribute('tabindex')).toBe('-1');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('handleFocus should be called on focus of container', async () => {
 | 
			
		||||
    const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
 | 
			
		||||
    const handleFocusSpy = vi.fn();
 | 
			
		||||
    render(Thumbnail, {
 | 
			
		||||
      asset,
 | 
			
		||||
      overrideDisplayForTest: true,
 | 
			
		||||
      handleFocus: handleFocusSpy,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const container = screen.getByTestId('container-with-tabindex');
 | 
			
		||||
    await fireEvent(container, new FocusEvent('focus'));
 | 
			
		||||
 | 
			
		||||
    expect(handleFocusSpy).toBeCalled();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('element will be focussed if not already', () => {
 | 
			
		||||
    const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
 | 
			
		||||
    const handleFocusSpy = vi.fn();
 | 
			
		||||
    render(Thumbnail, {
 | 
			
		||||
      asset,
 | 
			
		||||
      overrideDisplayForTest: true,
 | 
			
		||||
      focussed: true,
 | 
			
		||||
      handleFocus: handleFocusSpy,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(handleFocusSpy).toBeCalled();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -40,6 +40,7 @@
 | 
			
		||||
    thumbnailWidth?: number | undefined;
 | 
			
		||||
    thumbnailHeight?: number | undefined;
 | 
			
		||||
    selected?: boolean;
 | 
			
		||||
    focussed?: boolean;
 | 
			
		||||
    selectionCandidate?: boolean;
 | 
			
		||||
    disabled?: boolean;
 | 
			
		||||
    readonly?: boolean;
 | 
			
		||||
@ -60,7 +61,9 @@
 | 
			
		||||
    onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
 | 
			
		||||
    onSelect?: ((asset: AssetResponseDto) => void) | undefined;
 | 
			
		||||
    onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
 | 
			
		||||
    handleFocus?: (() => void) | undefined;
 | 
			
		||||
    class?: string;
 | 
			
		||||
    overrideDisplayForTest?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let {
 | 
			
		||||
@ -72,6 +75,7 @@
 | 
			
		||||
    thumbnailWidth = undefined,
 | 
			
		||||
    thumbnailHeight = undefined,
 | 
			
		||||
    selected = false,
 | 
			
		||||
    focussed = false,
 | 
			
		||||
    selectionCandidate = false,
 | 
			
		||||
    disabled = false,
 | 
			
		||||
    readonly = false,
 | 
			
		||||
@ -85,7 +89,9 @@
 | 
			
		||||
    onRetrieveElement = undefined,
 | 
			
		||||
    onSelect = undefined,
 | 
			
		||||
    onMouseEvent = undefined,
 | 
			
		||||
    handleFocus = undefined,
 | 
			
		||||
    class: className = '',
 | 
			
		||||
    overrideDisplayForTest = false,
 | 
			
		||||
  }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  let {
 | 
			
		||||
@ -94,6 +100,7 @@
 | 
			
		||||
 | 
			
		||||
  const componentId = generateId();
 | 
			
		||||
  let element: HTMLElement | undefined = $state();
 | 
			
		||||
  let focussableElement: HTMLElement | undefined = $state();
 | 
			
		||||
  let mouseOver = $state(false);
 | 
			
		||||
  let intersecting = $state(false);
 | 
			
		||||
  let lastRetrievedElement: HTMLElement | undefined = $state();
 | 
			
		||||
@ -111,6 +118,12 @@
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $effect(() => {
 | 
			
		||||
    if (focussed && document.activeElement !== focussableElement) {
 | 
			
		||||
      focussableElement?.focus();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let width = $derived(thumbnailSize || thumbnailWidth || 235);
 | 
			
		||||
  let height = $derived(thumbnailSize || thumbnailHeight || 235);
 | 
			
		||||
  let display = $derived(intersecting);
 | 
			
		||||
@ -217,7 +230,7 @@
 | 
			
		||||
    ></canvas>
 | 
			
		||||
  {/if}
 | 
			
		||||
 | 
			
		||||
  {#if display}
 | 
			
		||||
  {#if display || overrideDisplayForTest}
 | 
			
		||||
    <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
 | 
			
		||||
     the navigation url on scroll. Replace this with button for now. -->
 | 
			
		||||
    <div
 | 
			
		||||
@ -226,14 +239,20 @@
 | 
			
		||||
      class:cursor-pointer={!disabled}
 | 
			
		||||
      onmouseenter={onMouseEnter}
 | 
			
		||||
      onmouseleave={onMouseLeave}
 | 
			
		||||
      onkeypress={(evt) => {
 | 
			
		||||
      onkeydown={(evt) => {
 | 
			
		||||
        if (evt.key === 'Enter') {
 | 
			
		||||
          callClickHandlers();
 | 
			
		||||
        }
 | 
			
		||||
        if (evt.key === 'x') {
 | 
			
		||||
          onSelect?.(asset);
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
      tabindex={0}
 | 
			
		||||
      onclick={handleClick}
 | 
			
		||||
      role="link"
 | 
			
		||||
      bind:this={focussableElement}
 | 
			
		||||
      onfocus={handleFocus}
 | 
			
		||||
      data-testid="container-with-tabindex"
 | 
			
		||||
    >
 | 
			
		||||
      {#if mouseOver && !disableMouseOver}
 | 
			
		||||
        <!-- lazy show the url on mouse over-->
 | 
			
		||||
@ -244,7 +263,7 @@
 | 
			
		||||
          style:height="{height}px"
 | 
			
		||||
          href={currentUrlReplaceAssetId(asset.id)}
 | 
			
		||||
          onclick={(evt) => evt.preventDefault()}
 | 
			
		||||
          tabindex={0}
 | 
			
		||||
          tabindex={-1}
 | 
			
		||||
          aria-label="Thumbnail URL"
 | 
			
		||||
        >
 | 
			
		||||
        </a>
 | 
			
		||||
@ -258,6 +277,8 @@
 | 
			
		||||
            class="absolute p-2 focus:outline-none"
 | 
			
		||||
            class:cursor-not-allowed={disabled}
 | 
			
		||||
            role="checkbox"
 | 
			
		||||
            tabindex={-1}
 | 
			
		||||
            onfocus={handleFocus}
 | 
			
		||||
            aria-checked={selected}
 | 
			
		||||
            {disabled}
 | 
			
		||||
          >
 | 
			
		||||
 | 
			
		||||
@ -93,6 +93,10 @@
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const assetOnFocusHandler = (asset: AssetResponseDto) => {
 | 
			
		||||
    assetInteraction.focussedAssetId = asset.id;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onDestroy(() => {
 | 
			
		||||
    assetStore.taskManager.removeAllTasksForComponent(componentId);
 | 
			
		||||
  });
 | 
			
		||||
@ -223,6 +227,8 @@
 | 
			
		||||
                  onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
 | 
			
		||||
                  onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
 | 
			
		||||
                  selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
 | 
			
		||||
                  handleFocus={() => assetOnFocusHandler(asset)}
 | 
			
		||||
                  focussed={assetInteraction.isFocussedAsset(asset)}
 | 
			
		||||
                  selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
 | 
			
		||||
                  disabled={assetStore.albumAssets.has(asset.id)}
 | 
			
		||||
                  thumbnailWidth={width}
 | 
			
		||||
 | 
			
		||||
@ -706,6 +706,36 @@
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const focusNextAsset = async () => {
 | 
			
		||||
    if (assetInteraction.focussedAssetId === null) {
 | 
			
		||||
      const firstAsset = assetStore.getFirstAsset();
 | 
			
		||||
      if (firstAsset !== null) {
 | 
			
		||||
        assetInteraction.focussedAssetId = firstAsset.id;
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
 | 
			
		||||
      if (focussedAsset) {
 | 
			
		||||
        const nextAsset = await assetStore.getNextAsset(focussedAsset);
 | 
			
		||||
        if (nextAsset !== null) {
 | 
			
		||||
          assetInteraction.focussedAssetId = nextAsset.id;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const focusPreviousAsset = async () => {
 | 
			
		||||
    if (assetInteraction.focussedAssetId !== null) {
 | 
			
		||||
      const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
 | 
			
		||||
      if (focussedAsset) {
 | 
			
		||||
        const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
 | 
			
		||||
        if (previousAsset) {
 | 
			
		||||
          assetInteraction.focussedAssetId = previousAsset.id;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onDestroy(() => {
 | 
			
		||||
    assetStore.taskManager.removeAllTasksForComponent(componentId);
 | 
			
		||||
  });
 | 
			
		||||
@ -749,6 +779,8 @@
 | 
			
		||||
        { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
 | 
			
		||||
        { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
 | 
			
		||||
        { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
 | 
			
		||||
        { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
 | 
			
		||||
        { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      if (assetInteraction.selectionActive) {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
 | 
			
		||||
  import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
 | 
			
		||||
  import { goto } from '$app/navigation';
 | 
			
		||||
  import type { Action } from '$lib/components/asset-viewer/actions/action';
 | 
			
		||||
  import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
 | 
			
		||||
@ -178,6 +178,26 @@
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const focusNextAsset = () => {
 | 
			
		||||
    if (assetInteraction.focussedAssetId === null && assets.length > 0) {
 | 
			
		||||
      assetInteraction.focussedAssetId = assets[0].id;
 | 
			
		||||
    } else if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
 | 
			
		||||
      const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
 | 
			
		||||
      if (currentIndex !== -1 && currentIndex + 1 < assets.length) {
 | 
			
		||||
        assetInteraction.focussedAssetId = assets[currentIndex + 1].id;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const focusPreviousAsset = () => {
 | 
			
		||||
    if (assetInteraction.focussedAssetId !== null && assets.length > 0) {
 | 
			
		||||
      const currentIndex = assets.findIndex((a) => a.id === assetInteraction.focussedAssetId);
 | 
			
		||||
      if (currentIndex >= 1) {
 | 
			
		||||
        assetInteraction.focussedAssetId = assets[currentIndex - 1].id;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let shortcutList = $derived(
 | 
			
		||||
    (() => {
 | 
			
		||||
      if ($isViewerOpen) {
 | 
			
		||||
@ -188,6 +208,8 @@
 | 
			
		||||
        { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
 | 
			
		||||
        { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
 | 
			
		||||
        { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
 | 
			
		||||
        { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
 | 
			
		||||
        { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      if (assetInteraction.selectionActive) {
 | 
			
		||||
@ -306,6 +328,10 @@
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const assetOnFocusHandler = (asset: AssetResponseDto) => {
 | 
			
		||||
    assetInteraction.focussedAssetId = asset.id;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
 | 
			
		||||
  let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
 | 
			
		||||
 | 
			
		||||
@ -382,10 +408,12 @@
 | 
			
		||||
          }}
 | 
			
		||||
          onSelect={(asset) => handleSelectAssets(asset)}
 | 
			
		||||
          onMouseEvent={() => assetMouseEventHandler(asset)}
 | 
			
		||||
          handleFocus={() => assetOnFocusHandler(asset)}
 | 
			
		||||
          onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
 | 
			
		||||
          {showArchiveIcon}
 | 
			
		||||
          {asset}
 | 
			
		||||
          selected={assetInteraction.selectedAssets.has(asset)}
 | 
			
		||||
          focussed={assetInteraction.isFocussedAsset(asset)}
 | 
			
		||||
          selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
 | 
			
		||||
          thumbnailWidth={geometry.boxes[i].width}
 | 
			
		||||
          thumbnailHeight={geometry.boxes[i].height}
 | 
			
		||||
 | 
			
		||||
@ -25,6 +25,7 @@
 | 
			
		||||
    shortcuts = {
 | 
			
		||||
      general: [
 | 
			
		||||
        { key: ['←', '→'], action: $t('previous_or_next_photo') },
 | 
			
		||||
        { key: ['x'], action: $t('select') },
 | 
			
		||||
        { key: ['Esc'], action: $t('back_close_deselect') },
 | 
			
		||||
        { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
 | 
			
		||||
        { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ export class AssetInteraction {
 | 
			
		||||
  readonly selectedGroup = new SvelteSet<string>();
 | 
			
		||||
  assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>());
 | 
			
		||||
  assetSelectionStart = $state<AssetResponseDto | null>(null);
 | 
			
		||||
  focussedAssetId = $state<string | null>(null);
 | 
			
		||||
 | 
			
		||||
  selectionActive = $derived(this.selectedAssets.size > 0);
 | 
			
		||||
  selectedAssetsArray = $derived([...this.selectedAssets]);
 | 
			
		||||
@ -63,4 +64,8 @@ export class AssetInteraction {
 | 
			
		||||
    this.assetSelectionCandidates.clear();
 | 
			
		||||
    this.assetSelectionStart = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isFocussedAsset(asset: AssetResponseDto) {
 | 
			
		||||
    return this.focussedAssetId === asset.id;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -308,6 +308,34 @@ describe('AssetStore', () => {
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('firstAsset', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([]);
 | 
			
		||||
      await assetStore.init();
 | 
			
		||||
      await assetStore.updateViewport({ width: 0, height: 0 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('empty store returns null', () => {
 | 
			
		||||
      expect(assetStore.getFirstAsset()).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('populated store returns first asset', () => {
 | 
			
		||||
      const assetOne = assetFactory.build({
 | 
			
		||||
        fileCreatedAt: '2024-01-20T12:00:00.000Z',
 | 
			
		||||
        localDateTime: '2024-01-20T12:00:00.000Z',
 | 
			
		||||
      });
 | 
			
		||||
      const assetTwo = assetFactory.build({
 | 
			
		||||
        fileCreatedAt: '2024-01-15T12:00:00.000Z',
 | 
			
		||||
        localDateTime: '2024-01-15T12:00:00.000Z',
 | 
			
		||||
      });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo]);
 | 
			
		||||
      expect(assetStore.getFirstAsset()).toEqual(assetOne);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getPreviousAsset', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
    const bucketAssets: Record<string, AssetResponseDto[]> = {
 | 
			
		||||
 | 
			
		||||
@ -848,6 +848,13 @@ export class AssetStore {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getFirstAsset(): AssetResponseDto | null {
 | 
			
		||||
    if (this.buckets.length > 0 && this.buckets[0].assets.length > 0) {
 | 
			
		||||
      return this.buckets[0].assets[0];
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
 | 
			
		||||
    const info = await this.getBucketInfoForAsset(asset);
 | 
			
		||||
    if (!info) {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user