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/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
index 9408f6079a..6a7ce82672 100644
--- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
+++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts
@@ -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;
@@ -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();
diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts
index e3799a7c3b..d3e4e5f7ec 100644
--- a/e2e/src/ui/specs/timeline/utils.ts
+++ b/e2e/src/ui/specs/timeline/utils.ts
@@ -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) {
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..801a466ca8
--- /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 @@
- {assetOwner.name} -
-{asset.stack.assetCount.toLocaleString($locale)}
-+ {assetOwner.name} +
+{asset.stack.assetCount.toLocaleString($locale)}
+