mirror of
https://github.com/immich-app/immich.git
synced 2026-02-21 04:00:40 +09:00
refactor: thumbnail components
This commit is contained in:
@@ -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()]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
87
web/src/lib/components/Image.spec.ts
Normal file
87
web/src/lib/components/Image.spec.ts
Normal file
@@ -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
web/src/lib/components/Image.svelte
Normal file
54
web/src/lib/components/Image.svelte
Normal file
@@ -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';
|
||||
|
||||
interface Props extends 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 { 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 @@
|
||||
</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
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
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 {
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
||||
]}
|
||||
class={['group focus-visible:outline-none flex overflow-hidden', backgroundColorClass, { 'rounded-xl': selected }]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
onmouseenter={onMouseEnter}
|
||||
@@ -226,15 +232,6 @@
|
||||
tabindex={0}
|
||||
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
|
||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
@@ -247,92 +244,9 @@
|
||||
{ '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
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
@@ -341,8 +255,9 @@
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#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
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
@@ -351,8 +266,9 @@
|
||||
/>
|
||||
</div>
|
||||
{: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
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
@@ -387,7 +303,7 @@
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.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:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
@@ -395,11 +311,100 @@
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/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>
|
||||
|
||||
{#if selectionCandidate}
|
||||
<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 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
></div>
|
||||
@@ -410,7 +415,7 @@
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
tabindex={-1}
|
||||
aria-checked={selected}
|
||||
@@ -427,11 +432,14 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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}
|
||||
<video
|
||||
bind:this={player}
|
||||
class="h-full w-full object-cover"
|
||||
class={['h-full w-full object-cover', className]}
|
||||
class:rounded-xl={curve}
|
||||
muted
|
||||
autoplay
|
||||
|
||||
Reference in New Issue
Block a user