Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-25 11:53:18 +00:00
379 changed files with 14057 additions and 12369 deletions
+5 -5
View File
@@ -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",
+87
View 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
View 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';
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];
}
+1 -2
View File
@@ -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 = {
+42
View File
@@ -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);
});
});
+1 -2
View File
@@ -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;
}
+5
View File
@@ -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);