mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor: thumbnail components (#26379)
This commit is contained in:
@@ -45,8 +45,7 @@ test.describe('Shared Links', () => {
|
|||||||
await page.goto(`/share/${sharedLink.key}`);
|
await page.goto(`/share/${sharedLink.key}`);
|
||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||||
await page.waitForSelector('[data-group] svg');
|
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
|
||||||
await page.getByRole('checkbox').click();
|
|
||||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ test.describe('Timeline', () => {
|
|||||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
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 }) => {
|
test('Add photos to album', async ({ page }) => {
|
||||||
const album = timelineRestData.album;
|
const album = timelineRestData.album;
|
||||||
@@ -447,7 +447,7 @@ test.describe('Timeline', () => {
|
|||||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
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');
|
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||||
const requestJson = request.postDataJSON();
|
const requestJson = request.postDataJSON();
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export const thumbnailUtils = {
|
|||||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
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(
|
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();
|
).toBeVisible();
|
||||||
},
|
},
|
||||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
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}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: ClassValue;
|
||||||
hideMessage?: boolean;
|
hideMessage?: boolean;
|
||||||
width?: string | undefined;
|
width?: string | undefined;
|
||||||
height?: string | undefined;
|
height?: string | undefined;
|
||||||
@@ -14,7 +15,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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:width
|
||||||
style:height
|
style:height
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
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 { Icon } from '@immich/ui';
|
||||||
import { mdiEyeOffOutline } from '@mdi/js';
|
import { mdiEyeOffOutline } from '@mdi/js';
|
||||||
import type { ActionReturn } from 'svelte/action';
|
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,51 +48,37 @@
|
|||||||
loaded = true;
|
loaded = true;
|
||||||
onComplete?.(false);
|
onComplete?.(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setErrored = () => {
|
const setErrored = () => {
|
||||||
errored = true;
|
errored = true;
|
||||||
onComplete?.(true);
|
onComplete?.(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
function mount(elem: HTMLImageElement): ActionReturn {
|
let sharedClasses = $derived([
|
||||||
if (elem.complete) {
|
curve && 'rounded-xl',
|
||||||
loaded = true;
|
circle && 'rounded-full',
|
||||||
onComplete?.(false);
|
shadow && 'shadow-lg',
|
||||||
}
|
(circle || !heightStyle) && 'aspect-square',
|
||||||
return {
|
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
|
||||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
]);
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let optionalClasses = $derived(
|
let style = $derived(
|
||||||
[
|
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
|
||||||
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(' '),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if errored}
|
{#if errored}
|
||||||
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
<BrokenAsset class={[sharedClasses, brokenAssetClass]} width={widthStyle} height={heightStyle} />
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<Image
|
||||||
use:mount
|
|
||||||
onload={setLoaded}
|
|
||||||
onerror={setErrored}
|
|
||||||
style:width={widthStyle}
|
|
||||||
style:height={heightStyle}
|
|
||||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
|
||||||
style:opacity={hidden ? '0.5' : '1'}
|
|
||||||
src={url}
|
src={url}
|
||||||
|
onLoad={setLoaded}
|
||||||
|
onError={setErrored}
|
||||||
|
class={['object-cover bg-gray-300 dark:bg-gray-700', sharedClasses, imageClass]}
|
||||||
|
{style}
|
||||||
alt={loaded || errored ? altText : ''}
|
alt={loaded || errored ? altText : ''}
|
||||||
{title}
|
draggable={false}
|
||||||
class={['object-cover', optionalClasses, imageClass]}
|
title={title ?? undefined}
|
||||||
draggable="false"
|
|
||||||
loading={preload ? 'eager' : 'lazy'}
|
loading={preload ? 'eager' : 'lazy'}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -196,13 +196,19 @@
|
|||||||
document.removeEventListener('pointermove', moveHandler, true);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={[
|
class={['group focus-visible:outline-none flex overflow-hidden', backgroundColorClass, { 'rounded-xl': selected }]}
|
||||||
'focus-visible:outline-none flex overflow-hidden',
|
|
||||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
|
||||||
]}
|
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
onmouseenter={onMouseEnter}
|
onmouseenter={onMouseEnter}
|
||||||
@@ -224,18 +230,11 @@
|
|||||||
data-asset={asset.id}
|
data-asset={asset.id}
|
||||||
data-thumbnail-focus-container
|
data-thumbnail-focus-container
|
||||||
data-selected={selected ? true : undefined}
|
data-selected={selected ? true : undefined}
|
||||||
|
data-readonly={readonly ? true : undefined}
|
||||||
|
data-disabled={disabled ? true : undefined}
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
role="link"
|
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
|
<div
|
||||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||||
style:width="inherit"
|
style:width="inherit"
|
||||||
@@ -248,92 +247,9 @@
|
|||||||
{ 'rounded-xl': selected },
|
{ '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
|
<ImageThumbnail
|
||||||
class={imageClass}
|
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||||
{brokenAssetClass}
|
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||||
altText={$getAltText(asset)}
|
altText={$getAltText(asset)}
|
||||||
widthStyle="{width}px"
|
widthStyle="{width}px"
|
||||||
@@ -342,8 +258,9 @@
|
|||||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||||
/>
|
/>
|
||||||
{#if asset.isVideo}
|
{#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
|
<VideoThumbnail
|
||||||
|
class="group-focus-visible:rounded-lg"
|
||||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
curve={selected}
|
curve={selected}
|
||||||
@@ -352,8 +269,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
{: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
|
<VideoThumbnail
|
||||||
|
class="group-focus-visible:rounded-lg"
|
||||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
pauseIcon={mdiMotionPauseOutline}
|
pauseIcon={mdiMotionPauseOutline}
|
||||||
@@ -388,7 +306,7 @@
|
|||||||
<canvas
|
<canvas
|
||||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||||
data-testid="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:width="{width}px"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
class:rounded-xl={selected}
|
class:rounded-xl={selected}
|
||||||
@@ -396,11 +314,100 @@
|
|||||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||||
></canvas>
|
></canvas>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
{#if selectionCandidate}
|
{#if selectionCandidate}
|
||||||
<div
|
<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 }}
|
in:fade={{ duration: 100 }}
|
||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
></div>
|
></div>
|
||||||
@@ -411,7 +418,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onIconClickedHandler}
|
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"
|
role="checkbox"
|
||||||
tabindex={-1}
|
tabindex={-1}
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
@@ -428,11 +435,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
[data-asset]:focus > [data-outline] {
|
|
||||||
outline-style: solid;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
curve?: boolean;
|
curve?: boolean;
|
||||||
playIcon?: string;
|
playIcon?: string;
|
||||||
pauseIcon?: string;
|
pauseIcon?: string;
|
||||||
|
class?: ClassValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
curve = false,
|
curve = false,
|
||||||
playIcon = mdiPlayCircleOutline,
|
playIcon = mdiPlayCircleOutline,
|
||||||
pauseIcon = mdiPauseCircleOutline,
|
pauseIcon = mdiPauseCircleOutline,
|
||||||
|
class: className = undefined,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let remainingSeconds = $state(durationInSeconds);
|
let remainingSeconds = $state(durationInSeconds);
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
{#if enablePlayback}
|
{#if enablePlayback}
|
||||||
<video
|
<video
|
||||||
bind:this={player}
|
bind:this={player}
|
||||||
class="h-full w-full object-cover"
|
class={['h-full w-full object-cover', className]}
|
||||||
class:rounded-xl={curve}
|
class:rounded-xl={curve}
|
||||||
muted
|
muted
|
||||||
autoplay
|
autoplay
|
||||||
|
|||||||
Reference in New Issue
Block a user