refactor: thumbnail components

This commit is contained in:
midzelis
2026-01-15 20:34:21 +00:00
parent b4e16efdf4
commit 0add43ec90
9 changed files with 378 additions and 149 deletions

View File

@@ -45,8 +45,7 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
await page.waitForSelector('[data-group] svg');
await page.getByRole('checkbox').click();
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
});

View File

@@ -106,7 +106,7 @@ export const thumbnailUtils = {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl:not([data-outline])`,
),
).toBeVisible();
},

View File

@@ -0,0 +1,87 @@
import Image from '$lib/components/Image.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { fireEvent, render } from '@testing-library/svelte';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
describe('Image component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders an img element when src is provided', () => {
const { baseElement } = render(Image, { src: '/test.jpg', alt: 'test' });
const img = baseElement.querySelector('img');
expect(img).not.toBeNull();
expect(img!.getAttribute('src')).toBe('/test.jpg');
});
it('does not render an img element when src is undefined', () => {
const { baseElement } = render(Image, { src: undefined });
const img = baseElement.querySelector('img');
expect(img).toBeNull();
});
it('calls onStart when src is set', () => {
const onStart = vi.fn();
render(Image, { src: '/test.jpg', onStart });
expect(onStart).toHaveBeenCalledOnce();
});
it('calls onLoad when image loads', async () => {
const onLoad = vi.fn();
const { baseElement } = render(Image, { src: '/test.jpg', onLoad });
const img = baseElement.querySelector('img')!;
await fireEvent.load(img);
expect(onLoad).toHaveBeenCalledOnce();
});
it('calls onError when image fails to load', async () => {
const onError = vi.fn();
const { baseElement } = render(Image, { src: '/test.jpg', onError });
const img = baseElement.querySelector('img')!;
await fireEvent.error(img);
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith(expect.any(Error));
expect(onError.mock.calls[0][0].message).toBe('Failed to load image: /test.jpg');
});
it('calls cancelImageUrl on unmount', () => {
const { unmount } = render(Image, { src: '/test.jpg' });
expect(cancelImageUrl).not.toHaveBeenCalled();
unmount();
expect(cancelImageUrl).toHaveBeenCalledWith('/test.jpg');
});
it('does not call onLoad after unmount', async () => {
const onLoad = vi.fn();
const { baseElement, unmount } = render(Image, { src: '/test.jpg', onLoad });
const img = baseElement.querySelector('img')!;
unmount();
await fireEvent.load(img);
expect(onLoad).not.toHaveBeenCalled();
});
it('does not call onError after unmount', async () => {
const onError = vi.fn();
const { baseElement, unmount } = render(Image, { src: '/test.jpg', onError });
const img = baseElement.querySelector('img')!;
unmount();
await fireEvent.error(img);
expect(onError).not.toHaveBeenCalled();
});
it('passes through additional HTML attributes', () => {
const { baseElement } = render(Image, {
src: '/test.jpg',
alt: 'test alt',
class: 'my-class',
draggable: false,
});
const img = baseElement.querySelector('img')!;
expect(img.getAttribute('alt')).toBe('test alt');
expect(img.getAttribute('draggable')).toBe('false');
});
});

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { onDestroy, untrack } from 'svelte';
import type { HTMLImgAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLImgAttributes, 'onload' | 'onerror'> {
src: string | undefined;
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
ref?: HTMLImageElement;
}
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
let capturedSource: string | undefined = $state();
let destroyed = false;
$effect(() => {
if (src !== undefined && capturedSource === undefined) {
capturedSource = src;
untrack(() => {
onStart?.();
});
}
});
onDestroy(() => {
destroyed = true;
if (capturedSource !== undefined) {
imageManager.cancelPreloadUrl(capturedSource);
}
});
const handleLoad = () => {
if (destroyed || !src) {
return;
}
onLoad?.();
};
const handleError = () => {
if (destroyed || !src) {
return;
}
onError?.(new Error(`Failed to load image: ${src}`));
};
</script>
{#if capturedSource}
{#key capturedSource}
<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
{/key}
{/if}

View File

@@ -2,9 +2,10 @@
import { Icon } from '@immich/ui';
import { mdiImageBrokenVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: string;
class?: ClassValue;
hideMessage?: boolean;
width?: string | undefined;
height?: string | undefined;
@@ -14,7 +15,10 @@
</script>
<div
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
class={[
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
className,
]}
style:width
style:height
>

View File

@@ -0,0 +1,89 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { fireEvent, render } from '@testing-library/svelte';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
describe('ImageThumbnail component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders an img element with correct attributes', () => {
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
});
const img = baseElement.querySelector('img');
expect(img).not.toBeNull();
expect(img!.getAttribute('src')).toBe('/test-thumbnail.jpg');
expect(img!.getAttribute('alt')).toBe('');
});
it('shows BrokenAsset on error', async () => {
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
});
const img = baseElement.querySelector('img')!;
await fireEvent.error(img);
expect(baseElement.querySelector('img')).toBeNull();
expect(baseElement.querySelector('span')?.textContent).toEqual('error_loading_image');
});
it('calls onComplete with false on successful load', async () => {
const onComplete = vi.fn();
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
onComplete,
});
const img = baseElement.querySelector('img')!;
await fireEvent.load(img);
expect(onComplete).toHaveBeenCalledWith(false);
});
it('calls onComplete with true on error', async () => {
const onComplete = vi.fn();
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
onComplete,
});
const img = baseElement.querySelector('img')!;
await fireEvent.error(img);
expect(onComplete).toHaveBeenCalledWith(true);
});
it('applies hidden styles when hidden is true', () => {
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
hidden: true,
});
const img = baseElement.querySelector('img')!;
const style = img.getAttribute('style') ?? '';
expect(style).toContain('grayscale');
expect(style).toContain('opacity');
});
it('sets alt text after loading', async () => {
const { baseElement } = render(ImageThumbnail, {
url: '/test-thumbnail.jpg',
altText: 'Test image',
widthStyle: '200px',
});
const img = baseElement.querySelector('img')!;
expect(img.getAttribute('alt')).toBe('');
await fireEvent.load(img);
expect(img.getAttribute('alt')).toBe('Test image');
});
});

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import Image from '$lib/components/Image.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
import type { ClassValue } from 'svelte/elements';
interface Props {
@@ -49,51 +48,37 @@
loaded = true;
onComplete?.(false);
};
const setErrored = () => {
errored = true;
onComplete?.(true);
};
function mount(elem: HTMLImageElement): ActionReturn {
if (elem.complete) {
loaded = true;
onComplete?.(false);
}
return {
destroy: () => imageManager.cancelPreloadUrl(url),
};
}
let sharedClasses = $derived([
curve && 'rounded-xl',
circle && 'rounded-full',
shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square',
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
]);
let optionalClasses = $derived(
[
curve && 'rounded-xl',
circle && 'rounded-full',
shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square',
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
brokenAssetClass,
]
.filter(Boolean)
.join(' '),
let style = $derived(
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
);
</script>
{#if errored}
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
<BrokenAsset class={[sharedClasses, brokenAssetClass]} width={widthStyle} height={heightStyle} />
{:else}
<img
use:mount
onload={setLoaded}
onerror={setErrored}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
<Image
src={url}
onLoad={setLoaded}
onError={setErrored}
class={['object-cover bg-gray-300 dark:bg-gray-700', sharedClasses, imageClass]}
{style}
alt={loaded || errored ? altText : ''}
{title}
class={['object-cover', optionalClasses, imageClass]}
draggable="false"
draggable={false}
title={title ?? undefined}
loading={preload ? 'eager' : 'lazy'}
/>
{/if}

View File

@@ -196,13 +196,19 @@
document.removeEventListener('pointermove', moveHandler, true);
};
});
const backgroundColorClass = $derived.by(() => {
if (loaded && !selected) {
return 'bg-transparent';
}
if (disabled) {
return 'bg-gray-300';
}
return 'dark:bg-neutral-700 bg-neutral-200';
});
</script>
<div
class={[
'focus-visible:outline-none flex overflow-hidden',
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
]}
class={['group focus-visible:outline-none flex overflow-hidden', backgroundColorClass, { 'rounded-xl': selected }]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
@@ -226,15 +232,6 @@
tabindex={0}
role="link"
>
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
data-outline
></div>
<div
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
@@ -247,92 +244,9 @@
{ 'rounded-xl': selected },
]}
>
<!-- icon overlay -->
<div>
<!-- Gradient overlay on hover -->
{#if !usingMobileDevice && !disabled}
<div
class={[
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
{ 'rounded-xl': selected },
]}
></div>
{/if}
<!-- Dimmed support -->
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
{/if}
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !!assetOwner}
<div class="absolute bottom-1 end-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
<!-- Stacked asset -->
{#if asset.stack && showStackedIcon}
<div
class={[
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
]}
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
</div>
<!-- lazy show the url on mouse over-->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
<a
class="absolute w-full top-0 bottom-0"
style:cursor="unset"
href={currentUrlReplaceAssetId(asset.id)}
onclick={(evt) => evt.preventDefault()}
tabindex={-1}
aria-label="Thumbnail URL"
>
</a>
{/if}
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
@@ -341,8 +255,9 @@
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
@@ -351,8 +266,9 @@
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
pauseIcon={mdiMotionPauseOutline}
@@ -387,7 +303,7 @@
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
@@ -395,11 +311,100 @@
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
<!-- icon overlay -->
<div class="z-2 absolute inset-0">
<!-- Gradient overlay on hover -->
{#if !usingMobileDevice && !disabled}
<div
class={[
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
{ 'rounded-xl group-focus-visible:rounded-lg': selected },
]}
></div>
{/if}
<!-- Dimmed support -->
{#if dimmed && !mouseOver}
<div
id="a"
class={[
'z-2 absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
]}
></div>
{/if}
<!-- Favorite asset star -->
{#if !authManager.isSharedLink && asset.isFavorite}
<div class="z-2 absolute bottom-2 start-2">
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
{#if !!assetOwner}
<div class="z-2 absolute bottom-1 end-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
</span>
</div>
{/if}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
</span>
</div>
{/if}
<!-- Stacked asset -->
{#if asset.stack && showStackedIcon}
<div
class={[
'z-2 absolute flex place-items-center gap-1 text-xs font-medium text-white',
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
]}
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
</span>
</div>
{/if}
</div>
<!-- lazy show the url on mouse over-->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
<a
class="z-2 absolute w-full top-0 bottom-0"
style:cursor="unset"
href={currentUrlReplaceAssetId(asset.id)}
onclick={(evt) => evt.preventDefault()}
tabindex={-1}
aria-label="Thumbnail URL"
>
</a>
{/if}
</div>
{#if selectionCandidate}
<div
class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
class={['z-2 absolute top-0 h-full w-full bg-immich-primary opacity-40', { 'rounded-xl': selected }]}
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
></div>
@@ -410,7 +415,7 @@
<button
type="button"
onclick={onIconClickedHandler}
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
class={['absolute z-2 p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
role="checkbox"
tabindex={-1}
aria-checked={selected}
@@ -427,11 +432,14 @@
{/if}
</button>
{/if}
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:outline-4 group-focus-visible:-outline-offset-4',
{ 'rounded-xl': selected },
]}
data-outline
></div>
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>

View File

@@ -2,6 +2,7 @@
import { Icon, LoadingSpinner } from '@immich/ui';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import { Duration } from 'luxon';
import type { ClassValue } from 'svelte/elements';
interface Props {
url: string;
@@ -12,6 +13,7 @@
curve?: boolean;
playIcon?: string;
pauseIcon?: string;
class?: ClassValue;
}
let {
@@ -23,6 +25,7 @@
curve = false,
playIcon = mdiPlayCircleOutline,
pauseIcon = mdiPauseCircleOutline,
class: className = undefined,
}: Props = $props();
let remainingSeconds = $state(durationInSeconds);
@@ -57,7 +60,7 @@
{#if enablePlayback}
<video
bind:this={player}
class="h-full w-full object-cover"
class={['h-full w-full object-cover', className]}
class:rounded-xl={curve}
muted
autoplay