Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions e2e/src/specs/web/shared-link.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
});

Expand Down
4 changes: 2 additions & 2 deletions e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
Expand All @@ -447,7 +447,7 @@ test.describe('Timeline', () => {
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();
Expand Down
4 changes: 2 additions & 2 deletions e2e/src/ui/specs/timeline/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ export const thumbnailUtils = {
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
async expectSelectedDisabled(page: Page, assetId: string) {
await expect(
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
Expand Down
87 changes: 87 additions & 0 deletions web/src/lib/components/Image.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
54 changes: 54 additions & 0 deletions web/src/lib/components/Image.svelte
Original file line number Diff line number Diff line change
@@ -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';

type Props = 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();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michelheusschen Hopefully the variable naming now makes it more clear that we only intend to operate on a single non-undefined src value. If you want this to call listeners and update multiple times based on updates to the src prop - surround this with a {#key} - or just use native <img>

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}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need the #key here, do you? It'll only re-render the img which will update/re-render anyways

<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
{/key}
{/if}
8 changes: 6 additions & 2 deletions web/src/lib/components/assets/broken-asset.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
>
Expand Down
89 changes: 89 additions & 0 deletions web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
53 changes: 19 additions & 34 deletions web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading