diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts
index 017bc0fcb2..f6d1ec98d4 100644
--- a/e2e/src/specs/web/shared-link.e2e-spec.ts
+++ b/e2e/src/specs/web/shared-link.e2e-spec.ts
@@ -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()]);
});
diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts
index 774839b174..172a567fa4 100644
--- a/e2e/src/ui/specs/timeline/utils.ts
+++ b/e2e/src/ui/specs/timeline/utils.ts
@@ -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();
},
diff --git a/web/src/lib/components/Image.spec.ts b/web/src/lib/components/Image.spec.ts
new file mode 100644
index 0000000000..8435e1bb25
--- /dev/null
+++ b/web/src/lib/components/Image.spec.ts
@@ -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');
+ });
+});
diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte
new file mode 100644
index 0000000000..65d2e28e34
--- /dev/null
+++ b/web/src/lib/components/Image.svelte
@@ -0,0 +1,54 @@
+
+
+{#if capturedSource}
+ {#key capturedSource}
+
+ {/key}
+{/if}
diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte
index a15a787e64..f66e80ef6d 100644
--- a/web/src/lib/components/assets/broken-asset.svelte
+++ b/web/src/lib/components/assets/broken-asset.svelte
@@ -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 @@
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts
new file mode 100644
index 0000000000..04835e9209
--- /dev/null
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts
@@ -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');
+ });
+});
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
index a1dd22f44f..a54ad911fd 100644
--- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
@@ -1,9 +1,8 @@
{#if errored}
-
+
{:else}
-

{/if}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 8270646470..f159da56d6 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -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';
+ });
-
-
-
-
-
-
- {#if !usingMobileDevice && !disabled}
-
- {/if}
-
-
- {#if dimmed && !mouseOver}
-
- {/if}
-
-
- {#if !authManager.isSharedLink && asset.isFavorite}
-
-
-
- {/if}
-
- {#if !!assetOwner}
-
-
- {assetOwner.name}
-
-
- {/if}
-
- {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
-
-
-
- {/if}
-
- {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
-
-
-
-
-
- {/if}
-
- {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
-
-
-
-
-
- {/if}
-
-
- {#if asset.stack && showStackedIcon}
-
-
- {asset.stack.assetCount.toLocaleString($locale)}
-
-
-
- {/if}
-
-
-
- {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
-
evt.preventDefault()}
- tabindex={-1}
- aria-label="Thumbnail URL"
- >
-
- {/if}
-
((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
-
+
{:else if asset.isImage && asset.livePhotoVideoId}
-
+
{/if}
+
+
+
+
+ {#if !usingMobileDevice && !disabled}
+
+ {/if}
+
+
+ {#if dimmed && !mouseOver}
+
+ {/if}
+
+
+ {#if !authManager.isSharedLink && asset.isFavorite}
+
+
+
+ {/if}
+
+ {#if !!assetOwner}
+
+
+ {assetOwner.name}
+
+
+ {/if}
+
+ {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
+
+
+
+ {/if}
+
+ {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
+
+
+
+
+
+ {/if}
+
+ {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
+
+
+
+
+
+ {/if}
+
+
+ {#if asset.stack && showStackedIcon}
+
+
+ {asset.stack.assetCount.toLocaleString($locale)}
+
+
+
+ {/if}
+
+
+
+ {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
+ evt.preventDefault()}
+ tabindex={-1}
+ aria-label="Thumbnail URL"
+ >
+
+ {/if}
{#if selectionCandidate}
@@ -410,7 +415,7 @@
-
-
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
index 222fa7a8ec..28b7ef62ff 100644
--- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
@@ -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}