mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	fix(web): minor album card issues (#7975)
* fix(web): minor album card issues * fix album grid gap
This commit is contained in:
		
							parent
							
								
									0f79c4ff46
								
							
						
					
					
						commit
						cfb14ca80b
					
				@ -13,6 +13,7 @@ vi.mock('@immich/sdk', async (originalImport) => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const sdkMock: MockedObject<typeof sdk> = sdk as MockedObject<typeof sdk>;
 | 
			
		||||
const onShowContextMenu = vi.fn();
 | 
			
		||||
 | 
			
		||||
describe('AlbumCard component', () => {
 | 
			
		||||
  let sut: RenderResult<AlbumCard>;
 | 
			
		||||
@ -90,34 +91,30 @@ describe('AlbumCard component', () => {
 | 
			
		||||
    expect(albumDetailsElement).toHaveTextContent('0 items');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('hides context menu when "onShowContextMenu" is undefined', () => {
 | 
			
		||||
    const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
 | 
			
		||||
    sut = render(AlbumCard, { album });
 | 
			
		||||
 | 
			
		||||
    const contextButtonParent = sut.queryByTestId('context-button-parent');
 | 
			
		||||
    expect(contextButtonParent).not.toBeInTheDocument();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('with rendered component - no thumbnail', () => {
 | 
			
		||||
    const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      sut = render(AlbumCard, { album });
 | 
			
		||||
      sut = render(AlbumCard, { album, onShowContextMenu });
 | 
			
		||||
 | 
			
		||||
      const albumImgElement = sut.getByTestId('album-image');
 | 
			
		||||
      await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('dispatches custom "click" event with the album in context', async () => {
 | 
			
		||||
      const onClickHandler = vi.fn();
 | 
			
		||||
      sut.component.$on('click', onClickHandler);
 | 
			
		||||
      const albumCardElement = sut.getByTestId('album-card');
 | 
			
		||||
 | 
			
		||||
      await fireEvent.click(albumCardElement);
 | 
			
		||||
      expect(onClickHandler).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
 | 
			
		||||
      const onClickHandler = vi.fn();
 | 
			
		||||
      sut.component.$on('showalbumcontextmenu', onClickHandler);
 | 
			
		||||
 | 
			
		||||
      const contextMenuButtonParent = sut.getByTestId('context-button-parent');
 | 
			
		||||
    it('dispatches "onShowContextMenu" event on context menu click with mouse coordinates', async () => {
 | 
			
		||||
      const contextMenuButton = sut.getByTestId('context-button-parent').children[0];
 | 
			
		||||
      expect(contextMenuButton).toBeDefined();
 | 
			
		||||
 | 
			
		||||
      // Mock getBoundingClientRect to return a bounding rectangle that will result in the expected position
 | 
			
		||||
      contextMenuButtonParent.getBoundingClientRect = () => ({
 | 
			
		||||
      contextMenuButton.getBoundingClientRect = () => ({
 | 
			
		||||
        x: 123,
 | 
			
		||||
        y: 456,
 | 
			
		||||
        width: 0,
 | 
			
		||||
@ -130,14 +127,14 @@ describe('AlbumCard component', () => {
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      await fireEvent(
 | 
			
		||||
        contextMenuButtonParent,
 | 
			
		||||
        contextMenuButton,
 | 
			
		||||
        new MouseEvent('click', {
 | 
			
		||||
          clientX: 123,
 | 
			
		||||
          clientY: 456,
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(onClickHandler).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } }));
 | 
			
		||||
      expect(onShowContextMenu).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -5,25 +5,20 @@
 | 
			
		||||
  import { getAssetThumbnailUrl } from '$lib/utils';
 | 
			
		||||
  import { ThumbnailFormat, getAssetThumbnail, getUserById, type AlbumResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { mdiDotsVertical } from '@mdi/js';
 | 
			
		||||
  import { createEventDispatcher, onMount } from 'svelte';
 | 
			
		||||
  import { getContextMenuPosition } from '../../utils/context-menu';
 | 
			
		||||
  import { onMount } from 'svelte';
 | 
			
		||||
  import { getContextMenuPosition, type ContextMenuPosition } from '../../utils/context-menu';
 | 
			
		||||
  import IconButton from '../elements/buttons/icon-button.svelte';
 | 
			
		||||
  import type { OnClick, OnShowContextMenu } from './album-card';
 | 
			
		||||
 | 
			
		||||
  export let album: AlbumResponseDto;
 | 
			
		||||
  export let isSharingView = false;
 | 
			
		||||
  export let showItemCount = true;
 | 
			
		||||
  export let showContextMenu = true;
 | 
			
		||||
  export let preload = false;
 | 
			
		||||
  let showVerticalDots = false;
 | 
			
		||||
  export let onShowContextMenu: ((position: ContextMenuPosition) => void) | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
  $: imageData = album.albumThumbnailAssetId
 | 
			
		||||
    ? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
 | 
			
		||||
    : null;
 | 
			
		||||
 | 
			
		||||
  const dispatchClick = createEventDispatcher<OnClick>();
 | 
			
		||||
  const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
 | 
			
		||||
 | 
			
		||||
  const loadHighQualityThumbnail = async (assetId: string | null) => {
 | 
			
		||||
    if (!assetId) {
 | 
			
		||||
      return;
 | 
			
		||||
@ -33,8 +28,11 @@
 | 
			
		||||
    return URL.createObjectURL(data);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const showAlbumContextMenu = (e: MouseEvent) =>
 | 
			
		||||
    dispatchShowContextMenu('showalbumcontextmenu', getContextMenuPosition(e));
 | 
			
		||||
  const showAlbumContextMenu = (e: MouseEvent) => {
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    onShowContextMenu?.(getContextMenuPosition(e));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || null;
 | 
			
		||||
@ -43,25 +41,17 @@
 | 
			
		||||
  const getAlbumOwnerInfo = () => getUserById({ id: album.ownerId });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
 | 
			
		||||
<div
 | 
			
		||||
  class="group relative mt-4 rounded-2xl border-[1px] border-transparent p-5 hover:cursor-pointer hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
 | 
			
		||||
  on:click={() => dispatchClick('click', album)}
 | 
			
		||||
  on:keydown={() => dispatchClick('click', album)}
 | 
			
		||||
  on:mouseenter={() => (showVerticalDots = true)}
 | 
			
		||||
  on:mouseleave={() => (showVerticalDots = false)}
 | 
			
		||||
  class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
 | 
			
		||||
  data-testid="album-card"
 | 
			
		||||
>
 | 
			
		||||
  <!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
			
		||||
  {#if showContextMenu}
 | 
			
		||||
  {#if onShowContextMenu}
 | 
			
		||||
    <div
 | 
			
		||||
      id={`icon-${album.id}`}
 | 
			
		||||
      class="absolute right-6 top-6 z-10"
 | 
			
		||||
      on:click|stopPropagation|preventDefault={showAlbumContextMenu}
 | 
			
		||||
      class:hidden={!showVerticalDots}
 | 
			
		||||
      id="icon-{album.id}"
 | 
			
		||||
      class="absolute right-6 top-6 z-10 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
 | 
			
		||||
      data-testid="context-button-parent"
 | 
			
		||||
    >
 | 
			
		||||
      <IconButton color="transparent-primary">
 | 
			
		||||
      <IconButton color="transparent-primary" title="Show album options" on:click={showAlbumContextMenu}>
 | 
			
		||||
        <Icon path={mdiDotsVertical} size="20" class="icon-white-drop-shadow text-white" />
 | 
			
		||||
      </IconButton>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@ -1,12 +0,0 @@
 | 
			
		||||
import type { AlbumResponseDto } from '@immich/sdk';
 | 
			
		||||
 | 
			
		||||
export type OnShowContextMenu = {
 | 
			
		||||
  showalbumcontextmenu: OnShowContextMenuDetail;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type OnClick = {
 | 
			
		||||
  click: OnClickDetail;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type OnShowContextMenuDetail = { x: number; y: number };
 | 
			
		||||
export type OnClickDetail = AlbumResponseDto;
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
<script lang="ts" context="module">
 | 
			
		||||
  import { AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
 | 
			
		||||
  import { goto } from '$app/navigation';
 | 
			
		||||
  import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card';
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import { createAlbum, deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { get } from 'svelte/store';
 | 
			
		||||
@ -118,6 +117,7 @@
 | 
			
		||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
			
		||||
  import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import type { ContextMenuPosition } from '$lib/utils/context-menu';
 | 
			
		||||
 | 
			
		||||
  export let albums: AlbumResponseDto[];
 | 
			
		||||
  export let searchAlbum: string;
 | 
			
		||||
@ -125,7 +125,7 @@
 | 
			
		||||
  let shouldShowEditAlbumForm = false;
 | 
			
		||||
  let selectedAlbum: AlbumResponseDto;
 | 
			
		||||
  let albumToDelete: AlbumResponseDto | null;
 | 
			
		||||
  let contextMenuPosition: OnShowContextMenuDetail = { x: 0, y: 0 };
 | 
			
		||||
  let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
 | 
			
		||||
  let contextMenuTargetAlbum: AlbumResponseDto | undefined = undefined;
 | 
			
		||||
 | 
			
		||||
  $: {
 | 
			
		||||
@ -145,7 +145,7 @@
 | 
			
		||||
    await removeAlbumsIfEmpty();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void {
 | 
			
		||||
  function showAlbumContextMenu(contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto): void {
 | 
			
		||||
    contextMenuTargetAlbum = album;
 | 
			
		||||
    contextMenuPosition = {
 | 
			
		||||
      x: contextMenuDetail.x,
 | 
			
		||||
@ -228,13 +228,13 @@
 | 
			
		||||
{#if albums.length > 0}
 | 
			
		||||
  <!-- Album Card -->
 | 
			
		||||
  {#if $albumViewSettings.view === AlbumViewMode.Cover}
 | 
			
		||||
    <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
 | 
			
		||||
    <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
 | 
			
		||||
      {#each albumsFiltered as album, index (album.id)}
 | 
			
		||||
        <a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
 | 
			
		||||
          <AlbumCard
 | 
			
		||||
            preload={index < 20}
 | 
			
		||||
            {album}
 | 
			
		||||
            on:showalbumcontextmenu={({ detail }) => showAlbumContextMenu(detail, album)}
 | 
			
		||||
            onShowContextMenu={(position) => showAlbumContextMenu(position, album)}
 | 
			
		||||
          />
 | 
			
		||||
        </a>
 | 
			
		||||
      {/each}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
export type Align = 'middle' | 'top-left' | 'top-right';
 | 
			
		||||
 | 
			
		||||
export const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle') => {
 | 
			
		||||
export type ContextMenuPosition = { x: number; y: number };
 | 
			
		||||
 | 
			
		||||
export const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle'): ContextMenuPosition => {
 | 
			
		||||
  const { x, y, currentTarget, target } = event;
 | 
			
		||||
  const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect();
 | 
			
		||||
  if (box) {
 | 
			
		||||
 | 
			
		||||
@ -267,16 +267,10 @@
 | 
			
		||||
    {#if searchResultAlbums.length > 0}
 | 
			
		||||
      <section>
 | 
			
		||||
        <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
 | 
			
		||||
        <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
 | 
			
		||||
        <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
 | 
			
		||||
          {#each searchResultAlbums as album, index (album.id)}
 | 
			
		||||
            <a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
 | 
			
		||||
              <AlbumCard
 | 
			
		||||
                preload={index < 20}
 | 
			
		||||
                {album}
 | 
			
		||||
                isSharingView={false}
 | 
			
		||||
                showItemCount={false}
 | 
			
		||||
                showContextMenu={false}
 | 
			
		||||
              />
 | 
			
		||||
              <AlbumCard preload={index < 20} {album} isSharingView={false} showItemCount={false} />
 | 
			
		||||
            </a>
 | 
			
		||||
          {/each}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -80,10 +80,10 @@
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <!-- Share Album List -->
 | 
			
		||||
        <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
 | 
			
		||||
        <div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
 | 
			
		||||
          {#each data.sharedAlbums as album, index (album.id)}
 | 
			
		||||
            <a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
 | 
			
		||||
              <AlbumCard preload={index < 20} {album} isSharingView showContextMenu={false} />
 | 
			
		||||
              <AlbumCard preload={index < 20} {album} isSharingView />
 | 
			
		||||
            </a>
 | 
			
		||||
          {/each}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user