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