mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+5
-5
@@ -65,7 +65,7 @@
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
@@ -85,20 +85,20 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"dotenv": "^17.0.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-compat": "^6.0.2",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"factory.ts": "^1.4.1",
|
||||
"globals": "^16.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"happy-dom": "^20.0.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.51.5",
|
||||
"svelte": "5.53.0",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
@@ -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}
|
||||
@@ -46,6 +46,7 @@
|
||||
userSettings,
|
||||
allowEdit = false,
|
||||
showOwner = false,
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
albumGroupIds = $bindable([]),
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
let hasNext = false;
|
||||
let hasNext: boolean;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
|
||||
@@ -42,16 +42,7 @@
|
||||
}
|
||||
|
||||
function ratioSelected(ratio: AspectRatioOption): boolean {
|
||||
let currentRatioRotated;
|
||||
if (ratio.value === 'original') {
|
||||
const { width, height } = transformManager.cropImageSize;
|
||||
// Account for rotation when comparing to original
|
||||
if (isRotated) {
|
||||
currentRatioRotated = `${height}:${width}`;
|
||||
}
|
||||
currentRatioRotated = `${width}:${height}`;
|
||||
}
|
||||
currentRatioRotated = rotatedRatio(ratio);
|
||||
const currentRatioRotated = rotatedRatio(ratio);
|
||||
|
||||
return transformManager.cropAspectRatio === currentRatioRotated;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -224,18 +230,11 @@
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
data-readonly={readonly ? true : undefined}
|
||||
data-disabled={disabled ? true : undefined}
|
||||
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"
|
||||
@@ -248,92 +247,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"
|
||||
@@ -342,8 +258,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}
|
||||
@@ -352,8 +269,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}
|
||||
@@ -388,7 +306,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}
|
||||
@@ -396,11 +314,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>
|
||||
@@ -411,7 +418,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}
|
||||
@@ -428,11 +435,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
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
let {
|
||||
searchName = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
searchedPeopleLocal = $bindable(),
|
||||
type,
|
||||
numberPeopleToSearch = maximumLengthSearchPeople,
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
afterNavigate(({ from, to }) => {
|
||||
memoryStore.ready().then(
|
||||
() => {
|
||||
let target = null;
|
||||
let target;
|
||||
if (to?.params?.assetId) {
|
||||
target = to;
|
||||
} else if (from?.params?.assetId) {
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
let {
|
||||
places = $bindable([]),
|
||||
searchQuery = '',
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
searchResultCount = $bindable(0),
|
||||
userSettings,
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
placesGroupIds = $bindable([]),
|
||||
}: Props = $props();
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
onScrubKeyDown = undefined,
|
||||
startScrub = undefined,
|
||||
stopScrub = undefined,
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
scrubberWidth = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
const monthsLength = timelineManager.months.length;
|
||||
for (let i = -1; i < monthsLength + 1; i++) {
|
||||
let monthGroup: ViewportTopMonth;
|
||||
let monthGroupHeight = 0;
|
||||
let monthGroupHeight: number;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
monthGroup = 'lead-in';
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
let {
|
||||
timelineManager,
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
invisible = $bindable(false),
|
||||
removeAction,
|
||||
withStacked = false,
|
||||
|
||||
@@ -3,12 +3,12 @@ import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { waitForWebsocketEvent } from '$lib/stores/websocket';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsCreateDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiCropRotate } from '@mdi/js';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type EditAction = AssetEditsDto['edits'][number];
|
||||
export type EditAction = AssetEditsCreateDto['edits'][number];
|
||||
export type EditActions = EditAction[];
|
||||
|
||||
export interface EditToolManager {
|
||||
@@ -84,7 +84,7 @@ export class EditManager {
|
||||
this.selectedTool = this.tools[0];
|
||||
}
|
||||
|
||||
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) {
|
||||
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsCreateDto) {
|
||||
this.hasAppliedEdits = false;
|
||||
if (this.selectedTool?.type === toolType) {
|
||||
return;
|
||||
@@ -133,7 +133,7 @@ export class EditManager {
|
||||
? removeAssetEdits({ id: assetId })
|
||||
: editAsset({
|
||||
id: assetId,
|
||||
assetEditActionListDto: {
|
||||
assetEditsCreateDto: {
|
||||
edits,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1004,7 +1004,7 @@ class TransformManager implements EditToolManager {
|
||||
];
|
||||
}
|
||||
|
||||
let cursorName = '';
|
||||
let cursorName: string;
|
||||
if (onTopLeftCorner || onBottomRightCorner) {
|
||||
cursorName = 'nwse-resize';
|
||||
} else if (onTopRightCorner || onBottomLeftCorner) {
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
|
||||
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup];
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,13 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
|
||||
export const getLibrariesActions = ($t: MessageFormatter) => {
|
||||
const ScanAll: ActionItem = {
|
||||
title: $t('scan_all_libraries'),
|
||||
type: $t('command'),
|
||||
icon: mdiSync,
|
||||
onAction: () => handleScanAllLibraries(),
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
$if: () => libraries.length > 0,
|
||||
};
|
||||
|
||||
const Create: ActionItem = {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||
import { getUniqueOriginalPaths } from '@immich/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/managers/event-manager.svelte', () => ({
|
||||
eventManager: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@immich/sdk', () => ({
|
||||
getAssetsByOriginalPath: vi.fn(),
|
||||
getUniqueOriginalPaths: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('foldersStore', () => {
|
||||
beforeEach(() => {
|
||||
foldersStore.clearCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the same non-null tree for concurrent fetchTree calls', async () => {
|
||||
let resolvePaths: (value: string[]) => void;
|
||||
|
||||
vi.mocked(getUniqueOriginalPaths).mockReturnValue(
|
||||
new Promise<string[]>((resolve) => {
|
||||
resolvePaths = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = foldersStore.fetchTree();
|
||||
const second = foldersStore.fetchTree();
|
||||
|
||||
resolvePaths!(['/photos/2026']);
|
||||
|
||||
const [firstTree, secondTree] = await Promise.all([first, second]);
|
||||
|
||||
expect(firstTree).not.toBeNull();
|
||||
expect(secondTree).not.toBeNull();
|
||||
expect(secondTree).toEqual(firstTree);
|
||||
});
|
||||
});
|
||||
@@ -28,10 +28,9 @@ class FoldersStore {
|
||||
if (this.initialized) {
|
||||
return this.folders!;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
this.folders = TreeNode.fromPaths(await getUniqueOriginalPaths());
|
||||
this.folders.collapse();
|
||||
this.initialized = true;
|
||||
return this.folders;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getBaseUrl,
|
||||
type AssetMediaResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -112,6 +113,10 @@ export const fileUploadHandler = async ({
|
||||
promises.push(
|
||||
uploadExecutionQueue.addTask(() => fileUploader({ assetFile: file, deviceAssetId, albumId, isLockedAssets })),
|
||||
);
|
||||
} else {
|
||||
toastManager.warning(get(t)('unsupported_file_type', { values: { file: file.name, type: file.type } }), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
delete owners[id];
|
||||
};
|
||||
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t));
|
||||
|
||||
const getActionsForLibrary = (library: LibraryResponseDto) => {
|
||||
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
|
||||
|
||||
Reference in New Issue
Block a user