From 0add43ec90e654a5ce99677e84e6769f79031ed2 Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 15 Jan 2026 20:34:21 +0000 Subject: [PATCH] refactor: thumbnail components --- e2e/src/specs/web/shared-link.e2e-spec.ts | 3 +- e2e/src/ui/specs/timeline/utils.ts | 2 +- web/src/lib/components/Image.spec.ts | 87 +++++++ web/src/lib/components/Image.svelte | 54 +++++ .../lib/components/assets/broken-asset.svelte | 8 +- .../assets/thumbnail/image-thumbnail.spec.ts | 89 +++++++ .../assets/thumbnail/image-thumbnail.svelte | 53 ++-- .../assets/thumbnail/thumbnail.svelte | 226 +++++++++--------- .../assets/thumbnail/video-thumbnail.svelte | 5 +- 9 files changed, 378 insertions(+), 149 deletions(-) create mode 100644 web/src/lib/components/Image.spec.ts create mode 100644 web/src/lib/components/Image.svelte create mode 100644 web/src/lib/components/assets/thumbnail/image-thumbnail.spec.ts 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} - {loaded {/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}