From 9fc6fbc3735bbca045c9dfbd61807b55f15f894c Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:46:29 +0100 Subject: [PATCH 01/19] fix(web): restore asset update events in asset viewer (#26845) --- .../asset-viewer/asset-viewer.spec.ts | 76 +++++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 8 ++ 2 files changed, 84 insertions(+) create mode 100644 web/src/lib/components/asset-viewer/asset-viewer.spec.ts diff --git a/web/src/lib/components/asset-viewer/asset-viewer.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts new file mode 100644 index 0000000000..a1f50da86a --- /dev/null +++ b/web/src/lib/components/asset-viewer/asset-viewer.spec.ts @@ -0,0 +1,76 @@ +import { getAnimateMock } from '$lib/__mocks__/animate.mock'; +import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock'; +import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store'; +import { renderWithTooltips } from '$tests/helpers'; +import { updateAsset } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import { preferencesFactory } from '@test-data/factories/preferences-factory'; +import { userAdminFactory } from '@test-data/factories/user-factory'; +import { fireEvent, waitFor } from '@testing-library/svelte'; +import AssetViewer from './asset-viewer.svelte'; + +vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({ + featureFlagsManager: { + init: vi.fn(), + loadFeatureFlags: vi.fn(), + value: { smartSearch: true, trash: true }, + } as never, +})); + +vi.mock('$lib/stores/ocr.svelte', () => ({ + ocrManager: { + clear: vi.fn(), + getAssetOcr: vi.fn(), + hasOcrData: false, + showOverlay: false, + }, +})); + +vi.mock('@immich/sdk', async () => { + const sdk = await vi.importActual('@immich/sdk'); + return { + ...sdk, + updateAsset: vi.fn(), + }; +}); + +describe('AssetViewer', () => { + beforeAll(() => { + Element.prototype.animate = getAnimateMock(); + vi.stubGlobal('ResizeObserver', getResizeObserverMock()); + }); + + afterEach(() => { + resetSavedUser(); + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('updates the top bar favorite action after pressing favorite', async () => { + const ownerId = 'owner-id'; + const user = userAdminFactory.build({ id: ownerId }); + const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false }); + + userStore.set(user); + preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } })); + vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true }); + + const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, { + cursor: { current: asset }, + showNavigation: false, + }); + + expect(getByLabelText('to_favorite')).toBeInTheDocument(); + expect(queryByLabelText('unfavorite')).toBeNull(); + + await fireEvent.click(getByLabelText('to_favorite')); + + await waitFor(() => + expect(updateAsset).toHaveBeenCalledWith({ id: asset.id, updateAssetDto: { isFavorite: true } }), + ); + await waitFor(() => expect(getByLabelText('unfavorite')).toBeInTheDocument()); + }); +}); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index cf1ad4be5a..8520e69a3d 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; @@ -142,6 +143,12 @@ } }; + const onAssetUpdate = (updatedAsset: AssetResponseDto) => { + if (asset.id === updatedAsset.id) { + cursor = { ...cursor, current: updatedAsset }; + } + }; + onMount(() => { syncAssetViewerOpenClass(true); unsubscribes.push( @@ -406,6 +413,7 @@ + From 27f69b39b2a17b52653989c55c5e8e99648ab3fc Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:49:35 +0100 Subject: [PATCH 02/19] fix(server): use correct day ordering in timeline buckets (#26821) * fix(web): sort timeline day groups received from server * fix(server): use correct day ordering in timeline buckets --- server/src/queries/asset.repository.sql | 1 + server/src/repositories/asset.repository.ts | 4 +- .../repositories/asset.repository.spec.ts | 57 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 632fb823c6..a74a05f466 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -438,6 +438,7 @@ with and "stack"."primaryAssetId" != "asset"."id" ) order by + (asset."localDateTime" AT TIME ZONE 'UTC')::date desc, "asset"."fileCreatedAt" desc ), "agg" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e971a995e6..82534dbfa3 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -744,6 +744,7 @@ export class AssetRepository { params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }], }) getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { + const order = options.order ?? 'desc'; const query = this.db .with('cte', (qb) => qb @@ -841,7 +842,8 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), + .orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order) + .orderBy('asset.fileCreatedAt', order), ) .with('agg', (qb) => qb diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 97f503e9ed..896489672e 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -1,9 +1,11 @@ import { Kysely } from 'kysely'; +import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; import { BaseService } from 'src/services/base.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -22,6 +24,61 @@ beforeAll(async () => { }); describe(AssetRepository.name, () => { + describe('getTimeBucket', () => { + it('should order assets by local day first and fileCreatedAt within each day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] = + await Promise.all([ + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'), + localDateTime: new Date('2026-03-08T22:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'), + localDateTime: new Date('2026-03-09T01:30:00.000Z'), + }), + ctx.newAsset({ + ownerId: user.id, + fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'), + localDateTime: new Date('2026-03-09T01:45:00.000Z'), + }), + ]); + + await Promise.all([ + ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }), + ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }), + ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }), + ]); + + const descendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(descendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id], + }), + ); + + const ascendingBucket = await sut.getTimeBucket( + '2026-03-01', + { order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline }, + auth, + ); + expect(JSON.parse(ascendingBucket.assets)).toEqual( + expect.objectContaining({ + id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id], + }), + ); + }); + }); + describe('upsertExif', () => { it('should append to locked columns', async () => { const { ctx, sut } = setup(); From 8764a1894b644d7b481ad2631d93461f58892668 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 11 Mar 2026 10:48:46 -0400 Subject: [PATCH 03/19] feat: adaptive progressive image loading for photo viewer (#26636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): adaptive progressive image loading for photo viewer Replace ImageManager with a new AdaptiveImageLoader that progressively loads images through quality tiers (thumbnail → preview → original). New components and utilities: - AdaptiveImage: layered image renderer with thumbhash, thumbnail, preview, and original layers with visibility managed by load state - AdaptiveImageLoader: state machine driving the quality progression with per-quality callbacks and error handling - ImageLayer/Image: low-level image elements with load/error lifecycle - PreloadManager: preloads adjacent assets for instant navigation - AlphaBackground/DelayedLoadingSpinner: loading state UI Zoom is handled via a derived CSS transform applied to the content wrapper in AdaptiveImage, with the zoom library (zoomTarget: null) only tracking state without manipulating the DOM directly. Also adds scaleToCover to container-utils and getAssetUrls to utils. * fix: don't partially render images in firefox * add passive loading indicator to asset-viewer --------- Co-authored-by: Alex --- e2e/src/specs/web/photo-viewer.e2e-spec.ts | 62 ++-- .../asset-viewer/broken-asset.e2e-spec.ts | 6 +- web/src/lib/actions/image-loader.svelte.ts | 25 ++ web/src/lib/actions/zoom-image.ts | 6 +- web/src/lib/components/AdaptiveImage.svelte | 228 +++++++++++++ web/src/lib/components/AlphaBackground.svelte | 11 + .../components/DelayedLoadingSpinner.svelte | 20 ++ web/src/lib/components/Image.svelte | 27 +- web/src/lib/components/ImageLayer.svelte | 47 +++ web/src/lib/components/LoadingDots.svelte | 46 +++ .../asset-viewer/PreloadManager.svelte.ts | 104 ++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 15 +- .../asset-viewer/asset-viewer.svelte | 181 ++++++----- .../face-editor/face-editor.svelte | 37 ++- .../asset-viewer/photo-viewer.svelte | 227 +++++-------- .../memory-page/memory-photo-viewer.svelte | 18 +- web/src/lib/managers/ImageManager.spec.ts | 99 ------ web/src/lib/managers/ImageManager.svelte.ts | 37 --- .../managers/asset-viewer-manager.svelte.ts | 15 + web/src/lib/utils.ts | 8 + .../lib/utils/adaptive-image-loader.spec.ts | 304 ++++++++++++++++++ .../lib/utils/adaptive-image-loader.svelte.ts | 164 ++++++++++ web/src/lib/utils/asset-utils.ts | 2 + web/src/lib/utils/container-utils.ts | 13 + web/src/lib/utils/layout-utils.spec.ts | 54 ++++ 25 files changed, 1340 insertions(+), 416 deletions(-) create mode 100644 web/src/lib/actions/image-loader.svelte.ts create mode 100644 web/src/lib/components/AdaptiveImage.svelte create mode 100644 web/src/lib/components/AlphaBackground.svelte create mode 100644 web/src/lib/components/DelayedLoadingSpinner.svelte create mode 100644 web/src/lib/components/ImageLayer.svelte create mode 100644 web/src/lib/components/LoadingDots.svelte create mode 100644 web/src/lib/components/asset-viewer/PreloadManager.svelte.ts delete mode 100644 web/src/lib/managers/ImageManager.spec.ts delete mode 100644 web/src/lib/managers/ImageManager.svelte.ts create mode 100644 web/src/lib/utils/adaptive-image-loader.spec.ts create mode 100644 web/src/lib/utils/adaptive-image-loader.svelte.ts create mode 100644 web/src/lib/utils/layout-utils.spec.ts diff --git a/e2e/src/specs/web/photo-viewer.e2e-spec.ts b/e2e/src/specs/web/photo-viewer.e2e-spec.ts index 3f9bb4237a..88b61278bc 100644 --- a/e2e/src/specs/web/photo-viewer.e2e-spec.ts +++ b/e2e/src/specs/web/photo-viewer.e2e-spec.ts @@ -1,14 +1,13 @@ import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; -import { Page, expect, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Socket } from 'socket.io-client'; import { utils } from 'src/utils'; -function imageLocator(page: Page) { - return page.getByAltText('Image taken').locator('visible=true'); -} test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; let rawAsset: AssetMediaResponseDto; + let websocket: Socket; test.beforeAll(async () => { utils.initSdk(); @@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => { admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + websocket = await utils.connectWebsocket(admin.accessToken); + }); + + test.afterAll(() => { + utils.disconnectWebsocket(websocket); }); test.beforeEach(async ({ context, page }) => { @@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => { test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const originalResponse = page.waitForResponse((response) => response.url().includes('/original')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + + await originalResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /original/); }); test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { await page.goto(`/photos/${rawAsset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const box = await imageLocator(page).boundingBox(); - expect(box).toBeTruthy(); - const { x, y, width, height } = box!; - await page.mouse.move(x + width / 2, y + height / 2); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + + const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize')); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); + + await fullsizeResponse; + + const original = page.getByTestId('original').filter({ visible: true }); + await expect(original).toHaveAttribute('src', /fullsize/); }); test('reloads photo when checksum changes', async ({ page }) => { await page.goto(`/photos/${asset.id}`); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); - const initialSrc = await imageLocator(page).getAttribute('src'); + + const preview = page.getByTestId('preview').filter({ visible: true }); + await expect(preview).toHaveAttribute('src', /.+/); + const initialSrc = await preview.getAttribute('src'); + + const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.replaceAsset(admin.accessToken, asset.id); - await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + await websocketEvent; + + await expect(preview).not.toHaveAttribute('src', initialSrc!); }); }); diff --git a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts index fa010f0c1b..2b036d3f52 100644 --- a/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts @@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => { test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => { await context.route( - (url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`), + (url) => + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) || + url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`), async (route) => { return route.fulfill({ status: 404 }); }, @@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => { await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.waitForSelector('#immich-asset-viewer'); - const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]'); + const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first(); await expect(viewerBrokenAsset).toBeVisible(); await expect(viewerBrokenAsset.locator('svg')).toBeVisible(); diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000..49a53dac26 --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,25 @@ +import { cancelImageUrl } from '$lib/utils/sw-messaging'; + +export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) { + let destroyed = false; + + const handleLoad = () => !destroyed && onLoad(); + const handleError = () => !destroyed && onError(); + + const img = document.createElement('img'); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + onStart?.(); + img.src = src; + + return () => { + destroyed = true; + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + cancelImageUrl(src); + img.remove(); + }; +} + +export type LoadImageFunction = typeof loadImage; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 602ed9bd63..66659997d2 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -2,7 +2,11 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: null, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte new file mode 100644 index 0000000000..92e3fad2d3 --- /dev/null +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -0,0 +1,228 @@ + + +
+ {@render backdrop?.()} + +
+
+ {#if show.alphaBackground} + + {/if} + + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + + {/if} + {/if} + + {#if show.thumbnail} + + {/if} + + {#if show.brokenAsset} + + {/if} + + {#if show.preview} + + {/if} + + {#if show.original} + + {/if} +
+
+
diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte new file mode 100644 index 0000000000..c0d8536a2f --- /dev/null +++ b/web/src/lib/components/AlphaBackground.svelte @@ -0,0 +1,11 @@ + + +
diff --git a/web/src/lib/components/DelayedLoadingSpinner.svelte b/web/src/lib/components/DelayedLoadingSpinner.svelte new file mode 100644 index 0000000000..d18d373566 --- /dev/null +++ b/web/src/lib/components/DelayedLoadingSpinner.svelte @@ -0,0 +1,20 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte index 417af56192..7ad6dc3ab7 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -1,4 +1,5 @@ + +{#key adaptiveImageLoader} +
+ adaptiveImageLoader.onStart(quality)} + onLoad={() => adaptiveImageLoader.onLoad(quality)} + onError={() => adaptiveImageLoader.onError(quality)} + bind:ref + class="h-full w-full bg-transparent" + {alt} + {role} + draggable={false} + data-testid={quality} + /> + {@render overlays?.()} +
+{/key} diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte new file mode 100644 index 0000000000..3dcfcb8122 --- /dev/null +++ b/web/src/lib/components/LoadingDots.svelte @@ -0,0 +1,46 @@ + + +
+ {#each [0, 1, 2] as i (i)} + + {/each} +
+ + diff --git a/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts new file mode 100644 index 0000000000..38da1dc08d --- /dev/null +++ b/web/src/lib/components/asset-viewer/PreloadManager.svelte.ts @@ -0,0 +1,104 @@ +import { loadImage } from '$lib/actions/image-loader.svelte'; +import { getAssetUrls } from '$lib/utils'; +import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte'; +import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk'; + +type AssetCursor = { + current: AssetResponseDto; + nextAsset?: AssetResponseDto; + previousAsset?: AssetResponseDto; +}; + +export class PreloadManager { + private nextPreloader: AdaptiveImageLoader | undefined; + private previousPreloader: AdaptiveImageLoader | undefined; + + private startPreloader( + asset: AssetResponseDto | undefined, + sharedlink: SharedLinkResponseDto | undefined, + ): AdaptiveImageLoader | undefined { + if (!asset) { + return; + } + const urls = getAssetUrls(asset, sharedlink); + const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview'); + const qualityList: QualityList = [ + { + quality: 'thumbnail', + url: urls.thumbnail, + onAfterLoad: afterThumbnail, + onAfterError: afterThumbnail, + }, + { + quality: 'preview', + url: urls.preview, + onAfterError: (loader) => loader.trigger('original'), + }, + { quality: 'original', url: urls.original }, + ]; + const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage); + loader.start(); + return loader; + } + + private destroyPreviousPreloader() { + this.previousPreloader?.destroy(); + this.previousPreloader = undefined; + } + + private destroyNextPreloader() { + this.nextPreloader?.destroy(); + this.nextPreloader = undefined; + } + + cancelBeforeNavigation(direction: 'previous' | 'next') { + switch (direction) { + case 'next': { + this.destroyPreviousPreloader(); + break; + } + case 'previous': { + this.destroyNextPreloader(); + break; + } + } + } + + updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + const movedForward = newCursor.current.id === oldCursor.nextAsset?.id; + const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id; + + if (!movedBackward) { + this.destroyPreviousPreloader(); + } + + if (!movedForward) { + this.destroyNextPreloader(); + } + + if (movedForward) { + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } else if (movedBackward) { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + } else { + this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink); + this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink); + } + } + + initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) { + if (cursor.nextAsset) { + this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink); + } + if (cursor.previousAsset) { + this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink); + } + } + + destroy() { + this.destroyNextPreloader(); + this.destroyPreviousPreloader(); + } +} + +export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index bb52c71260..3ccadf944f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -34,7 +34,9 @@ type PersonResponseDto, type StackResponseDto, } from '@immich/sdk'; - import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui'; + import LoadingDots from '$lib/components/LoadingDots.svelte'; + import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { mdiArrowLeft, mdiArrowRight, @@ -104,7 +106,16 @@ -
+
+ {#if assetViewerManager.isImageLoading} + + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8520e69a3d..3f7b048c8f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -5,6 +5,7 @@ import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte'; + import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte'; import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; @@ -12,9 +13,9 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { imageManager } from '$lib/managers/ImageManager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -37,6 +38,7 @@ } from '@immich/sdk'; import { CommandPaletteDefaultProvider } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; + import type { SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; @@ -93,20 +95,19 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, slideshowRepeat, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; - const asset = $derived(cursor.current); + let previewStackedAsset: AssetResponseDto | undefined = $state(); + let stack: StackResponseDto | null = $state(null); + + const asset = $derived(previewStackedAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); - let previewStackedAsset: AssetResponseDto | undefined = $state(); let fullscreenElement = $state(); - let unsubscribes: (() => void)[] = []; - let stack: StackResponseDto | null = $state(null); let playOriginalVideo = $state($alwaysLoadOriginalVideo); let slideshowStartAssetId = $state(); @@ -116,7 +117,7 @@ }; const refreshStack = async () => { - if (authManager.isSharedLink) { + if (authManager.isSharedLink || !withStacked) { return; } @@ -127,19 +128,17 @@ if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = null; } - - untrack(() => { - imageManager.preload(stack?.assets[1]); - }); }; const handleFavorite = async () => { - if (album && album.isActivityEnabled) { - try { - await activityManager.toggleLike(); - } catch (error) { - handleError(error, $t('errors.unable_to_change_favorite')); - } + if (!album || !album.isActivityEnabled) { + return; + } + + try { + await activityManager.toggleLike(); + } catch (error) { + handleError(error, $t('errors.unable_to_change_favorite')); } }; @@ -151,33 +150,34 @@ onMount(() => { syncAssetViewerOpenClass(true); - unsubscribes.push( - slideshowState.subscribe((value) => { - if (value === SlideshowState.PlaySlideshow) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - handlePromiseError(handlePlaySlideshow()); - } else if (value === SlideshowState.StopSlideshow) { - handlePromiseError(handleStopSlideshow()); - } - }), - slideshowNavigation.subscribe((value) => { - if (value === SlideshowNavigation.Shuffle) { - slideshowHistory.reset(); - slideshowHistory.queue(toTimelineAsset(asset)); - } - }), - ); + const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + handlePromiseError(handlePlaySlideshow()); + } else if (value === SlideshowState.StopSlideshow) { + handlePromiseError(handleStopSlideshow()); + } + }); + + const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => { + if (value === SlideshowNavigation.Shuffle) { + slideshowHistory.reset(); + slideshowHistory.queue(toTimelineAsset(asset)); + } + }); + + return () => { + slideshowStateUnsubscribe(); + slideshowNavigationUnsubscribe(); + }; }); onDestroy(() => { - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - activityManager.reset(); assetViewerManager.closeEditor(); syncAssetViewerOpenClass(false); + preloadManager.destroy(); }); const closeViewer = () => { @@ -194,8 +194,7 @@ }; const tracker = new InvocationTracker(); - - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next') => { if (!order) { if ($slideshowState === SlideshowState.PlaySlideshow) { order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; @@ -204,16 +203,19 @@ } } - e?.stopPropagation(); - imageManager.cancel(asset); + preloadManager.cancelBeforeNavigation(order); + if (tracker.isActive()) { return; } void tracker.invoke(async () => { + const isShuffle = + $slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; + let hasNext: boolean; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + if (isShuffle) { hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); if (!hasNext) { const asset = await onRandom?.(); @@ -227,17 +229,22 @@ order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); } - if ($slideshowState === SlideshowState.PlaySlideshow) { - if (hasNext) { - $restartSlideshowProgress = true; - } else if ($slideshowRepeat && slideshowStartAssetId) { - // Loop back to starting asset - await setAssetId(slideshowStartAssetId); - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; } + + if (hasNext) { + $restartSlideshowProgress = true; + return; + } + + if ($slideshowRepeat && slideshowStartAssetId) { + await setAssetId(slideshowStartAssetId); + $restartSlideshowProgress = true; + return; + } + + await handleStopSlideshow(); }, $t('error_while_navigating')); }; @@ -281,12 +288,14 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? asset : undefined; + const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { + previewStackedAsset = isMouseOver ? stackedAsset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; + const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.DELETE: @@ -359,17 +368,31 @@ await ocrManager.getAssetOcr(asset.id); } }; + $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset; untrack(() => handlePromiseError(refresh())); - imageManager.preload(cursor.nextAsset); - imageManager.preload(cursor.previousAsset); + }); + + let lastCursor = $state(); + + $effect(() => { + if (cursor.current.id === lastCursor?.current.id) { + return; + } + if (lastCursor) { + preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); + } + if (!lastCursor) { + preloadManager.initializePreloads(cursor, sharedLink); + } + lastCursor = cursor; }); const viewerKind = $derived.by(() => { if (previewStackedAsset) { - return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; + return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; } if (asset.type === AssetTypeEnum.Video) { return 'VideoViewer'; @@ -410,6 +433,24 @@ assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor, ); + + const onSwipe = (event: SwipeCustomEvent) => { + if (assetViewerManager.zoom > 1) { + return; + } + + if (ocrManager.showOverlay) { + return; + } + + if (event.detail.direction === 'left') { + navigateAsset('next'); + } + + if (event.detail.direction === 'right') { + navigateAsset('previous'); + } + }; @@ -456,23 +497,15 @@
{/if} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
navigateAsset('previous')} />
{/if} -
- {#if viewerKind === 'StackPhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} - {sharedLink} - /> - {:else if viewerKind === 'StackVideoViewer'} +
+ {#if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'PhotoViewer'} - navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} - {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} - /> + {:else if viewerKind === 'VideoViewer'} - {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset} + {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
navigateAsset('next')} />
diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 39088b23de..e84bc9fa0c 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -3,7 +3,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; - import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils'; + import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; @@ -81,15 +81,20 @@ await getPeople(); }); - $effect(() => { - const metrics = getContentMetrics(htmlElement); - - const imageBoundingBox = { - top: metrics.offsetY, - left: metrics.offsetX, - width: metrics.contentWidth, - height: metrics.contentHeight, + const imageContentMetrics = $derived.by(() => { + const natural = getNaturalSize(htmlElement); + const container = { width: containerWidth, height: containerHeight }; + const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container); + return { + contentWidth, + contentHeight, + offsetX: (containerWidth - contentWidth) / 2, + offsetY: (containerHeight - contentHeight) / 2, }; + }); + + $effect(() => { + const { offsetX, offsetY } = imageContentMetrics; if (!canvas) { return; @@ -105,8 +110,8 @@ } faceRect.set({ - top: imageBoundingBox.top + 200, - left: imageBoundingBox.left + 200, + top: offsetY + 200, + left: offsetX + 200, }); faceRect.setCoords(); @@ -214,13 +219,13 @@ } const { left, top, width, height } = faceRect.getBoundingRect(); - const metrics = getContentMetrics(htmlElement); + const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const natural = getNaturalSize(htmlElement); - const scaleX = natural.width / metrics.contentWidth; - const scaleY = natural.height / metrics.contentHeight; - const imageX = (left - metrics.offsetX) * scaleX; - const imageY = (top - metrics.offsetY) * scaleY; + const scaleX = natural.width / contentWidth; + const scaleY = natural.height / contentHeight; + const imageX = (left - offsetX) * scaleX; + const imageY = (top - offsetY) * scaleY; return { imageWidth: natural.width, diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 411c9f3ee3..55c765ce22 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,66 +1,56 @@ -
- +
+ +
transformManager.handleMouseDownOn(e, ResizeBoundary.None)} + >
+ + {#each edges as edge (edge)} + {@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)} + + {/each} + + {#each corners as corner (corner)} + {@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)} + + {/each} +
+
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 77290d3e6d..652cd0bee9 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -1,9 +1,10 @@ -import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; +import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { getDimensions } from '$lib/utils/asset-utils'; import { normalizeTransformEdits } from '$lib/utils/editor'; import { handleError } from '$lib/utils/handle-error'; import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk'; +import { clamp } from 'lodash-es'; import { tick } from 'svelte'; export type CropAspectRatio = @@ -37,17 +38,27 @@ type RegionConvertParams = { to: ImageDimensions; }; +export enum ResizeBoundary { + None = 'none', + TopLeft = 'top-left', + TopRight = 'top-right', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + Right = 'right', + Top = 'top', + Bottom = 'bottom', +} + class TransformManager implements EditToolManager { canReset: boolean = $derived.by(() => this.checkEdits()); hasChanges: boolean = $state(false); - darkenLevel = $state(0.65); isInteracting = $state(false); isDragging = $state(false); animationFrame = $state | null>(null); - canvasCursor = $state('default'); - dragOffset = $state({ x: 0, y: 0 }); - resizeSide = $state(''); + dragAnchor = $state({ x: 0, y: 0 }); + resizeSide = $state(ResizeBoundary.None); imgElement = $state(null); cropAreaEl = $state(null); overlayEl = $state(null); @@ -69,7 +80,6 @@ class TransformManager implements EditToolManager { const newAngle = this.imageRotation % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); - orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); edits = $derived.by(() => this.getEdits()); @@ -81,9 +91,9 @@ class TransformManager implements EditToolManager { return; } - const newCrop = transformManager.recalculateCrop(aspectRatio); + const newCrop = this.recalculateCrop(aspectRatio); if (newCrop) { - transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.animateCropChange(newCrop); this.region = newCrop; } } @@ -216,17 +226,11 @@ class TransformManager implements EditToolManager { } reset() { - this.darkenLevel = 0.65; this.isInteracting = false; this.animationFrame = null; - this.canvasCursor = 'default'; - this.dragOffset = { x: 0, y: 0 }; - this.resizeSide = ''; + this.dragAnchor = { x: 0, y: 0 }; + this.resizeSide = ResizeBoundary.None; this.imgElement = null; - if (this.cropAreaEl) { - this.cropAreaEl.style.cursor = ''; - } - document.body.style.cursor = ''; this.cropAreaEl = null; this.isDragging = false; this.overlayEl = null; @@ -295,12 +299,12 @@ class TransformManager implements EditToolManager { }; } - animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { - const cropFrame = element.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { + animateCropChange(to: Region, duration = 100) { + if (!this.cropFrame) { return; } + const from = this.region; const startTime = performance.now(); const initialCrop = { ...from }; @@ -334,28 +338,6 @@ class TransformManager implements EditToolManager { return { newWidth, newHeight }; } - // Calculate constrained dimensions based on aspect ratio and limits - getConstrainedDimensions( - desiredWidth: number, - desiredHeight: number, - maxWidth: number, - maxHeight: number, - minSize = 50, - ) { - const { newWidth, newHeight } = this.adjustDimensions( - desiredWidth, - desiredHeight, - this.cropAspectRatio, - maxWidth, - maxHeight, - minSize, - ); - return { - width: Math.max(minSize, Math.min(newWidth, maxWidth)), - height: Math.max(minSize, Math.min(newHeight, maxHeight)), - }; - } - adjustDimensions( newWidth: number, newHeight: number, @@ -364,49 +346,45 @@ class TransformManager implements EditToolManager { yLimit: number, minSize: number, ) { + if (aspectRatio === 'free') { + return { + newWidth: clamp(newWidth, minSize, xLimit), + newHeight: clamp(newHeight, minSize, yLimit), + }; + } + let w = newWidth; let h = newHeight; - let aspectMultiplier: number; + const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number); + const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h; - if (aspectRatio === 'free') { - aspectMultiplier = newWidth / newHeight; - } else { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; - } - - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; + h = w / aspectMultiplier; + // When dragging a corner, use the biggest region that fits 'inside' the mouse location. + if (h < newHeight) { + h = newHeight; + w = h * aspectMultiplier; } if (w > xLimit) { w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h > yLimit) { h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } if (w < minSize) { w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } + h = w / aspectMultiplier; } if (h < minSize) { h = minSize; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } + w = h * aspectMultiplier; } - if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w / h !== aspectMultiplier) { if (w < minSize) { h = w / aspectMultiplier; } @@ -428,10 +406,6 @@ class TransformManager implements EditToolManager { this.cropFrame.style.width = `${crop.width}px`; this.cropFrame.style.height = `${crop.height}px`; - this.drawOverlay(crop); - } - - drawOverlay(crop: Region) { if (!this.overlayEl) { return; } @@ -465,7 +439,6 @@ class TransformManager implements EditToolManager { const cropFrameEl = this.cropFrame; cropFrameEl?.classList.add('transition'); this.region = this.normalizeCropArea(scale); - cropFrameEl?.classList.add('transition'); cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { passive: true, }); @@ -540,7 +513,7 @@ class TransformManager implements EditToolManager { normalizeCropArea(scale: number) { const img = this.imgElement; if (!img) { - return { ...this.region }; + return this.region; } const scaleRatio = scale / this.cropImageScale; @@ -576,38 +549,17 @@ class TransformManager implements EditToolManager { this.draw(); } - handleMouseDown(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) { + if (e.button !== 0) { return; } - const { mouseX, mouseY } = this.getMousePosition(e); - - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if ( - onTopLeftCorner || - onTopRightCorner || - onBottomLeftCorner || - onBottomRightCorner || - onLeftBoundary || - onRightBoundary || - onTopBoundary || - onBottomBoundary - ) { - this.setResizeSide(mouseX, mouseY); - } else if (this.isInCropArea(mouseX, mouseY)) { - this.startDragging(mouseX, mouseY); + this.isInteracting = true; + this.resizeSide = resizeBoundary; + if (resizeBoundary === ResizeBoundary.None) { + this.isDragging = true; + const { mouseX, mouseY } = this.getMousePosition(e); + this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y }; } document.body.style.userSelect = 'none'; @@ -615,20 +567,16 @@ class TransformManager implements EditToolManager { } handleMouseMove(e: MouseEvent) { - const canvas = this.cropAreaEl; - if (!canvas) { + if (!this.cropAreaEl) { return; } - const resizeSideValue = this.resizeSide; const { mouseX, mouseY } = this.getMousePosition(e); if (this.isDragging) { this.moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { + } else if (this.resizeSide !== ResizeBoundary.None) { this.resizeCrop(mouseX, mouseY); - } else { - this.updateCursor(mouseX, mouseY); } } @@ -638,131 +586,42 @@ class TransformManager implements EditToolManager { this.isInteracting = false; this.isDragging = false; - this.resizeSide = ''; - this.fadeOverlay(true); // Darken the background + this.resizeSide = ResizeBoundary.None; } getMousePosition(e: MouseEvent) { - let offsetX = e.clientX; - let offsetY = e.clientY; - const clienRect = this.cropAreaEl?.getBoundingClientRect(); - const rotateDeg = this.normalizedRotation; - - if (rotateDeg == 90) { - offsetX = e.clientY - (clienRect?.top ?? 0); - offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - } else if (rotateDeg == 180) { - offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - } else if (rotateDeg == 270) { - offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - offsetY = e.clientX - (clienRect?.left ?? 0); - } else if (rotateDeg == 0) { - offsetX -= clienRect?.left ?? 0; - offsetY -= clienRect?.top ?? 0; + if (!this.cropAreaEl) { + throw new Error('Crop area is undefined'); } - return { mouseX: offsetX, mouseY: offsetY }; - } + const clientRect = this.cropAreaEl.getBoundingClientRect(); - // Boundary detection helpers - private isInRange(value: number, target: number, sensitivity: number): boolean { - return value >= target - sensitivity && value <= target + sensitivity; - } - - private isWithinBounds(value: number, min: number, max: number): boolean { - return value >= min && value <= max; - } - - isOnCropBoundary(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - const sensitivity = 10; - const cornerSensitivity = 15; - const { width: imgWidth, height: imgHeight } = this.previewImageSize; - - const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; - if (outOfBound) { - return { - onLeftBoundary: false, - onRightBoundary: false, - onTopBoundary: false, - onBottomBoundary: false, - onTopLeftCorner: false, - onTopRightCorner: false, - onBottomLeftCorner: false, - onBottomRightCorner: false, - }; + switch (this.normalizedRotation) { + case 90: { + return { + mouseX: e.clientY - clientRect.top, + mouseY: -e.clientX + clientRect.right, + }; + } + case 180: { + return { + mouseX: -e.clientX + clientRect.right, + mouseY: -e.clientY + clientRect.bottom, + }; + } + case 270: { + return { + mouseX: -e.clientY + clientRect.bottom, + mouseY: e.clientX - clientRect.left, + }; + } + // also case 0: + default: { + return { + mouseX: e.clientX - clientRect.left, + mouseY: e.clientY - clientRect.top, + }; + } } - - const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onRightBoundary = - this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height); - const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - const onBottomBoundary = - this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width); - - const onTopLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onTopRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); - const onBottomLeftCorner = - this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - const onBottomRightCorner = - this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); - - return { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - }; - } - - isInCropArea(mouseX: number, mouseY: number) { - const { x, y, width, height } = this.region; - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; - } - - setResizeSide(mouseX: number, mouseY: number) { - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (onTopLeftCorner) { - this.resizeSide = 'top-left'; - } else if (onTopRightCorner) { - this.resizeSide = 'top-right'; - } else if (onBottomLeftCorner) { - this.resizeSide = 'bottom-left'; - } else if (onBottomRightCorner) { - this.resizeSide = 'bottom-right'; - } else if (onLeftBoundary) { - this.resizeSide = 'left'; - } else if (onRightBoundary) { - this.resizeSide = 'right'; - } else if (onTopBoundary) { - this.resizeSide = 'top'; - } else if (onBottomBoundary) { - this.resizeSide = 'bottom'; - } - } - - startDragging(mouseX: number, mouseY: number) { - this.isDragging = true; - const crop = this.region; - this.isInteracting = true; - this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y }; - this.fadeOverlay(false); } moveCrop(mouseX: number, mouseY: number) { @@ -772,102 +631,116 @@ class TransformManager implements EditToolManager { } this.hasChanges = true; - const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); - const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); - - this.region = { - ...this.region, - x: newX, - y: newY, - }; + this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width); + this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height); this.draw(); } resizeCrop(mouseX: number, mouseY: number) { const canvas = this.cropAreaEl; - const crop = this.region; - const resizeSideValue = this.resizeSide; - if (!canvas || !resizeSideValue) { + const currentCrop = this.region; + if (!canvas) { return; } - this.fadeOverlay(false); + this.isInteracting = true; this.hasChanges = true; - const { x, y, width, height } = crop; + const { x, y, width, height } = currentCrop; const minSize = 50; - let newRegion = { ...crop }; + let newRegion = { ...currentCrop }; - switch (resizeSideValue) { - case 'left': { - const desiredWidth = width + (x - mouseX); - if (desiredWidth >= minSize && mouseX >= 0) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); - const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); - newRegion = { - x: Math.max(0, x + width - finalWidth), - y, - width: finalWidth, - height: finalHeight, - }; - } + let desiredWidth = width; + let desiredHeight = height; + + // Width + switch (this.resizeSide) { + case ResizeBoundary.Left: + case ResizeBoundary.TopLeft: + case ResizeBoundary.BottomLeft: { + desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0))); break; } - case 'right': { - const desiredWidth = mouseX - x; - if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { - const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); - newRegion = { - ...newRegion, - width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)), - height: Math.max(minSize, Math.min(h, canvas.clientHeight)), - }; - } + case ResizeBoundary.Right: + case ResizeBoundary.TopRight: + case ResizeBoundary.BottomRight: { + desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x); break; } - case 'top': { - const desiredHeight = height + (y - mouseY); - if (desiredHeight >= minSize && mouseY >= 0) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - newRegion = { - x, - y: Math.max(0, y + height - h), - width: w, - height: h, - }; - } + } + + // Height + switch (this.resizeSide) { + case ResizeBoundary.Top: + case ResizeBoundary.TopLeft: + case ResizeBoundary.TopRight: { + desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0))); break; } - case 'bottom': { - const desiredHeight = mouseY - y; - if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { - const { newWidth: w, newHeight: h } = this.adjustDimensions( - width, - desiredHeight, - this.cropAspectRatio, - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - newRegion = { - ...newRegion, - width: w, - height: h, - }; - } + case ResizeBoundary.Bottom: + case ResizeBoundary.BottomLeft: + case ResizeBoundary.BottomRight: { + desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y); break; } - case 'top-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = height + (y - Math.max(mouseY, 0)); + } + + // Old + switch (this.resizeSide) { + case ResizeBoundary.Left: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + const finalWidth = clamp(w, minSize, canvas.clientWidth); + newRegion = { + x: Math.max(0, x + width - finalWidth), + y, + width: finalWidth, + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Right: { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + newRegion = { + ...newRegion, + width: clamp(w, minSize, canvas.clientWidth - x), + height: clamp(h, minSize, canvas.clientHeight), + }; + break; + } + case ResizeBoundary.Top: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case ResizeBoundary.Bottom: { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + break; + } + case ResizeBoundary.TopLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -884,9 +757,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'top-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = height + (y - Math.max(mouseY, 0)); + case ResizeBoundary.TopRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -903,9 +774,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-left': { - const desiredWidth = width + (x - Math.max(mouseX, 0)); - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomLeft: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -922,9 +791,7 @@ class TransformManager implements EditToolManager { }; break; } - case 'bottom-right': { - const desiredWidth = Math.max(mouseX, 0) - x; - const desiredHeight = Math.max(mouseY, 0) - y; + case ResizeBoundary.BottomRight: { const { newWidth: w, newHeight: h } = this.adjustDimensions( desiredWidth, desiredHeight, @@ -952,95 +819,6 @@ class TransformManager implements EditToolManager { this.draw(); } - updateCursor(mouseX: number, mouseY: number) { - if (!this.cropAreaEl) { - return; - } - - let { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = this.isOnCropBoundary(mouseX, mouseY); - - if (this.normalizedRotation == 90) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onLeftBoundary, - onTopBoundary, - onRightBoundary, - onBottomBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onBottomLeftCorner, - onTopLeftCorner, - onTopRightCorner, - onBottomRightCorner, - ]; - } else if (this.normalizedRotation == 180) { - [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; - [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; - - [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; - [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; - } else if (this.normalizedRotation == 270) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onRightBoundary, - onBottomBoundary, - onLeftBoundary, - onTopBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onTopRightCorner, - onBottomRightCorner, - onBottomLeftCorner, - onTopLeftCorner, - ]; - } - - let cursorName: string; - if (onTopLeftCorner || onBottomRightCorner) { - cursorName = 'nwse-resize'; - } else if (onTopRightCorner || onBottomLeftCorner) { - cursorName = 'nesw-resize'; - } else if (onLeftBoundary || onRightBoundary) { - cursorName = 'ew-resize'; - } else if (onTopBoundary || onBottomBoundary) { - cursorName = 'ns-resize'; - } else if (this.isInCropArea(mouseX, mouseY)) { - cursorName = 'move'; - } else { - cursorName = 'default'; - } - - if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) { - this.canvasCursor = cursorName; - document.body.style.cursor = cursorName; - this.cropAreaEl.style.cursor = cursorName; - } - } - - fadeOverlay(toDark: boolean) { - const overlay = this.overlayEl; - const cropFrame = document.querySelector('.crop-frame'); - - if (toDark) { - overlay?.classList.remove('light'); - cropFrame?.classList.remove('resizing'); - } else { - overlay?.classList.add('light'); - cropFrame?.classList.add('resizing'); - } - - this.isInteracting = !toDark; - } - resetCrop() { this.cropAspectRatio = 'free'; this.region = { From 0ac3d6a83a633ed4832de88f923b95644b825479 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Wed, 11 Mar 2026 13:38:08 -0500 Subject: [PATCH 12/19] fix(web): face selection box position resetting on browser resize (#26766) --- .../face-editor/face-editor.svelte | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index e84bc9fa0c..8b3d672bfe 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -74,6 +74,7 @@ canvas.add(faceRect); canvas.setActiveObject(faceRect); + setDefaultFaceRectanglePosition(faceRect); }; onMount(async () => { @@ -93,9 +94,19 @@ }; }); - $effect(() => { + const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const { offsetX, offsetY } = imageContentMetrics; + faceRect.set({ + top: offsetY + 200, + left: offsetX + 200, + }); + + faceRect.setCoords(); + positionFaceSelector(); + }; + + $effect(() => { if (!canvas) { return; } @@ -109,15 +120,21 @@ return; } - faceRect.set({ - top: offsetY + 200, - left: offsetX + 200, - }); - - faceRect.setCoords(); - positionFaceSelector(); + if (!isFaceRectIntersectingCanvas(faceRect, canvas)) { + setDefaultFaceRectanglePosition(faceRect); + } }); + const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => { + const faceBox = faceRect.getBoundingRect(); + return !( + 0 > faceBox.left + faceBox.width || + 0 > faceBox.top + faceBox.height || + canvas.width < faceBox.left || + canvas.height < faceBox.top + ); + }; + const cancel = () => { isFaceEditMode.value = false; }; From d49d9956112a52a6a7f3b142fb1837a084590c04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:03:19 +0100 Subject: [PATCH 13/19] chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7853a3000b..69e2da45f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,7 +248,7 @@ importers: version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 globals: specifier: ^17.0.0 version: 17.4.0 @@ -456,7 +456,7 @@ importers: version: 4.4.0 exiftool-vendored: specifier: ^35.0.0 - version: 35.10.1 + version: 35.13.1 express: specifier: ^5.1.0 version: 5.2.1 @@ -3919,8 +3919,8 @@ packages: peerDependencies: '@photo-sphere-viewer/core': 5.14.1 - '@photostructure/tz-lookup@11.4.0': - resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==} + '@photostructure/tz-lookup@11.5.0': + resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -7210,17 +7210,17 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - exiftool-vendored.exe@13.51.0: - resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==} + exiftool-vendored.exe@13.52.0: + resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==} os: [win32] - exiftool-vendored.pl@13.51.0: - resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==} + exiftool-vendored.pl@13.52.0: + resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==} os: ['!win32'] hasBin: true - exiftool-vendored@35.10.1: - resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==} + exiftool-vendored@35.13.1: + resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==} engines: {node: '>=20.0.0'} expect-type@1.3.0: @@ -15989,7 +15989,7 @@ snapshots: '@photo-sphere-viewer/core': 5.14.1 three: 0.182.0 - '@photostructure/tz-lookup@11.4.0': {} + '@photostructure/tz-lookup@11.5.0': {} '@pkgjs/parseargs@0.11.0': optional: true @@ -19617,21 +19617,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - exiftool-vendored.exe@13.51.0: + exiftool-vendored.exe@13.52.0: optional: true - exiftool-vendored.pl@13.51.0: {} + exiftool-vendored.pl@13.52.0: {} - exiftool-vendored@35.10.1: + exiftool-vendored@35.13.1: dependencies: - '@photostructure/tz-lookup': 11.4.0 + '@photostructure/tz-lookup': 11.5.0 '@types/luxon': 3.7.1 batch-cluster: 17.3.1 - exiftool-vendored.pl: 13.51.0 + exiftool-vendored.pl: 13.52.0 he: 1.2.0 luxon: 3.7.2 optionalDependencies: - exiftool-vendored.exe: 13.51.0 + exiftool-vendored.exe: 13.52.0 expect-type@1.3.0: {} From 4773788a8833ba45058c06094de20451c11e6b6c Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 11 Mar 2026 20:04:26 +0100 Subject: [PATCH 14/19] chore: more unused release workflow cleanup (#26817) --- .github/workflows/release.yml | 149 ---------------------------------- 1 file changed, 149 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 30e9c1c7ca..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: release.yml -on: - pull_request: - types: [closed] - paths: - - CHANGELOG.md - -jobs: - # Maybe double check PR source branch? - - merge_translations: - uses: ./.github/workflows/merge-translations.yml - permissions: - pull-requests: write - secrets: - PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }} - PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }} - - build_mobile: - uses: ./.github/workflows/build-mobile.yml - needs: merge_translations - permissions: - contents: read - secrets: - KEY_JKS: ${{ secrets.KEY_JKS }} - ALIAS: ${{ secrets.ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }} - # iOS secrets - APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} - IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl - IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }} - IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }} - FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} - with: - ref: main - environment: production - - prepare_release: - runs-on: ubuntu-latest - needs: build_mobile - permissions: - actions: read # To download the app artifact - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} - private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - token: ${{ steps.generate-token.outputs.token }} - persist-credentials: false - ref: main - - - name: Extract changelog - id: changelog - run: | - CHANGELOG_PATH=$RUNNER_TEMP/changelog.md - sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH - echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT - VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download APK - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - with: - name: release-apk-signed - github-token: ${{ steps.generate-token.outputs.token }} - - - name: Create draft release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.result }} - token: ${{ steps.generate-token.outputs.token }} - body_path: ${{ steps.changelog.outputs.path }} - draft: true - files: | - docker/docker-compose.yml - docker/docker-compose.rootless.yml - docker/example.env - docker/hwaccel.ml.yml - docker/hwaccel.transcoding.yml - docker/prometheus.yml - *.apk - - - name: Rename Outline document - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - continue-on-error: true - env: - OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }} - VERSION: ${{ steps.changelog.outputs.version }} - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const outlineKey = process.env.OUTLINE_API_KEY; - const version = process.env.VERSION; - const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'; - const baseUrl = 'https://outline.immich.cloud'; - - const listResponse = await fetch(`${baseUrl}/api/documents.list`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ parentDocumentId }) - }); - - if (!listResponse.ok) { - throw new Error(`Outline list failed: ${listResponse.statusText}`); - } - - const listData = await listResponse.json(); - const allDocuments = listData.data || []; - const document = allDocuments.find(doc => doc.title === 'next'); - - if (document) { - console.log(`Found document 'next', renaming to '${version}'...`); - - const updateResponse = await fetch(`${baseUrl}/api/documents.update`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${outlineKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - id: document.id, - title: version - }) - }); - - if (!updateResponse.ok) { - throw new Error(`Failed to rename document: ${updateResponse.statusText}`); - } - } else { - console.log('No document titled "next" found to rename'); - } From 471c27cd33adf01ef40145de59a0e6e46a3ff231 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:15:18 +0000 Subject: [PATCH 15/19] chore(mobile): remove background from asset viewer back button (#26851) We recently changed the asset viewer to use a gradient. The circle button looks out of place now. --- .../widgets/asset_viewer/viewer_top_app_bar.widget.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 397cd98ace..ae7dd85396 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); - final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; - return Padding( padding: const EdgeInsets.only(left: 12.0), child: ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, + backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent, shape: const CircleBorder(), iconSize: 22, - iconColor: foregroundColor, + iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white, padding: EdgeInsets.zero, elevation: showingDetails ? 4 : 0, ), From 6c531e0a5a34af52b676bf0c5d1e2be8bee8f985 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 11 Mar 2026 14:15:31 -0500 Subject: [PATCH 16/19] chore: add shadow to video play/pause icon shadow (#26836) --- .../asset_viewer/animated_play_pause.dart | 35 +++++++++++++++---- .../widgets/asset_viewer/video_controls.dart | 19 +++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart index e7ceac6105..4be7f49b5a 100644 --- a/mobile/lib/widgets/asset_viewer/animated_play_pause.dart +++ b/mobile/lib/widgets/asset_viewer/animated_play_pause.dart @@ -1,12 +1,15 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; /// A widget that animates implicitly between a play and a pause icon. class AnimatedPlayPause extends StatefulWidget { - const AnimatedPlayPause({super.key, required this.playing, this.size, this.color}); + const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows}); final double? size; final bool playing; final Color? color; + final List? shadows; @override State createState() => AnimatedPlayPauseState(); @@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State with SingleTickerP @override Widget build(BuildContext context) { + final icon = AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ); + return Center( - child: AnimatedIcon( - color: widget.color, - size: widget.size, - icon: AnimatedIcons.play_pause, - progress: animationController, + child: Stack( + alignment: Alignment.center, + children: [ + for (final shadow in widget.shadows ?? const []) + Transform.translate( + offset: shadow.offset, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2), + child: AnimatedIcon( + color: shadow.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ), + ), + icon, + ], ), ); } diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 4eed3903c9..85707c82ea 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -72,17 +72,14 @@ class VideoControls extends HookConsumerWidget { children: [ Row( children: [ - IconTheme( - data: const IconThemeData(shadows: _controlShadows), - child: IconButton( - iconSize: 32, - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(), - icon: isFinished - ? const Icon(Icons.replay, color: Colors.white, size: 32) - : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying), - onPressed: () => _toggle(ref, isCasting), - ), + IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + icon: isFinished + ? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows) + : AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows), + onPressed: () => _toggle(ref, isCasting), ), const Spacer(), Text( From 5c3777ab467cfc634e66abefdc58a68121efb0cf Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 12 Mar 2026 10:37:29 -0400 Subject: [PATCH 17/19] fix(web): fix zoom touch event handling (#26866) fix(web): fix zoom touch event handling and add clarifying comments - Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom - Add comment explaining pointer-events-none on zoom transform wrapper - Add comments for touchAction and overflow style overrides --- web/src/lib/actions/zoom-image.ts | 20 ++++++++++++++++++++ web/src/lib/components/AdaptiveImage.svelte | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 66659997d2..35c3d3a106 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -23,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.addEventListener('wheel', onInteractionStart, { capture: true }); node.addEventListener('pointerdown', onInteractionStart, { capture: true }); + // Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart + // handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's + // handler which conflicts. Chrome does not fire synthetic dblclick on touch. + let lastPointerWasTouch = false; + const trackPointerType = (event: PointerEvent) => { + lastPointerWasTouch = event.pointerType === 'touch'; + }; + const suppressTouchDblClick = (event: MouseEvent) => { + if (lastPointerWasTouch) { + event.stopImmediatePropagation(); + } + }; + node.addEventListener('pointerdown', trackPointerType, { capture: true }); + node.addEventListener('dblclick', suppressTouchDblClick, { capture: true }); + + // Allow zoomed content to render outside the container bounds node.style.overflow = 'visible'; + // Prevent browser handling of touch gestures so zoom-image can manage them + node.style.touchAction = 'none'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; @@ -34,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea } node.removeEventListener('wheel', onInteractionStart, { capture: true }); node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', trackPointerType, { capture: true }); + node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 92e3fad2d3..fad4d49d1b 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -162,8 +162,9 @@
{@render backdrop?.()} +
From 3bd37ebbfbf4dfacbe98ca3f20a79b5cb1c6efb3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:53:46 -0400 Subject: [PATCH 18/19] refactor: clean class (#26879) --- pnpm-lock.yaml | 20 +++++++---- web/package.json | 2 ++ web/src/lib/components/AlphaBackground.svelte | 5 +-- web/src/lib/components/LoadingDots.svelte | 3 +- web/src/lib/components/QueueCard.svelte | 11 +++++-- web/src/lib/components/QueueCardBadge.svelte | 26 ++++++++------- web/src/lib/components/QueueCardButton.svelte | 33 +++++++++---------- web/src/lib/components/QueueGraph.svelte | 5 +-- web/src/lib/index.spec.ts | 15 +++++++++ web/src/lib/index.ts | 16 +++++++++ 10 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 web/src/lib/index.spec.ts create mode 100644 web/src/lib/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e2da45f7..9d47ba73f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,12 @@ importers: tabbable: specifier: ^6.2.0 version: 6.4.0 + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -11252,8 +11258,8 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} tailwind-variants@3.2.2: resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} @@ -14959,8 +14965,8 @@ snapshots: simple-icons: 16.9.0 svelte: 5.53.7 svelte-highlight: 7.9.0 - tailwind-merge: 3.4.0 - tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1) + tailwind-merge: 3.5.0 + tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwindcss: 4.2.1 transitivePeerDependencies: - '@sveltejs/kit' @@ -24554,13 +24560,13 @@ snapshots: tabbable@6.4.0: {} - tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} - tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1): + tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1): dependencies: tailwindcss: 4.2.1 optionalDependencies: - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: diff --git a/web/package.json b/web/package.json index 67460e87e5..9c63b2e5f5 100644 --- a/web/package.json +++ b/web/package.json @@ -60,6 +60,8 @@ "svelte-maplibre": "^1.2.5", "svelte-persisted-store": "^0.12.0", "tabbable": "^6.2.0", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", "thumbhash": "^0.1.1", "transformation-matrix": "^3.1.0", "uplot": "^1.6.32" diff --git a/web/src/lib/components/AlphaBackground.svelte b/web/src/lib/components/AlphaBackground.svelte index c0d8536a2f..5c3869d587 100644 --- a/web/src/lib/components/AlphaBackground.svelte +++ b/web/src/lib/components/AlphaBackground.svelte @@ -1,11 +1,12 @@ -
+
diff --git a/web/src/lib/components/LoadingDots.svelte b/web/src/lib/components/LoadingDots.svelte index 3dcfcb8122..7e6692021f 100644 --- a/web/src/lib/components/LoadingDots.svelte +++ b/web/src/lib/components/LoadingDots.svelte @@ -1,4 +1,5 @@ -
+
{#each [0, 1, 2] as i (i)} diff --git a/web/src/lib/components/QueueCard.svelte b/web/src/lib/components/QueueCard.svelte index b7cde7b8f1..448558ed9f 100644 --- a/web/src/lib/components/QueueCard.svelte +++ b/web/src/lib/components/QueueCard.svelte @@ -1,4 +1,5 @@ - -
+
{@render children?.()}
diff --git a/web/src/lib/components/QueueCardButton.svelte b/web/src/lib/components/QueueCardButton.svelte index f71d8a3e44..9964b8fd1a 100644 --- a/web/src/lib/components/QueueCardButton.svelte +++ b/web/src/lib/components/QueueCardButton.svelte @@ -4,6 +4,7 @@ - diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index f2a23216df..01327643a1 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -1,4 +1,5 @@ -
+
{#if data[0].length === 0} {/if} diff --git a/web/src/lib/index.spec.ts b/web/src/lib/index.spec.ts new file mode 100644 index 0000000000..bda5a9e722 --- /dev/null +++ b/web/src/lib/index.spec.ts @@ -0,0 +1,15 @@ +import { cleanClass } from '$lib'; + +describe('cleanClass', () => { + it('should return a string of class names', () => { + expect(cleanClass('class1', 'class2', 'class3')).toBe('class1 class2 class3'); + }); + + it('should filter out undefined, null, and false values', () => { + expect(cleanClass('class1', undefined, 'class2', null, 'class3', false)).toBe('class1 class2 class3'); + }); + + it('should unnest arrays', () => { + expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); + }); +}); diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000000..b4fc195626 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1,16 @@ +import { twMerge } from 'tailwind-merge'; + +export const cleanClass = (...classNames: unknown[]) => { + return twMerge( + classNames + .flatMap((className) => (Array.isArray(className) ? className : [className])) + .filter((className) => { + if (!className || typeof className === 'boolean') { + return false; + } + + return typeof className === 'string'; + }) + .join(' '), + ); +}; From d4605b21d99fa7f9bc21689e932d26ef55870874 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 10:55:33 -0400 Subject: [PATCH 19/19] refactor: external links (#26880) --- .../admin-settings/AuthSettings.svelte | 11 ++------- .../admin-settings/BackupSettings.svelte | 10 +++----- .../admin-settings/FFmpegSettings.svelte | 14 ++++------- .../admin-settings/LibrarySettings.svelte | 8 +++---- .../admin-settings/MapSettings.svelte | 10 ++------ .../StorageTemplateSettings.svelte | 20 ++++------------ .../onboarding-page/onboarding-backup.svelte | 24 +++++-------------- .../AuthDisableLoginConfirmModal.svelte | 11 ++------- 8 files changed, 26 insertions(+), 82 deletions(-) diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index aec1761998..25af7bf2c1 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -11,7 +11,7 @@ import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin } from '@immich/sdk'; - import { Button, modalManager, Text, toastManager } from '@immich/ui'; + import { Button, Link, modalManager, Text, toastManager } from '@immich/ui'; import { mdiRestart } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -75,14 +75,7 @@ {#snippet children({ message })} - - {message} - + {message} {/snippet} diff --git a/web/src/lib/components/admin-settings/BackupSettings.svelte b/web/src/lib/components/admin-settings/BackupSettings.svelte index fc374ddd6f..7fd22a2b6d 100644 --- a/web/src/lib/components/admin-settings/BackupSettings.svelte +++ b/web/src/lib/components/admin-settings/BackupSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -52,15 +53,10 @@

{#snippet children({ message })} - + {message}
-
+ {/snippet}

diff --git a/web/src/lib/components/admin-settings/FFmpegSettings.svelte b/web/src/lib/components/admin-settings/FFmpegSettings.svelte index e062b616b3..95aa9d74f2 100644 --- a/web/src/lib/components/admin-settings/FFmpegSettings.svelte +++ b/web/src/lib/components/admin-settings/FFmpegSettings.svelte @@ -18,7 +18,7 @@ VideoCodec, VideoContainer, } from '@immich/sdk'; - import { Icon } from '@immich/ui'; + import { Icon, Link } from '@immich/ui'; import { mdiHelpCircleOutline } from '@mdi/js'; import { isEqual, sortBy } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -38,17 +38,11 @@ {#snippet children({ tag, message })} {#if tag === 'h264-link'} - - {message} - + {message} {:else if tag === 'hevc-link'} - - {message} - + {message} {:else if tag === 'vp9-link'} - - {message} - + {message} {/if} {/snippet} diff --git a/web/src/lib/components/admin-settings/LibrarySettings.svelte b/web/src/lib/components/admin-settings/LibrarySettings.svelte index a91a5eb97a..52c2eb8d4f 100644 --- a/web/src/lib/components/admin-settings/LibrarySettings.svelte +++ b/web/src/lib/components/admin-settings/LibrarySettings.svelte @@ -8,6 +8,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -73,14 +74,11 @@

{#snippet children({ message })} - {message} - + {/snippet}

diff --git a/web/src/lib/components/admin-settings/MapSettings.svelte b/web/src/lib/components/admin-settings/MapSettings.svelte index 692a5cfcf5..5888c82611 100644 --- a/web/src/lib/components/admin-settings/MapSettings.svelte +++ b/web/src/lib/components/admin-settings/MapSettings.svelte @@ -7,6 +7,7 @@ import FormatMessage from '$lib/elements/FormatMessage.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; + import { Link } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -54,14 +55,7 @@

{#snippet children({ message })} - - {message} - + {message} {/snippet}

diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index 7018bc5d04..8ccb3f7781 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -12,7 +12,7 @@ import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; - import { Heading, LoadingSpinner, Text } from '@immich/ui'; + import { Heading, Link, LoadingSpinner, Text } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; import { onDestroy } from 'svelte'; @@ -112,23 +112,11 @@ {#snippet children({ tag, message })} {#if tag === 'template-link'} - - {message} - + {message} {:else if tag === 'implications-link'} - + {message} - + {/if} {/snippet} diff --git a/web/src/lib/components/onboarding-page/onboarding-backup.svelte b/web/src/lib/components/onboarding-page/onboarding-backup.svelte index 146661884b..7d7f51c392 100644 --- a/web/src/lib/components/onboarding-page/onboarding-backup.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-backup.svelte @@ -1,6 +1,6 @@