Compare commits

..

23 Commits

Author SHA1 Message Date
midzelis 748e5dfcd5 feat: 'ken burns' style slideshow effect
Change-Id: I22c2bfbf08b5b0e0f4eb9f25e1efe0e16a6a6964

feat(web): manual compositor-driven crossfade for slideshow transitions

Change-Id: If1119429abd2689595defcb8831442506a6a6964

resolve conflict: event rename

Change-Id: I4d7c8d2a16237b42e49b87734764e18f6a6a6964

Change-Id: I22c2bfbf08b5b0e0f4eb9f25e1efe0e16a6a6964

resolve xxn conflicts

Change-Id: I86c287c6c8b59a5549153f21d1c092586a6a6964
2026-03-24 15:52:32 +00:00
midzelis 148f7a7fdb feat(web): hero view transitions for memory viewer
Change-Id: I6221557a6b8561122baccbc651a48ae46a6a6964
2026-03-24 15:43:06 +00:00
midzelis e79a98fa82 feat(web): view transitions from timeline to viewer
Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
2026-03-24 15:43:06 +00:00
midzelis 590a9df7ec feat(web): face overlay hover UX and face editor zoom preservation
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964

refactor(web): use asset metadata for face editor image dimensions instead of DOM

The face editor previously read naturalWidth/naturalHeight from the DOM
element via a $effect + load event listener. This was fragile on slow
hardware (ARM CI) because imgRef changes as AdaptiveImage progresses
through quality levels, and the DOM element's natural dimensions could
be 0 during transitions.

Now the face editor receives imageSize as a prop from the parent, derived
from the asset's metadata dimensions which are always available immediately.

Change-Id: Id4c3a59110feff4c50f429bbd744eac46a6a6964
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964
2026-03-24 15:24:12 +00:00
midzelis ed04d87273 feat(web): OCR overlay interactivity during zoom
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
2026-03-23 13:16:08 +00:00
Luis Nachtigall 00dae6ac38 fix(mobile): cronet image cache clearing on android (#27054) 2026-03-20 18:28:24 -04:00
Daniel Dietzler 5a8fd40dc5 fix: svelte reactivity issues (#27109) 2026-03-20 15:56:16 -04:00
Jason Rasmussen 813d684aaa fix: shared link add to album (#27063) 2026-03-20 13:14:07 -05:00
Luis Nachtigall 644f705be1 fix(mobile): correct maximumSizeBytes setter to use maximumSizeBytes property (#27098)
fix: correct maximumSizeBytes setter to use maximumSizeBytes property
2026-03-20 12:22:59 -04:00
Thomas f3e4bcc733 fix(server): queue version check job when config changed (#27094)
Nothing happens when the version checks are enabled, which means the
server may return very stale versions until the next job runs.

Refs: #26935
2026-03-20 10:07:40 -05:00
Michel Heusschen 9a0c17fdb8 fix(web): preserve album scroll when adding to other albums (#27078) 2026-03-20 09:42:19 -05:00
Michel Heusschen b7c4497dfd fix(web): allow showing combobox items outside modals (#27075)
fix(web): allow showing combobox items outside of modals
2026-03-20 09:41:34 -05:00
Yaros 9c227aeaf5 fix(mobile): simplified chinese not available (#27066) 2026-03-20 00:27:50 -05:00
github-actions e939fde6f1 chore: version v2.6.1 2026-03-19 17:56:42 +00:00
Mert 019beaed0b fix(mobile): server url migration (#27050)
decode json
2026-03-19 12:52:51 -05:00
Jason Rasmussen 0e4d6d4eac fix(web): disable send button (#27051)
fix(web): disable button while sending
2026-03-19 17:39:13 +00:00
Daniel Dietzler 79f978ddeb fix: writing empty exif tags (#27025) 2026-03-19 17:17:56 +00:00
Jason Rasmussen f2445ecab1 fix(web): stop in-progress uploads on logout (#27021) 2026-03-19 13:05:11 -04:00
Jason Rasmussen 86311e3cfe fix(web): wrap long album title (#27012) 2026-03-19 13:04:56 -04:00
Jason Rasmussen 29000461c2 fix: ignore errors deleting untitled album (#27020) 2026-03-19 12:55:43 -04:00
Mees Frensel b30373b24f fix: release rootles compose file (#27048) 2026-03-19 16:45:11 +00:00
bo0tzz bc2439883a fix: track PR template auto-close with label (#27042)
* fix: track PR template auto-close with label

* fix: format
2026-03-19 12:42:29 -04:00
Jason Rasmussen 044257531e fix(server): fallback to email when name is empty (#27016) 2026-03-19 12:41:20 -04:00
112 changed files with 4076 additions and 966 deletions
+21 -4
View File
@@ -6,6 +6,9 @@ on:
permissions: {} permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs: jobs:
parse: parse:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -46,9 +49,10 @@ jobs:
run: | run: |
gh api graphql \ gh api graphql \
-f prId="$NODE_ID" \ -f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \ -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query=' -f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) { mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
addComment(input: { addComment(input: {
subjectId: $prId, subjectId: $prId,
body: $body body: $body
@@ -60,21 +64,34 @@ jobs:
}) { }) {
__typename __typename
} }
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}' }'
- name: Reopen PR (sections now present, PR closed) - name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }} if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }} NODE_ID: ${{ github.event.pull_request.node_id }}
run: | run: |
gh api graphql \ gh api graphql \
-f prId="$NODE_ID" \ -f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query=' -f query='
mutation ReopenPR($prId: ID!) { mutation ReopenPR($prId: ID!, $labelId: ID!) {
reopenPullRequest(input: { reopenPullRequest(input: {
pullRequestId: $prId pullRequestId: $prId
}) { }) {
__typename __typename
} }
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}' }'
+1
View File
@@ -151,6 +151,7 @@ jobs:
body_path: misc/release/notes.tmpl body_path: misc/release/notes.tmpl
files: | files: |
docker/docker-compose.yml docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env docker/example.env
docker/hwaccel.ml.yml docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml docker/hwaccel.transcoding.yml
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.6.0", "version": "2.6.1",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
+2 -2
View File
@@ -1,7 +1,7 @@
[ [
{ {
"label": "v2.6.0", "label": "v2.6.1",
"url": "https://docs.v2.6.0.archive.immich.app" "url": "https://docs.v2.6.1.archive.immich.app"
}, },
{ {
"label": "v2.5.6", "label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "2.6.0", "version": "2.6.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils'; import { setupAssetViewerFixture } from './utils';
const waitForSelectorTransition = async (page: Page) => { const waitForSelectorTransition = async (page: Page) => {
await page.waitForFunction( await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
() => { await page.locator('#face-selector').evaluate(
const selector = document.querySelector('#face-selector') as HTMLElement | null; (el) =>
if (!selector) { new Promise<void>((resolve) => {
return false; requestAnimationFrame(() =>
requestAnimationFrame(() => {
const animations = el.getAnimations();
if (animations.length === 0) {
resolve();
return;
} }
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished'); void Promise.all(animations.map((a) => a.finished)).then(() => resolve());
}, }),
undefined, );
{ timeout: 1000, polling: 50 }, }),
); );
}; };
@@ -95,7 +100,7 @@ test.describe('face-editor', () => {
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 }); await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
await page.mouse.up(); await page.mouse.up();
await page.waitForTimeout(300); await waitForSelectorTransition(page);
}; };
test('Face editor opens with person list', async ({ page }) => { test('Face editor opens with person list', async ({ page }) => {
@@ -318,7 +323,7 @@ test.describe('face-editor', () => {
const centerY = beforeClick.top + beforeClick.height / 2; const centerY = beforeClick.top + beforeClick.height / 2;
await page.mouse.click(centerX, centerY); await page.mouse.click(centerX, centerY);
await page.waitForTimeout(300); await waitForSelectorTransition(page);
const afterClick = await getFaceBoxRect(page); const afterClick = await getFaceBoxRect(page);
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3); expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
@@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => {
await page.mouse.move(width / 2, height / 2); await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1); await page.mouse.wheel(0, -1);
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]'); const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
await expect(async () => { await expect(async () => {
const transform = await imgLocator.evaluate((element) => { const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
@@ -168,6 +168,82 @@ test.describe('zoom and face editor interaction', () => {
}); });
expect(afterTransform).not.toBe('none'); expect(afterTransform).not.toBe('none');
}); });
test('modifier+drag pans zoomed image without repositioning face rect', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
for (let i = 0; i < 10; i++) {
await page.mouse.wheel(0, -3);
}
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transform).not.toBe('none');
}).toPass({ timeout: 2000 });
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
const dataEl = page.locator('#face-editor-data');
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
const beforeLeft = Number(await dataEl.getAttribute('data-face-left'));
const beforeTop = Number(await dataEl.getAttribute('data-face-top'));
const transformBefore = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const panModifier = await page.evaluate(() =>
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control',
);
await page.keyboard.down(panModifier);
// Verify face editor becomes transparent to pointer events
await expect(async () => {
const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents);
expect(pe).toBe('none');
}).toPass({ timeout: 2000 });
await page.mouse.move(width / 2, height / 2);
await page.mouse.down();
await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 });
await page.mouse.up();
await page.keyboard.up(panModifier);
const transformAfter = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transformAfter).not.toBe(transformBefore);
// Extract translate values from matrix(a, b, c, d, tx, ty)
const parseTranslate = (matrix: string) => {
const values =
matrix
.match(/matrix\((.+)\)/)?.[1]
.split(',')
.map(Number) ?? [];
return { tx: values[4], ty: values[5] };
};
const panBefore = parseTranslate(transformBefore);
const panAfter = parseTranslate(transformAfter);
const panDeltaX = panAfter.tx - panBefore.tx;
const panDeltaY = panAfter.ty - panBefore.ty;
// Face rect screen position should have moved by the same amount as the pan
// (it follows the image), NOT been repositioned by a click
const afterLeft = Number(await dataEl.getAttribute('data-face-left'));
const afterTop = Number(await dataEl.getAttribute('data-face-top'));
const faceDeltaX = afterLeft - beforeLeft;
const faceDeltaY = afterTop - beforeTop;
expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3);
expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3);
});
}); });
test.describe('face overlay via detail panel interaction', () => { test.describe('face overlay via detail panel interaction', () => {
@@ -252,7 +328,7 @@ test.describe('face overlay via edit faces side panel', () => {
await ensureDetailPanelVisible(page); await ensureDetailPanelVisible(page);
await page.getByLabel('Edit people').click(); await page.getByLabel('Edit people').click();
const faceThumbnail = page.locator('section div[role="button"]').first(); const faceThumbnail = page.getByTestId('face-thumbnail').first();
await expect(faceThumbnail).toBeVisible(); await expect(faceThumbnail).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
@@ -6,6 +6,7 @@ import {
generateTimelineData, generateTimelineData,
TimelineAssetConfig, TimelineAssetConfig,
TimelineData, TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline'; } from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
}; };
test.beforeAll(async () => { test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid(); adminUserId = faker.string.uuid();
testContext.adminId = adminUserId; testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId }); timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => { await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') { if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id)); const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
+12 -9
View File
@@ -143,7 +143,7 @@ export const timelineUtils = {
return page.locator('#asset-grid'); return page.locator('#asset-grid');
}, },
async waitForTimelineLoad(page: Page) { async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toBeInViewport(); await page.locator('#asset-grid[data-initialized]').waitFor();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0); await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
}, },
async getScrollTop(page: Page) { async getScrollTop(page: Page) {
@@ -163,14 +163,17 @@ export const assetViewerUtils = {
return page.locator('#immich-asset-viewer'); return page.locator('#immich-asset-viewer');
}, },
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
.locator( const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, await imgLocator.or(videoLocator).waitFor();
)
.or( if ((await videoLocator.count()) === 0) {
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), await expect
) .poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
.waitFor(); .toBe(true);
}
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
}, },
async expectActiveAssetToBe(page: Page, assetId: string) { async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () => const activeElement = () =>
@@ -0,0 +1,273 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});
+2
View File
@@ -1275,6 +1275,7 @@
"hide_schema": "Hide schema", "hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition", "hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people", "hide_unnamed_people": "Hide unnamed people",
"hold_key_to_pan": "Hold {key} to pan",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.", "home_page_add_to_album_success": "Added {added} assets to album {album}.",
@@ -2159,6 +2160,7 @@
"skip_to_folders": "Skip to folders", "skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags", "skip_to_tags": "Skip to tags",
"slideshow": "Slideshow", "slideshow": "Slideshow",
"slideshow_ken_burns_effect": "Ken Burns effect",
"slideshow_repeat": "Repeat slideshow", "slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings", "slideshow_settings": "Slideshow settings",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-i18n", "name": "immich-i18n",
"version": "2.6.0", "version": "2.6.1",
"private": true, "private": true,
"scripts": { "scripts": {
"format": "prettier --cache --check .", "format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "immich-ml" name = "immich-ml"
version = "2.6.0" version = "2.6.1"
description = "" description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0" requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]] [[package]]
name = "immich-ml" name = "immich-ml"
version = "2.6.0" version = "2.6.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocache" }, { name = "aiocache" },
@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.net.Authenticator import java.net.Authenticator
import java.net.CookieHandler import java.net.CookieHandler
import java.net.PasswordAuthentication import java.net.PasswordAuthentication
@@ -277,10 +285,13 @@ object HttpClientManager {
return result return result
} }
fun rebuildCronetEngine(): CronetEngine { suspend fun rebuildCronetEngine(): Result<Long> {
val old = cronetEngine!! return runCatching {
cronetEngine?.shutdown()
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine() cronetEngine = buildCronetEngine()
return old deletionResult
}
} }
val cronetStoragePath: File get() = cronetStorageDir val cronetStoragePath: File get() = cronetStorageDir
@@ -301,7 +312,7 @@ object HttpClientManager {
} }
} }
private fun buildCronetEngine(): CronetEngine { fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext) return CronetEngine.Builder(appContext)
.enableHttp2(true) .enableHttp2(true)
.enableQuic(true) .enableQuic(true)
@@ -312,6 +323,27 @@ object HttpClientManager {
.build() .build()
} }
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
private fun build(cacheDir: File): OkHttpClient { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -21,11 +21,6 @@ import java.io.EOFException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private class RemoteRequest(val cancellationSignal: CancellationSignal) private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -205,20 +200,17 @@ private class CronetImageFetcher : ImageFetcher {
private fun onDrained() { private fun onDrained() {
val onCacheCleared = synchronized(stateLock) { val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null this.onCacheCleared = null
onCacheCleared onCacheCleared
} } ?: return
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } val result = HttpClientManager.rebuildCronetEngine()
synchronized(stateLock) { draining = false } synchronized(stateLock) { draining = false }
onCacheCleared(result) onCacheCleared(result)
} }
} }
}
override fun clearCache(onCleared: (Result<Long>) -> Unit) { override fun clearCache(onCleared: (Result<Long>) -> Unit) {
synchronized(stateLock) { synchronized(stateLock) {
@@ -306,26 +298,6 @@ private class CronetImageFetcher : ImageFetcher {
} }
} }
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
} }
private class OkHttpImageFetcher private constructor( private class OkHttpImageFetcher private constructor(
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3038, "android.injected.version.code" => 3039,
"android.injected.version.name" => "2.6.0", "android.injected.version.name" => "2.6.1",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.6.0</string> <string>2.6.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
+1 -1
View File
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'), 'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'), 'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'), 'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'), 'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), 'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'), 'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'), 'Czech (cs)': Locale('cs'),
+3 -2
View File
@@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -184,8 +185,8 @@ class ApiService {
if (externalJson != null) { if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson); final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) { for (final entry in list) {
final url = entry['url'] as String?; final url = AuxilaryEndpoint.fromJson(entry).url;
if (url != null && url.isNotEmpty) urls.add(url); if (url.isNotEmpty) urls.add(url);
} }
} }
return urls; return urls;
+1 -1
View File
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
set maximumSize(int value) => _small.maximumSize = value; set maximumSize(int value) => _small.maximumSize = value;
@override @override
set maximumSizeBytes(int value) => _small.maximumSize = value; set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
@override @override
void clear() { void clear() {
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.0 - API version: 2.6.1
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 2.6.0+3038 version: 2.6.1+3039
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
+1 -1
View File
@@ -15166,7 +15166,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "2.6.0", "version": "2.6.1",
"contact": {} "contact": {}
}, },
"tags": [ "tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "2.6.0", "version": "2.6.1",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 2.6.0 * 2.6.1
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-monorepo", "name": "immich-monorepo",
"version": "2.6.0", "version": "2.6.1",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
+97 -84
View File
@@ -747,8 +747,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../open-api/typescript-sdk version: link:../open-api/typescript-sdk
'@immich/ui': '@immich/ui':
specifier: ^0.64.0 specifier: ^0.65.3
version: 0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0 specifier: 0.3.0
version: 0.3.0 version: 0.3.0
@@ -781,7 +781,7 @@ importers:
version: 0.42.0 version: 0.42.0
'@zoom-image/svelte': '@zoom-image/svelte':
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.9(svelte@5.53.7) version: 0.3.9(svelte@5.53.13)
dom-to-image: dom-to-image:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0 version: 2.6.0
@@ -824,6 +824,9 @@ importers:
simple-icons: simple-icons:
specifier: ^15.15.0 specifier: ^15.15.0
version: 15.22.0 version: 15.22.0
smartcrop:
specifier: ^2.0.5
version: 2.0.5
socket.io-client: socket.io-client:
specifier: ~4.8.0 specifier: ~4.8.0
version: 4.8.3 version: 4.8.3
@@ -832,16 +835,16 @@ importers:
version: 5.2.2 version: 5.2.2
svelte-i18n: svelte-i18n:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1(svelte@5.53.7) version: 4.0.1(svelte@5.53.13)
svelte-jsoneditor: svelte-jsoneditor:
specifier: ^3.10.0 specifier: ^3.10.0
version: 3.11.0(svelte@5.53.7) version: 3.11.0(svelte@5.53.13)
svelte-maplibre: svelte-maplibre:
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.2.6(svelte@5.53.7) version: 1.2.6(svelte@5.53.13)
svelte-persisted-store: svelte-persisted-store:
specifier: ^0.12.0 specifier: ^0.12.0
version: 0.12.0(svelte@5.53.7) version: 0.12.0(svelte@5.53.13)
tabbable: tabbable:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.4.0 version: 6.4.0
@@ -875,16 +878,16 @@ importers:
version: 3.1.2 version: 3.1.2
'@sveltejs/adapter-static': '@sveltejs/adapter-static':
specifier: ^3.0.8 specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img': '@sveltejs/enhanced-img':
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.27.1 specifier: ^2.27.1
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: 6.2.4 specifier: 6.2.4
version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.7 specifier: ^4.1.7
version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -893,7 +896,7 @@ importers:
version: 6.9.1 version: 6.9.1
'@testing-library/svelte': '@testing-library/svelte':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) version: 5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event': '@testing-library/user-event':
specifier: ^14.5.2 specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1) version: 14.6.1(@testing-library/dom@10.4.1)
@@ -932,7 +935,7 @@ importers:
version: 6.2.1(eslint@10.0.2(jiti@2.6.1)) version: 6.2.1(eslint@10.0.2(jiti@2.6.1))
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^3.12.4 specifier: ^3.12.4
version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7) version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13)
eslint-plugin-unicorn: eslint-plugin-unicorn:
specifier: ^63.0.0 specifier: ^63.0.0
version: 63.0.0(eslint@10.0.2(jiti@2.6.1)) version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
@@ -953,19 +956,19 @@ importers:
version: 4.2.0(prettier@3.8.1) version: 4.2.0(prettier@3.8.1)
prettier-plugin-svelte: prettier-plugin-svelte:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7) version: 3.5.1(prettier@3.8.1)(svelte@5.53.13)
rollup-plugin-visualizer: rollup-plugin-visualizer:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.11(rollup@4.55.1) version: 6.0.11(rollup@4.55.1)
svelte: svelte:
specifier: 5.53.7 specifier: 5.53.13
version: 5.53.7 version: 5.53.13
svelte-check: svelte-check:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3) version: 4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3)
svelte-eslint-parser: svelte-eslint-parser:
specifier: ^1.3.3 specifier: ^1.3.3
version: 1.6.0(svelte@5.53.7) version: 1.6.0(svelte@5.53.13)
tailwindcss: tailwindcss:
specifier: ^4.1.7 specifier: ^4.1.7
version: 4.2.1 version: 4.2.1
@@ -3035,8 +3038,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
'@immich/ui@0.64.0': '@immich/ui@0.65.3':
resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
@@ -6752,6 +6755,9 @@ packages:
devalue@5.6.3: devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
devlop@1.1.0: devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -10901,6 +10907,9 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smartcrop@2.0.5:
resolution: {integrity: sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==}
snake-case@3.0.4: snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -11218,8 +11227,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.30.2 svelte: ^5.30.2
svelte@5.53.7: svelte@5.53.13:
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==} resolution: {integrity: sha512-9P6I/jGcQMzAMb76Uyd6L6RELAC7qt53GOSBLCke9lubh9iJjmjCo+EffRH4gOPnTB/x4RR2Tmt6s3o9ywQO3g==}
engines: {node: '>=18'} engines: {node: '>=18'}
svg-parser@2.0.4: svg-parser@2.0.4:
@@ -14948,22 +14957,22 @@ snapshots:
pg-connection-string: 2.12.0 pg-connection-string: 2.12.0
postgres: 3.4.8 postgres: 3.4.8
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.7)': '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.13)':
dependencies: dependencies:
front-matter: 4.0.2 front-matter: 4.0.2
marked: 17.0.3 marked: 17.0.3
node-emoji: 2.2.0 node-emoji: 2.2.0
svelte: 5.53.7 svelte: 5.53.13
'@immich/ui@0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)': '@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
dependencies: dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7) '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
'@mdi/js': 7.4.47 '@mdi/js': 7.4.47
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
luxon: 3.7.2 luxon: 3.7.2
simple-icons: 16.9.0 simple-icons: 16.9.0
svelte: 5.53.7 svelte: 5.53.13
svelte-highlight: 7.9.0 svelte-highlight: 7.9.0
tailwind-merge: 3.5.0 tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1) tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
@@ -16300,17 +16309,17 @@ snapshots:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies: dependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21 magic-string: 0.30.21
sharp: 0.34.5 sharp: 0.34.5
svelte: 5.53.7 svelte: 5.53.13
svelte-parse-markup: 0.1.5(svelte@5.53.7) svelte-parse-markup: 0.1.5(svelte@5.53.13)
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-imagetools: 9.0.3(rollup@4.55.1) vite-imagetools: 9.0.3(rollup@4.55.1)
zimmerframe: 1.1.4 zimmerframe: 1.1.4
@@ -16318,11 +16327,11 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/cookie': 0.6.0 '@types/cookie': 0.6.0
acorn: 8.16.0 acorn: 8.16.0
cookie: 0.6.0 cookie: 0.6.0
@@ -16333,28 +16342,28 @@ snapshots:
mrmime: 2.0.1 mrmime: 2.0.1
set-cookie-parser: 3.0.1 set-cookie-parser: 3.0.1
sirv: 3.0.2 sirv: 3.0.2
svelte: 5.53.7 svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
typescript: 5.9.3 typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
debug: 4.4.3 debug: 4.4.3
svelte: 5.53.7 svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
deepmerge: 4.3.1 deepmerge: 4.3.1
magic-string: 0.30.21 magic-string: 0.30.21
obug: 2.1.1 obug: 2.1.1
svelte: 5.53.7 svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitefu: 1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies: transitivePeerDependencies:
@@ -16602,15 +16611,15 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
redent: 3.0.0 redent: 3.0.0
'@testing-library/svelte-core@1.0.0(svelte@5.53.7)': '@testing-library/svelte-core@1.0.0(svelte@5.53.13)':
dependencies: dependencies:
svelte: 5.53.7 svelte: 5.53.13
'@testing-library/svelte@5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': '@testing-library/svelte@5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@testing-library/dom': 10.4.1 '@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.53.7) '@testing-library/svelte-core': 1.0.0(svelte@5.53.13)
svelte: 5.53.7 svelte: 5.53.13
optionalDependencies: optionalDependencies:
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -17527,10 +17536,10 @@ snapshots:
dependencies: dependencies:
'@namnode/store': 0.1.0 '@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.9(svelte@5.53.7)': '@zoom-image/svelte@0.3.9(svelte@5.53.13)':
dependencies: dependencies:
'@zoom-image/core': 0.42.0 '@zoom-image/core': 0.42.0
svelte: 5.53.7 svelte: 5.53.13
abbrev@1.1.1: {} abbrev@1.1.1: {}
@@ -17894,15 +17903,15 @@ snapshots:
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies: dependencies:
'@floating-ui/core': 1.7.3 '@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4 '@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
svelte: 5.53.7 svelte: 5.53.13
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
tabbable: 6.4.0 tabbable: 6.4.0
transitivePeerDependencies: transitivePeerDependencies:
- '@sveltejs/kit' - '@sveltejs/kit'
@@ -18995,6 +19004,8 @@ snapshots:
devalue@5.6.3: {} devalue@5.6.3: {}
devalue@5.6.4: {}
devlop@1.1.0: devlop@1.1.0:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
@@ -19403,7 +19414,7 @@ snapshots:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1)) eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1))
eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7): eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -19415,9 +19426,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.8) postcss-load-config: 3.1.4(postcss@8.5.8)
postcss-safe-parser: 7.0.1(postcss@8.5.8) postcss-safe-parser: 7.0.1(postcss@8.5.8)
semver: 7.7.4 semver: 7.7.4
svelte-eslint-parser: 1.6.0(svelte@5.53.7) svelte-eslint-parser: 1.6.0(svelte@5.53.13)
optionalDependencies: optionalDependencies:
svelte: 5.53.7 svelte: 5.53.13
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
@@ -23155,10 +23166,10 @@ snapshots:
dependencies: dependencies:
prettier: 3.8.1 prettier: 3.8.1
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7): prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.13):
dependencies: dependencies:
prettier: 3.8.1 prettier: 3.8.1
svelte: 5.53.7 svelte: 5.53.13
prettier@3.8.1: {} prettier@3.8.1: {}
@@ -23763,14 +23774,14 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
esm-env: 1.2.2 esm-env: 1.2.2
lz-string: 1.5.0 lz-string: 1.5.0
svelte: 5.53.7 svelte: 5.53.13
optionalDependencies: optionalDependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
rw@1.3.3: {} rw@1.3.3: {}
@@ -24101,6 +24112,8 @@ snapshots:
smart-buffer@4.2.0: {} smart-buffer@4.2.0: {}
smartcrop@2.0.5: {}
snake-case@3.0.4: snake-case@3.0.4:
dependencies: dependencies:
dot-case: 3.0.4 dot-case: 3.0.4
@@ -24394,23 +24407,23 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svelte-awesome@3.3.5(svelte@5.53.7): svelte-awesome@3.3.5(svelte@5.53.13):
dependencies: dependencies:
svelte: 5.53.7 svelte: 5.53.13
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3): svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3):
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3 chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1 picocolors: 1.1.1
sade: 1.8.1 sade: 1.8.1
svelte: 5.53.7 svelte: 5.53.13
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- picomatch - picomatch
svelte-eslint-parser@1.6.0(svelte@5.53.7): svelte-eslint-parser@1.6.0(svelte@5.53.13):
dependencies: dependencies:
eslint-scope: 8.4.0 eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
@@ -24420,7 +24433,7 @@ snapshots:
postcss-selector-parser: 7.1.1 postcss-selector-parser: 7.1.1
semver: 7.7.4 semver: 7.7.4
optionalDependencies: optionalDependencies:
svelte: 5.53.7 svelte: 5.53.13
svelte-floating-ui@1.5.8: svelte-floating-ui@1.5.8:
dependencies: dependencies:
@@ -24433,7 +24446,7 @@ snapshots:
dependencies: dependencies:
highlight.js: 11.11.1 highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.53.7): svelte-i18n@4.0.1(svelte@5.53.13):
dependencies: dependencies:
cli-color: 2.0.4 cli-color: 2.0.4
deepmerge: 4.3.1 deepmerge: 4.3.1
@@ -24441,10 +24454,10 @@ snapshots:
estree-walker: 2.0.2 estree-walker: 2.0.2
intl-messageformat: 10.7.18 intl-messageformat: 10.7.18
sade: 1.8.1 sade: 1.8.1
svelte: 5.53.7 svelte: 5.53.13
tiny-glob: 0.2.9 tiny-glob: 0.2.9
svelte-jsoneditor@3.11.0(svelte@5.53.7): svelte-jsoneditor@3.11.0(svelte@5.53.13):
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1 '@codemirror/commands': 6.10.1
@@ -24471,42 +24484,42 @@ snapshots:
memoize-one: 6.0.0 memoize-one: 6.0.0
natural-compare-lite: 1.4.0 natural-compare-lite: 1.4.0
sass: 1.97.1 sass: 1.97.1
svelte: 5.53.7 svelte: 5.53.13
svelte-awesome: 3.3.5(svelte@5.53.7) svelte-awesome: 3.3.5(svelte@5.53.13)
svelte-select: 5.8.3 svelte-select: 5.8.3
vanilla-picker: 2.12.3 vanilla-picker: 2.12.3
svelte-maplibre@1.2.6(svelte@5.53.7): svelte-maplibre@1.2.6(svelte@5.53.13):
dependencies: dependencies:
d3-geo: 3.1.1 d3-geo: 3.1.1
dequal: 2.0.3 dequal: 2.0.3
just-compare: 2.3.0 just-compare: 2.3.0
maplibre-gl: 5.19.0 maplibre-gl: 5.19.0
pmtiles: 3.2.1 pmtiles: 3.2.1
svelte: 5.53.7 svelte: 5.53.13
svelte-parse-markup@0.1.5(svelte@5.53.7): svelte-parse-markup@0.1.5(svelte@5.53.13):
dependencies: dependencies:
svelte: 5.53.7 svelte: 5.53.13
svelte-persisted-store@0.12.0(svelte@5.53.7): svelte-persisted-store@0.12.0(svelte@5.53.13):
dependencies: dependencies:
svelte: 5.53.7 svelte: 5.53.13
svelte-select@5.8.3: svelte-select@5.8.3:
dependencies: dependencies:
svelte-floating-ui: 1.5.8 svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7): svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7) runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
style-to-object: 1.0.14 style-to-object: 1.0.14
svelte: 5.53.7 svelte: 5.53.13
transitivePeerDependencies: transitivePeerDependencies:
- '@sveltejs/kit' - '@sveltejs/kit'
svelte@5.53.7: svelte@5.53.13:
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -24517,7 +24530,7 @@ snapshots:
aria-query: 5.3.1 aria-query: 5.3.1
axobject-query: 4.1.0 axobject-query: 4.1.0
clsx: 2.1.1 clsx: 2.1.1
devalue: 5.6.3 devalue: 5.6.4
esm-env: 1.2.2 esm-env: 1.2.2
esrap: 2.2.3 esrap: 2.2.3
is-reference: 3.0.3 is-reference: 3.0.3
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "2.6.0", "version": "2.6.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -24,7 +24,7 @@
"typeorm": "typeorm", "typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug", "migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run", "migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert", "migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'", "schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
+1 -9
View File
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;
userId: string; userId: string;
albumId: string | null;
showExif: boolean; showExif: boolean;
allowUpload: boolean; allowUpload: boolean;
allowDownload: boolean; allowDownload: boolean;
@@ -357,15 +358,6 @@ export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'], authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
authSharedLink: [
'shared_link.id',
'shared_link.userId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
],
user: userColumns, user: userColumns,
userWithPrefix: userWithPrefixColumns, userWithPrefix: userWithPrefixColumns,
userAdmin: [ userAdmin: [
@@ -173,6 +173,7 @@ order by
select select
"shared_link"."id", "shared_link"."id",
"shared_link"."userId", "shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt", "shared_link"."expiresAt",
"shared_link"."showExif", "shared_link"."showExif",
"shared_link"."allowUpload", "shared_link"."allowUpload",
@@ -211,6 +212,7 @@ where
select select
"shared_link"."id", "shared_link"."id",
"shared_link"."userId", "shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt", "shared_link"."expiresAt",
"shared_link"."showExif", "shared_link"."showExif",
"shared_link"."allowUpload", "shared_link"."allowUpload",
@@ -330,6 +330,7 @@ export class AlbumRepository {
await db await db
.insertInto('album_asset') .insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId }))) .values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute(); .execute();
} }
@@ -119,8 +119,12 @@ export class MetadataRepository {
} }
async writeTags(path: string, tags: Partial<Tags>): Promise<void> { async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
try { try {
await this.exiftool.write(path, tags); await this.exiftool.write(path, tagsToWrite);
} catch (error) { } catch (error) {
this.logger.warn(`Error writing exif data (${path}): ${error}`); this.logger.warn(`Error writing exif data (${path}): ${error}`);
} }
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
.leftJoin('album', 'album.id', 'shared_link.albumId') .leftJoin('album', 'album.id', 'shared_link.albumId')
.where('album.deletedAt', 'is', null) .where('album.deletedAt', 'is', null)
.select((eb) => [ .select((eb) => [
...columns.authSharedLink, 'shared_link.id',
'shared_link.userId',
'shared_link.albumId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
jsonObjectFrom( jsonObjectFrom(
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'), eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
).as('user'), ).as('user'),
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
DELETE FROM "shared_link_asset"
USING "shared_link"
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
`.execute(db);
}
export async function down(): Promise<void> {
// noop
}
+2 -1
View File
@@ -64,8 +64,9 @@ export class UserTable {
@Column({ unique: true, nullable: true, default: null }) @Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null; storageLabel!: string | null;
// TODO remove default, make nullable, and convert empty spaces to null
@Column({ default: '' }) @Column({ default: '' })
name!: Generated<string>; name!: string;
@Column({ type: 'bigint', nullable: true }) @Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null; quotaSizeInBytes!: ColumnType<number> | null;
+12
View File
@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const album = await this.findOrFail(id, { withAssets: false }); const album = await this.findOrFail(id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
} }
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> { async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const results: AlbumsAddAssetsResponseDto = { const results: AlbumsAddAssetsResponseDto = {
success: false, success: false,
error: BulkIdErrorReason.DUPLICATE, error: BulkIdErrorReason.DUPLICATE,
+9 -3
View File
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path'; import { extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database'; import { Asset, AuthSharedLink } from 'src/database';
import { import {
AssetBulkUploadCheckResponseDto, AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto, AssetMediaResponseDto,
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.create(auth.user.id, dto, file, sidecarFile); const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) { if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); await this.addToSharedLink(auth.sharedLink, asset.id);
} }
await this.userRepository.updateUsage(auth.user.id, file.size); await this.userRepository.updateUsage(auth.user.id, file.size);
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
}; };
} }
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
await (sharedLink.albumId
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError( private async handleUploadError(
error: any, error: any,
auth: AuthDto, auth: AuthDto,
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
} }
if (auth.sharedLink) { if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); await this.addToSharedLink(auth.sharedLink, duplicateId);
} }
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
+119 -181
View File
@@ -8,6 +8,7 @@ import { AuthService } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types'; import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory'; import { AuthFactory } from 'test/factories/auth.factory';
import { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
import { SessionFactory } from 'test/factories/session.factory'; import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory'; import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
@@ -15,31 +16,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newUuid } from 'test/small.factory'; import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = ({
id,
email,
name,
profileImagePath,
}: {
id: string;
email: string;
name: string;
profileImagePath?: string;
}) => ({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: id,
userEmail: email,
name,
profileImagePath,
isAdmin: false,
isOnboarded: false,
shouldChangePassword: false,
});
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
const email = 'test@immich.com'; const email = 'test@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails = { const loginDetails = {
isSecure: true, isSecure: true,
clientIp: '127.0.0.1', clientIp: '127.0.0.1',
@@ -48,11 +25,9 @@ const loginDetails = {
appVersion: null, appVersion: null,
}; };
const fixtures = { const dto = {
login: {
email, email,
password: 'password', password: 'password',
},
}; };
describe(AuthService.name, () => { describe(AuthService.name, () => {
@@ -63,7 +38,6 @@ describe(AuthService.name, () => {
({ sut, mocks } = newTestService(AuthService)); ({ sut, mocks } = newTestService(AuthService));
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' }); mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
}); });
@@ -75,13 +49,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => { it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('should check the user exists', async () => { it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
}); });
@@ -89,7 +63,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => { it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin); mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
}); });
@@ -100,7 +74,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session); mocks.session.create.mockResolvedValue(session);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ await expect(sut.login(dto, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id, userId: user.id,
userEmail: user.email, userEmail: user.email,
@@ -624,6 +598,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
await expect( await expect(
sut.callback( sut.callback(
@@ -638,31 +613,31 @@ describe(AuthService.name, () => {
it('should link an existing user', async () => { it('should link an existing user', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
}); });
it('should not link to a user with a different oauth sub', async () => { it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByEmail.mockResolvedValueOnce(user); mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
await expect( await expect(
sut.callback( sut.callback(
@@ -677,35 +652,30 @@ describe(AuthService.name, () => {
}); });
it('should allow auto registering by default', async () => { it('should allow auto registering by default', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1); expect(mocks.user.create).toHaveBeenCalledTimes(1);
}); });
it('should throw an error if user should be auto registered but the email claim does not exist', async () => { it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
const user = UserFactory.create({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
await expect( await expect(
sut.callback( sut.callback(
@@ -725,10 +695,9 @@ describe(AuthService.name, () => {
'app.immich:///oauth-callback?code=abc123', 'app.immich:///oauth-callback?code=abc123',
]) { ]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -743,135 +712,136 @@ describe(AuthService.name, () => {
} }
it('should use the default quota', async () => { it('should use the default quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should ignore an invalid storage quota', async () => { it('should infer name from given and family names', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); );
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
});
it('should fallback to email when no username is provided', async () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
});
it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should ignore a negative quota', async () => { it('should ignore a negative quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
}); });
it('should set quota for 0 quota', async () => { it('should set quota for 0 quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 0,
storageLabel: null,
});
}); });
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
});
}); });
it('should sync the profile picture', async () => { it('should sync the profile picture', async () => {
const fileId = newUuid(); const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' }); const user = UserFactory.create({ oauthId: 'oauth-id' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({ mocks.oauth.getProfile.mockResolvedValue(profile);
sub: user.oauthId,
email: user.email,
picture: pictureUrl,
});
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId); mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({ mocks.oauth.getProfilePicture.mockResolvedValue({
@@ -881,131 +851,96 @@ describe(AuthService.name, () => {
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`), profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
profileChangedAt: expect.any(Date), profileChangedAt: expect.any(Date),
}); });
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl); expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
}); });
it('should not sync the profile picture if the user already has one', async () => { it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({ mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({
sub: user.oauthId, sub: user.oauthId,
email: user.email, email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg', picture: 'https://auth.immich.cloud/profiles/1.jpg',
}); }),
);
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).not.toHaveBeenCalled(); expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled(); expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
}); });
it('should only allow "admin" and "user" for the role claim', async () => { it('should only allow "admin" and "user" for the role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: false,
});
}); });
it('should create an admin user if the role claim is set to admin', async () => { it('should create an admin user if the role claim is set to admin', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
}); });
it('should accept a custom role claim', async () => { it('should accept a custom role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
}); });
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' }); mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect( await sut.callback(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{}, {},
loginDetails, loginDetails,
), );
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith({ expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
}); });
}); });
@@ -1013,8 +948,10 @@ describe(AuthService.name, () => {
it('should link an account', async () => { it('should link an account', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
await sut.link( await sut.link(
@@ -1023,7 +960,7 @@ describe(AuthService.name, () => {
{}, {},
); );
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
}); });
it('should not link an already linked oauth.sub', async () => { it('should not link an already linked oauth.sub', async () => {
@@ -1032,6 +969,7 @@ describe(AuthService.name, () => {
const auth = { user: authUser, apiKey: authApiKey }; const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin); mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect( await expect(
+13 -5
View File
@@ -261,6 +261,11 @@ export class AuthService extends BaseService {
} }
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) { async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const expectedState = dto.state ?? this.getCookieOauthState(headers); const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) { if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing'); throw new BadRequestException('OAuth state is missing');
@@ -271,7 +276,6 @@ export class AuthService extends BaseService {
throw new BadRequestException('OAuth code verifier is missing'); throw new BadRequestException('OAuth code verifier is missing');
} }
const { oauth } = await this.getConfig({ withCache: false });
const url = this.resolveRedirectUri(oauth, dto.url); const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier); const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth; const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
@@ -298,7 +302,8 @@ export class AuthService extends BaseService {
throw new BadRequestException(`User does not exist and auto registering is disabled.`); throw new BadRequestException(`User does not exist and auto registering is disabled.`);
} }
if (!profile.email) { const email = profile.email;
if (!email) {
throw new BadRequestException('OAuth profile does not have an email address'); throw new BadRequestException('OAuth profile does not have an email address');
} }
@@ -320,10 +325,13 @@ export class AuthService extends BaseService {
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value), isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
}); });
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({ user = await this.createUser({
name: userName, name:
email: profile.email, profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
email,
email,
oauthId: profile.sub, oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB, quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null, storageLabel: storageLabel || null,
+1 -1
View File
@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
GPSLatitude: latitude, GPSLatitude: latitude,
GPSLongitude: longitude, GPSLongitude: longitude,
Rating: rating, Rating: rating,
TagsList: tags?.length ? tags : undefined, TagsList: tags,
}, },
_.isUndefined, _.isUndefined,
); );
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SemVer } from 'semver'; import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
}); });
}); });
describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
});
it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onWebsocketConnection', () => { describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => { it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' }); await sut.onWebsocketConnection({ userId: '42' });
+7
View File
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
return this.versionRepository.getAll(); return this.versionRepository.getAll();
} }
@OnEvent({ name: 'ConfigUpdate' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
await this.handleQueueVersionCheck();
}
}
async handleQueueVersionCheck() { async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} }); await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
} }
@@ -0,0 +1,28 @@
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthProfileLike } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
export class OAuthProfileFactory {
private constructor(private value: OAuthProfile) {}
static create(dto: OAuthProfileLike = {}) {
return OAuthProfileFactory.from(dto).build();
}
static from(dto: OAuthProfileLike = {}) {
const sub = newUuid();
return new OAuthProfileFactory({
sub,
name: 'Name',
given_name: 'Given',
family_name: 'Family',
email: `oauth-${sub}@immich.cloud`,
email_verified: true,
...dto,
});
}
build() {
return { ...this.value };
}
}
+2
View File
@@ -1,4 +1,5 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { ActivityTable } from 'src/schema/tables/activity.table'; import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
@@ -34,3 +35,4 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
export type ActivityLike = Partial<Selectable<ActivityTable>>; export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>; export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>; export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;
+1
View File
@@ -48,6 +48,7 @@ export const authStub = {
showExif: true, showExif: true,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
albumId: null,
expiresAt: null, expiresAt: null,
password: null, password: null,
userId: '42', userId: '42',
+2 -2
View File
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result }; return { result };
} }
async newAlbum(dto: Insertable<AlbumTable>) { async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto); const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []); const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
return { album, result }; return { album, result };
} }
@@ -0,0 +1,75 @@
import { Kysely } from 'kysely';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { newDate } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = () => {
const { ctx } = newMediumService(BaseService, {
database,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(MetadataRepository) };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe(MetadataRepository.name, () => {
describe('writeTags', () => {
it('should write an empty description', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { Description: '' });
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
});
it('should write an empty tags list', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { TagsList: [] });
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
});
});
it('should write tags', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, {
Description: 'my-description',
ImageDescription: 'my-image-description',
DateTimeOriginal: newDate().toISOString(),
GPSLatitude: 42,
GPSLongitude: 69,
Rating: 3,
TagsList: ['tagA'],
});
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('my-description'));
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
expect(fileContent).toEqual(expect.stringContaining('tagA'));
});
});
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetFileType } from 'src/enum'; import { AssetFileType, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository'; import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, { return newMediumService(AssetMediaService, {
database: db || defaultDatabase, database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository], real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
}); });
}; };
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
const { asset } = await ctx.newAsset({ ownerId: user.id }); const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 }); await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } }); const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(), fileCreatedAt: new Date(),
assetData: Buffer.from('some data'), assetData: Buffer.from('some data'),
}, },
file, mediumFactory.uploadFile(),
), ),
).resolves.toEqual({ ).resolves.toEqual({
id: expect.any(String), id: expect.any(String),
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
}); });
}); });
it('should add to a shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const file = mediumFactory.uploadFile();
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, file);
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should handle adding a duplicate asset to a shared link', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
assetIds: [asset.id],
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should add to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
it('should handle adding a duplicate asset to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
}); });
describe('viewThumbnail', () => { describe('viewThumbnail', () => {
@@ -47,15 +47,15 @@ describe(UserService.name, () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue(); ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert(); const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email }); await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists'); await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
}); });
it('should not return password', async () => { it('should not return password', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue(); ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' }); const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' }); const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined(); expect((user as any).password).toBeUndefined();
}); });
}); });
+11 -1
View File
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
expiresAt = null, expiresAt = null,
userId = newUuid(), userId = newUuid(),
showExif = true, showExif = true,
albumId = null,
allowUpload = false, allowUpload = false,
allowDownload = true, allowDownload = true,
password = null, password = null,
} = sharedLink; } = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password }; return {
id,
albumId,
expiresAt,
userId,
showExif,
allowUpload,
allowDownload,
password,
};
}; };
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({ const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "2.6.0", "version": "2.6.1",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.64.0", "@immich/ui": "^0.65.3",
"@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/core": "^5.14.0",
@@ -53,6 +53,7 @@
"pmtiles": "^4.3.0", "pmtiles": "^4.3.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"simple-icons": "^15.15.0", "simple-icons": "^15.15.0",
"smartcrop": "^2.0.5",
"socket.io-client": "~4.8.0", "socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2", "svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
@@ -100,7 +101,7 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0", "rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7", "svelte": "5.53.13",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3", "svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
+609
View File
@@ -75,6 +75,35 @@
--immich-dark-bg: 10 10 10; --immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235; --immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33; --immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
/* Base animation duration for standard transitions (page fades, info panel) */
--vt-duration-default: 250ms;
/* Duration for hero transitions (thumbnail to full viewer) */
--vt-duration-hero: 280ms;
/* Duration for next/previous photo navigation */
--vt-duration-viewer-navigation: 270ms;
/* Duration for slideshow mode transitions */
--vt-duration-slideshow: 1s;
/* Easing function for slide animations (ease-out) */
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
/* How far images slide in/out during navigation (% of viewport) */
--vt-viewer-slide-distance: 15%;
/* Starting opacity for fly transitions (slide+fade effect) */
--vt-viewer-opacity-start: 0.1;
/* Maximum blur during fly transitions (currently disabled) */
--vt-viewer-blur-max: 0px;
--vt-viewer-next-in: flyInRight;
--vt-viewer-next-out: flyOutLeft;
--vt-viewer-prev-in: flyInLeft;
--vt-viewer-prev-out: flyOutRight;
--vt-viewer-old-opacity: 1;
/* Easing function for memory and hero morph transitions */
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
} }
button:not(:disabled), button:not(:disabled),
@@ -176,3 +205,583 @@
@apply bg-subtle rounded-lg; @apply bg-subtle rounded-lg;
} }
} }
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(*) {
animation: var(--vt-duration-slideshow) linear crossfadeOut forwards;
}
&::view-transition-new(*) {
animation: var(--vt-duration-slideshow) linear crossfadeIn forwards;
}
&::view-transition-image-pair(*) {
isolation: auto;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-image-pair(info) {
isolation: auto;
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
z-index: 4;
}
::view-transition-image-pair(letterbox-left),
::view-transition-image-pair(letterbox-right),
::view-transition-image-pair(letterbox-top),
::view-transition-image-pair(letterbox-bottom) {
isolation: auto;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: none;
width: 100%;
height: 100%;
object-fit: fill;
background-color: var(--color-black);
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: var(--vt-memory-easing);
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
::view-transition-old(memory-overlay),
::view-transition-old(memory-controls),
::view-transition-new(memory-overlay),
::view-transition-new(memory-controls) {
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
html:active-view-transition-type(memory) {
&::view-transition-group(hero),
&::view-transition-group(hero-out) {
animation-duration: var(--vt-duration-memory);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
z-index: 1;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
z-index: 2;
opacity: 0.25;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 120ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
opacity: 0;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
&::view-transition-image-pair(hero) {
isolation: auto;
}
&::view-transition-old(hero) {
display: none;
}
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-image-pair(hero-out) {
isolation: auto;
}
&::view-transition-old(hero-out) {
display: none;
}
&::view-transition-new(hero-out) {
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-departing) {
animation: none;
}
&::view-transition-old(memory-departing) {
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
}
&::view-transition-new(memory-departing) {
animation: none;
visibility: hidden;
}
}
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
}
&::view-transition-old(hero),
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls),
&::view-transition-group(memory-nav-buttons) {
animation: none;
z-index: 5;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls),
&::view-transition-old(memory-nav-buttons) {
animation: none;
visibility: hidden;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls),
&::view-transition-new(memory-nav-buttons) {
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
opacity: 0;
}
}
::view-transition-old(memory-fade-out) {
animation: 500ms linear crossfadeOut forwards;
}
::view-transition-new(memory-fade-in) {
animation: 500ms linear crossfadeIn forwards;
}
html:active-view-transition-type(memory-nav-fast) {
&::view-transition-old(memory-fade-out) {
animation-duration: 250ms;
}
&::view-transition-new(memory-fade-in) {
animation-duration: 250ms;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation-duration: 100ms;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 100ms linear 150ms fadeIn forwards;
opacity: 0;
}
}
html:active-view-transition-type(memory-nav) {
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 150ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear 300ms fadeIn forwards;
opacity: 0;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
opacity: 0.25;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
}
}
::view-transition-old(next),
::view-transition-old(next-old),
::view-transition-new(next),
::view-transition-new(next-new),
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-new(previous),
::view-transition-new(previous-new) {
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
}
::view-transition-old(next),
::view-transition-old(next-old),
::view-transition-old(previous),
::view-transition-old(previous-old) {
opacity: var(--vt-viewer-old-opacity);
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation-name: var(--vt-viewer-next-out);
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation-name: var(--vt-viewer-next-in);
}
::view-transition-old(previous),
::view-transition-old(previous-old) {
animation-name: var(--vt-viewer-prev-out);
}
::view-transition-new(previous),
::view-transition-new(previous-new) {
animation-name: var(--vt-viewer-prev-in);
}
::view-transition-old(next-old),
::view-transition-new(next-new),
::view-transition-old(previous-old),
::view-transition-new(previous-new) {
overflow: hidden;
}
::view-transition-old(previous-old) {
z-index: -1;
}
@keyframes fadeFromDim {
from {
opacity: 0.25;
}
to {
opacity: 0;
}
}
@keyframes dimDown {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}
@keyframes flyInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes panelSlideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes panelSlideOutRight {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
@keyframes fadeIn {
from {
opacity: 0;
}
50% {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
50% {
opacity: 0.85;
}
to {
opacity: 0;
}
}
@keyframes crossfadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes crossfadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 100;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
background-color: transparent;
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-group(previous),
&::view-transition-group(previous-old),
&::view-transition-group(next),
&::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
&::view-transition-old(previous),
&::view-transition-old(previous-old),
&::view-transition-old(next),
&::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
&::view-transition-new(previous),
&::view-transition-new(previous-new),
&::view-transition-new(next),
&::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: 0s;
}
&::view-transition-old(hero) {
animation: var(--vt-duration-default) fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) fadeIn forwards;
}
}
}
}
+7
View File
@@ -1,6 +1,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core'; import { createZoomImageWheel } from '@zoom-image/core';
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
type TouchEventLike = { type TouchEventLike = {
touches: Iterable<{ clientX: number; clientY: number }> & { length: number }; touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
targetTouches: ArrayLike<unknown>; targetTouches: ArrayLike<unknown>;
@@ -123,6 +124,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
{ capture: true, signal }, { capture: true, signal },
); );
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = 'transform';
}
node.style.overflow = 'visible'; node.style.overflow = 'visible';
node.style.touchAction = 'none'; node.style.touchAction = 'none';
return { return {
@@ -134,6 +138,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
}, },
destroy() { destroy() {
controller.abort(); controller.abort();
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = '';
}
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
unsubscribe(); unsubscribe();
} }
+41 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import AlphaBackground from '$lib/components/AlphaBackground.svelte'; import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte'; import ImageLayer from '$lib/components/ImageLayer.svelte';
@@ -18,11 +19,17 @@
sharedLink?: SharedLinkResponseDto; sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover'; objectFit?: 'contain' | 'cover';
container: Size; container: Size;
showLetterboxes?: boolean;
transitionName?: string | null | undefined;
letterboxTransitionName?: string | undefined;
imageClass?: string;
onUrlChange?: (url: string) => void; onUrlChange?: (url: string) => void;
onImageReady?: () => void; onImageReady?: () => void;
onError?: () => void; onError?: () => void;
ref?: HTMLDivElement; ref?: HTMLDivElement;
imgRef?: HTMLImageElement; imgRef?: HTMLImageElement;
imgNaturalSize?: Size;
imgScaledSize?: Size;
backdrop?: Snippet; backdrop?: Snippet;
overlays?: Snippet; overlays?: Snippet;
}; };
@@ -31,10 +38,18 @@
ref = $bindable(), ref = $bindable(),
// eslint-disable-next-line no-useless-assignment // eslint-disable-next-line no-useless-assignment
imgRef = $bindable(), imgRef = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgNaturalSize = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgScaledSize = $bindable(),
asset, asset,
sharedLink, sharedLink,
objectFit = 'contain', objectFit = 'contain',
container, container,
showLetterboxes = true,
transitionName,
letterboxTransitionName,
imageClass,
onUrlChange, onUrlChange,
onImageReady, onImageReady,
onError, onError,
@@ -98,9 +113,21 @@
return { width: 1, height: 1 }; return { width: 1, height: 1 };
}); });
const { width, height, left, top } = $derived.by(() => { $effect(() => {
imgNaturalSize = imageDimensions;
});
const scaledDimensions = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit; const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container); return scaleFn(imageDimensions, container);
});
$effect(() => {
imgScaledSize = scaledDimensions;
});
const { width, height, left, top } = $derived.by(() => {
const { width, height } = scaledDimensions;
return { return {
width: width + 'px', width: width + 'px',
height: height + 'px', height: height + 'px',
@@ -148,10 +175,20 @@
}); });
</script> </script>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}> <div class="relative h-full w-full overflow-hidden" bind:this={ref}>
{@render backdrop?.()} {@render backdrop?.()}
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height> <Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
<div
class={['absolute inset-0 pointer-events-none', imageClass]}
style:left
style:top
style:width
style:height
style:view-transition-name={transitionName}
data-transition-name={transitionName}
>
{#if show.alphaBackground} {#if show.alphaBackground}
<AlphaBackground /> <AlphaBackground />
{/if} {/if}
@@ -11,7 +11,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin } from '@immich/sdk'; import { sendTestEmailAdmin } from '@immich/sdk';
import { Button, LoadingSpinner, toastManager } from '@immich/ui'; import { Button, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -142,6 +142,7 @@
<Button <Button
size="small" size="small"
shape="round" shape="round"
loading={isSending}
disabled={!configToEdit.notifications.smtp.enabled} disabled={!configToEdit.notifications.smtp.enabled}
onclick={handleSendTestEmail} onclick={handleSendTestEmail}
> >
@@ -151,9 +152,6 @@
{$t('admin.notification_email_sent_test_email_button')} {$t('admin.notification_email_sent_test_email_button')}
{/if} {/if}
</Button> </Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div> </div>
</div> </div>
</SettingAccordion> </SettingAccordion>
@@ -33,13 +33,11 @@
{#if isOwned} {#if isOwned}
<Textarea <Textarea
bind:value={description} bind:value={description}
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent" variant="ghost"
rows={1}
grow
shape="rectangle"
onfocusout={handleFocusOut} onfocusout={handleFocusOut}
placeholder={$t('add_a_description')} placeholder={$t('add_a_description')}
data-testid="autogrow-textarea" data-testid="autogrow-textarea"
class="max-h-32"
{@attach fromAction(shortcut, () => ({ {@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter', ctrl: true }, shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(), onShortcut: (e) => e.currentTarget.blur(),
@@ -3,59 +3,56 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk'; import { updateAlbumInfo } from '@immich/sdk';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { tv } from 'tailwind-variants'; import { fromAction } from 'svelte/attachments';
interface Props { type Props = {
id: string; id: string;
albumName: string; albumName: string;
isOwned: boolean; isOwned: boolean;
onUpdate: (albumName: string) => void; onUpdate: (albumName: string) => void;
} };
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props(); let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
let newAlbumName = $derived(albumName); let newAlbumName = $derived(albumName);
const handleUpdateName = async () => { const handleUpdate = async () => {
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
if (newAlbumName === albumName) { if (newAlbumName === albumName) {
return; return;
} }
try { try {
const response = await updateAlbumInfo({ const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
id,
updateAlbumDto: {
albumName: newAlbumName,
},
});
({ albumName } = response); ({ albumName } = response);
eventManager.emit('AlbumUpdate', response); eventManager.emit('AlbumUpdate', response);
onUpdate(albumName); onUpdate(albumName);
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_save_album')); handleError(error, $t('errors.unable_to_save_album'));
return;
} }
}; };
const styles = tv({ const textClasses = 'text-2xl lg:text-6xl text-primary';
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
</script> </script>
<input <div class="mb-2">
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }} {#if isOwned}
onblur={handleUpdateName} <Textarea
class={styles({ isOwned })}
type="text"
bind:value={newAlbumName} bind:value={newAlbumName}
disabled={!isOwned} variant="ghost"
title={$t('edit_title')} title={$t('edit_title')}
onblur={handleUpdate}
placeholder={$t('add_a_title')} placeholder={$t('add_a_title')}
class={textClasses}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: (event) => event.currentTarget.blur(),
}))}
/> />
{:else}
<div class={textClasses}>{newAlbumName}</div>
{/if}
</div>
@@ -1,3 +1,9 @@
<script module lang="ts">
const useSplitNavTransitions =
typeof document !== 'undefined' &&
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
</script>
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { focusTrap } from '$lib/actions/focus-trap'; import { focusTrap } from '$lib/actions/focus-trap';
@@ -13,6 +19,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
@@ -27,6 +34,7 @@
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
import { import {
AssetTypeEnum, AssetTypeEnum,
getAssetInfo, getAssetInfo,
@@ -40,7 +48,7 @@
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures'; import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte'; import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte'; import ActivityViewer from './activity-viewer.svelte';
@@ -59,7 +67,7 @@
previousAsset?: AssetResponseDto; previousAsset?: AssetResponseDto;
}; };
interface Props { type Props = {
cursor: AssetCursor; cursor: AssetCursor;
showNavigation?: boolean; showNavigation?: boolean;
withStacked?: boolean; withStacked?: boolean;
@@ -72,7 +80,7 @@
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (asset: AssetResponseDto) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
} };
let { let {
cursor, cursor,
@@ -89,13 +97,14 @@
onRandom, onRandom,
}: Props = $props(); }: Props = $props();
const { setAssetId } = assetViewingStore; const { setAssetId, invisible } = assetViewingStore;
const { const {
restartProgress: restartSlideshowProgress, restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
slideshowNavigation, slideshowNavigation,
slideshowState, slideshowState,
slideshowRepeat, slideshowRepeat,
slideshowTransition,
} = slideshowStore; } = slideshowStore;
const stackThumbnailSize = 60; const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
@@ -109,6 +118,10 @@
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let playOriginalVideo = $state($alwaysLoadOriginalVideo); let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>(); let slideshowStartAssetId = $state<string>();
@@ -142,15 +155,25 @@
} }
}; };
const onAssetUpdate = (updatedAsset: AssetResponseDto) => { let transitionName = $state<string | undefined>('hero');
if (asset.id === updatedAsset.id) { let letterboxTransitionName = $state<string | undefined>(undefined);
cursor = { ...cursor, current: updatedAsset }; let detailPanelTransitionName = $state<string | undefined>(undefined);
}
};
let unsubscribes: (() => void)[] = [];
onMount(() => { onMount(() => {
syncAssetViewerOpenClass(true); syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
const addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
};
unsubscribes.push(
eventManager.on({
ViewerOpenTransition: addInfoTransition,
ViewerCloseTransition: addInfoTransition,
}),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) { if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset)); slideshowHistory.queue(toTimelineAsset(asset));
@@ -158,19 +181,14 @@
} else if (value === SlideshowState.StopSlideshow) { } else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow()); handlePromiseError(handleStopSlideshow());
} }
}); }),
slideshowNavigation.subscribe((value) => {
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) { if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset)); slideshowHistory.queue(toTimelineAsset(asset));
} }
}); }),
);
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
};
}); });
onDestroy(() => { onDestroy(() => {
@@ -179,10 +197,16 @@
isFaceEditMode.value = false; isFaceEditMode.value = false;
syncAssetViewerOpenClass(false); syncAssetViewerOpenClass(false);
preloadManager.destroy(); preloadManager.destroy();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
}); });
const closeViewer = () => { const closeViewer = () => {
onClose?.(asset); transitionName = 'hero';
const id = stack?.primaryAssetId ?? asset.id;
onClose?.({ ...asset, id });
}; };
const closeEditor = async () => { const closeEditor = async () => {
@@ -194,43 +218,100 @@
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
}; };
const tracker = new InvocationTracker(); const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
const navigateAsset = (order?: 'previous' | 'next') => { if (direction === 'previous' || direction === 'next') {
if (!order) { return useSplitNavTransitions ? `${direction}-${kind}` : direction;
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
}
} }
return direction ?? undefined;
};
const clearTransitionNames = () => {
detailPanelTransitionName = undefined;
transitionName = undefined;
letterboxTransitionName = undefined;
};
const startTransition = async (
types: string[],
targetTransition: string | null,
navigateFn: () => Promise<boolean>,
) => {
const oldName = getTransitionName('old', targetTransition);
const newName = getTransitionName('new', targetTransition);
let result = false;
await viewTransitionManager.startTransition({
types,
prepareOldSnapshot: () => {
transitionName = oldName;
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
detailPanelTransitionName = 'detail-panel';
},
performUpdate: async (signal) => {
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
result = await navigateFn();
await ready;
},
prepareNewSnapshot: () => {
transitionName = newName;
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
},
onFinished: clearTransitionNames,
});
return result;
};
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
preloadManager.cancelBeforeNavigation(order); preloadManager.cancelBeforeNavigation(order);
const skipped = viewTransitionManager.skipTransitions();
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
if (tracker.isActive()) { let navigate: () => Promise<boolean>;
return; let types: string[];
} let targetTransition: string | null;
void tracker.invoke(async () => { if (slideShowPlaying && slideShowShuffle) {
const isShuffle = navigate = async () => {
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle; let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
let hasNext: boolean;
if (isShuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.(); const asset = await onRandom?.();
if (asset) { if (asset) {
slideshowHistory.queue(asset); slideshowHistory.queue(asset);
hasNext = true; next = true;
} }
} }
return next;
};
types = ['slideshow'];
targetTransition = null;
} else { } else {
hasNext = navigate = async () => {
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); const target = order === 'previous' ? previousAsset : nextAsset;
return navigateToAsset(target);
};
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
targetTransition = slideShowPlaying ? null : order;
} }
if ($slideshowState !== SlideshowState.PlaySlideshow) { const targetAsset = order === 'previous' ? previousAsset : nextAsset;
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
const useTransition = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
let hasNext: boolean;
if (slideShowPlaying && useTransition) {
hasNext = false;
await crossfadeViewerContent(async () => {
hasNext = await navigate();
}, 1000);
} else if (canTransition && useTransition) {
hasNext = await startTransition(types, targetTransition, navigate);
} else {
hasNext = await navigate();
}
if (!slideShowPlaying) {
return; return;
} }
@@ -246,12 +327,33 @@
} }
await handleStopSlideshow(); await handleStopSlideshow();
}, $t('error_while_navigating'));
}; };
/** const tracker = new InvocationTracker();
* Slide show mode let navigating = $state(false);
*/ const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
if (tracker.isActive()) {
return;
}
navigating = true;
void tracker.invoke(
() => completeNavigation(order, skipTransition),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => {
navigating = false;
eventManager.emit('ViewerAfterNavigate');
},
);
};
let assetViewerHtmlElement = $state<HTMLElement>(); let assetViewerHtmlElement = $state<HTMLElement>();
@@ -277,10 +379,11 @@
const handleStopSlideshow = async () => { const handleStopSlideshow = async () => {
try { try {
if (document.fullscreenElement) { if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = ''; document.body.style.cursor = '';
await document.exitFullscreen(); await document.exitFullscreen();
}
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen')); handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally { } finally {
@@ -289,8 +392,24 @@
} }
}; };
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined; if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
return;
}
isFaceEditMode.value = false;
void crossfadeViewerContent(() => {
previewStackedAsset = stackedAsset;
});
};
const handleStackSlideshowMouseLeave = () => {
if (!previewStackedAsset) {
return;
}
removeCrossfadeOverlay();
previewStackedAsset = undefined;
}; };
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
@@ -391,15 +510,24 @@
if (cursor.current.id === lastCursor?.current.id) { if (cursor.current.id === lastCursor?.current.id) {
return; return;
} }
if (lastCursor) { if (lastCursor) {
previewStackedAsset = undefined;
ocrManager.showOverlay = false;
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink); preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
lastCursor = cursor;
return;
} }
if (!lastCursor) {
preloadManager.initializePreloads(cursor, sharedLink); preloadManager.initializePreloads(cursor, sharedLink);
}
lastCursor = cursor; lastCursor = cursor;
}); });
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
cursor = { ...cursor, current: update };
}
};
const viewerKind = $derived.by(() => { const viewerKind = $derived.by(() => {
if (previewStackedAsset) { if (previewStackedAsset) {
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer'; return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
@@ -470,13 +598,17 @@
<section <section
id="immich-asset-viewer" id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none" class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
class:invisible={$invisible}
data-navigating={navigating || undefined}
use:focusTrap use:focusTrap
bind:this={assetViewerHtmlElement} bind:this={assetViewerHtmlElement}
> >
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} {#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> <div
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
style:view-transition-name="exclude"
>
<AssetViewerNavBar <AssetViewerNavBar
{asset} {asset}
{album} {album}
@@ -487,7 +619,7 @@
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined} onClose={onClose ? closeViewer : undefined}
{playOriginalVideo} {playOriginalVideo}
{setPlayOriginalVideo} {setPlayOriginalVideo}
/> />
@@ -508,16 +640,21 @@
{/if} {/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div
data-test-id="previous-asset"
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
style:view-transition-name="exclude-leftbutton"
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
{/if} {/if}
<!-- Asset Viewer -->
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackVideoViewer'} {#if viewerKind === 'StackVideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
asset={previewStackedAsset!} asset={previewStackedAsset!}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash} cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType} projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true} loopVideo={true}
@@ -530,6 +667,7 @@
/> />
{:else if viewerKind === 'LiveVideoViewer'} {:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
{asset} {asset}
assetId={asset.livePhotoVideoId!} assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
@@ -541,13 +679,20 @@
{playOriginalVideo} {playOriginalVideo}
/> />
{:else if viewerKind === 'ImagePanaramaViewer'} {:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} /> <ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
{:else if viewerKind === 'CropArea'} {:else if viewerKind === 'CropArea'}
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} /> <PhotoViewer
{transitionName}
{letterboxTransitionName}
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
/>
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
{asset} {asset}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
@@ -581,15 +726,20 @@
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div
data-test-id="next-asset"
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
style:view-transition-name="exclude-rightbutton"
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>
{/if} {/if}
{#if showDetailPanel || assetViewerManager.isShowEditor} {#if showDetailPanel || assetViewerManager.isShowEditor}
<div <div
transition:fly={{ duration: 150 }} transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel" id="detail-panel"
style:view-transition-name={detailPanelTransitionName}
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light" class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes" translate="yes"
> >
@@ -605,9 +755,14 @@
</div> </div>
{/if} {/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor} {#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
{@const stackedAssets = stack.assets} {@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
id="stack-slideshow"
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
onmouseleave={handleStackSlideshowMouseLeave}
>
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar"> <div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
<div <div
@@ -620,10 +775,12 @@
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)} asset={toTimelineAsset(stackedAsset)}
onClick={() => { onClick={() => {
removeCrossfadeOverlay();
cursor.current = stackedAsset; cursor.current = stackedAsset;
previewStackedAsset = undefined; previewStackedAsset = undefined;
isFaceEditMode.value = false;
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
readonly readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false} showStackedIcon={false}
@@ -11,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
@@ -50,15 +50,15 @@
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte';
interface Props { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null; currentAlbum?: AlbumResponseDto | null;
} };
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false); let showAssetPath = $state(false);
let showEditFaces = $state(false); let showEditFaces = $derived(isEditFacesPanelOpen.value);
let isOwner = $derived($user?.id === asset.ownerId); let isOwner = $derived($user?.id === asset.ownerId);
let people = $derived(asset.people || []); let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []); let unassignedFaces = $derived(asset.unassignedFaces || []);
@@ -107,7 +107,7 @@
return; return;
} }
showEditFaces = false; isEditFacesPanelOpen.value = false;
previousId = asset.id; previousId = asset.id;
}); });
@@ -124,7 +124,7 @@
const handleRefreshPeople = async () => { const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id }); asset = await getAssetInfo({ id: asset.id });
eventManager.emit('AssetUpdate', asset); eventManager.emit('AssetUpdate', asset);
showEditFaces = false; isEditFacesPanelOpen.value = false;
}; };
const getAssetFolderHref = (asset: AssetResponseDto) => { const getAssetFolderHref = (asset: AssetResponseDto) => {
@@ -221,7 +221,7 @@
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => (showEditFaces = true)} onclick={() => (isEditFacesPanelOpen.value = true)}
/> />
{/if} {/if}
</div> </div>
@@ -230,8 +230,9 @@
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)} {#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden} {#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a <a
class="w-22" class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })} href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)} onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])} onblur={() => ($boundingBoxesArray = [])}
@@ -248,6 +249,8 @@
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
hidden={person.isHidden} hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/> />
</div> </div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p> <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
@@ -576,7 +579,7 @@
<PersonSidePanel <PersonSidePanel
assetId={asset.id} assetId={asset.id}
assetType={asset.type} assetType={asset.type}
onClose={() => (showEditFaces = false)} onClose={() => (isEditFacesPanelOpen.value = false)}
onRefresh={handleRefreshPeople} onRefresh={handleRefreshPeople}
/> />
{/if} {/if}
@@ -7,6 +7,7 @@
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -74,6 +75,8 @@
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none" class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform} style:transform={imageTransform}
onload={() => eventManager.emit('ViewerOpenTransitionReady')}
onerror={() => eventManager.emit('ViewerOpenTransitionReady')}
/> />
<div <div
class={[ class={[
@@ -4,7 +4,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { computeContentMetrics, getNaturalSize, mapContentRectToNatural } from '$lib/utils/container-utils'; import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils'; import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
@@ -14,15 +14,16 @@
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props { type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement; imageSize: Size;
containerWidth: number; containerWidth: number;
containerHeight: number; containerHeight: number;
assetId: string; assetId: string;
} };
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state();
@@ -37,6 +38,7 @@
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 }); let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
let userMovedRect = false; let userMovedRect = false;
let previousMetrics: ResizeContext | null = null; let previousMetrics: ResizeContext | null = null;
let panModifierHeld = $state(false);
let filteredCandidates = $derived( let filteredCandidates = $derived(
searchTerm searchTerm
@@ -58,7 +60,7 @@
}; };
const setupCanvas = () => { const setupCanvas = () => {
if (!canvasEl || !htmlElement) { if (!canvasEl) {
return; return;
} }
@@ -130,9 +132,12 @@
}; };
}); });
const imageContentMetrics = $derived( const imageContentMetrics = $derived.by(() => {
computeContentMetrics(getNaturalSize(htmlElement), { width: containerWidth, height: containerHeight }), if (imageSize.width === 0 || imageSize.height === 0) {
); return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics; const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
@@ -204,6 +209,9 @@
const gap = 15; const gap = 15;
const padding = faceRect.padding ?? 0; const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect(); const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const faceBox = { const faceBox = {
left: (rawBox.left - padding) * currentZoom + currentPositionX, left: (rawBox.left - padding) * currentZoom + currentPositionX,
@@ -291,6 +299,57 @@
} }
}); });
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const panModifierKey = isMac ? 'Meta' : 'Control';
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
const isZoomed = $derived(assetViewerManager.zoom > 1);
$effect(() => {
if (!containerEl) {
return;
}
const element = containerEl;
const parent = element.parentElement;
const activate = () => {
panModifierHeld = true;
element.style.pointerEvents = 'none';
if (parent) {
parent.style.cursor = 'move';
}
};
const deactivate = () => {
panModifierHeld = false;
element.style.pointerEvents = '';
if (parent) {
parent.style.cursor = '';
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === panModifierKey) {
activate();
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === panModifierKey) {
deactivate();
}
};
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', deactivate);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', deactivate);
deactivate();
};
});
const trapEvents = (node: HTMLElement) => { const trapEvents = (node: HTMLElement) => {
const stop = (e: Event) => e.stopPropagation(); const stop = (e: Event) => e.stopPropagation();
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const; const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
@@ -312,13 +371,12 @@
}; };
const getFaceCroppedCoordinates = () => { const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) { if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
return; return;
} }
const scaledWidth = faceRect.getScaledWidth(); const scaledWidth = faceRect.getScaledWidth();
const scaledHeight = faceRect.getScaledHeight(); const scaledHeight = faceRect.getScaledHeight();
const natural = getNaturalSize(htmlElement);
const imageRect = mapContentRectToNatural( const imageRect = mapContentRectToNatural(
{ {
@@ -328,12 +386,12 @@
height: scaledHeight, height: scaledHeight,
}, },
imageContentMetrics, imageContentMetrics,
natural, imageSize,
); );
return { return {
imageWidth: natural.width, imageWidth: imageSize.width,
imageHeight: natural.height, imageHeight: imageSize.height,
x: Math.floor(imageRect.left), x: Math.floor(imageRect.left),
y: Math.floor(imageRect.top), y: Math.floor(imageRect.top),
width: Math.floor(imageRect.width), width: Math.floor(imageRect.width),
@@ -391,7 +449,7 @@
<div <div
id="face-selector" id="face-selector"
bind:this={faceSelectorEl} bind:this={faceSelectorEl}
class="fixed w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out" class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
use:trapEvents use:trapEvents
onwheel={(e) => e.stopPropagation()} onwheel={(e) => e.stopPropagation()}
> >
@@ -434,4 +492,15 @@
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button> <Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
</div> </div>
{#if isZoomed && !panModifierHeld}
<div
transition:fade={{ duration: 200 }}
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
>
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
</p>
</div>
{/if}
</div> </div>
@@ -4,13 +4,14 @@
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = { type Props = {
transitionName?: string;
letterboxTransitionName?: string;
asset: AssetResponseDto; asset: AssetResponseDto;
}; };
let { asset }: Props = $props(); let { transitionName, letterboxTransitionName, asset }: Props = $props();
const assetId = $derived(asset.id); const assetId = $derived(asset.id);
@@ -20,11 +21,16 @@
}; };
</script> </script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} /> <PhotoSphereViewer
{transitionName}
{letterboxTransitionName}
panorama={data}
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
/>
{:catch} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
{/await} {/await}
@@ -0,0 +1,68 @@
<script lang="ts">
interface Props {
letterboxTransitionName?: string | undefined;
show?: boolean;
scaledDimensions: {
width: number;
height: number;
};
container: {
width: number;
height: number;
};
}
let { letterboxTransitionName, show = true, scaledDimensions, container }: Props = $props();
const shouldShowLetterboxes = $derived(show && !!letterboxTransitionName);
const letterboxes = $derived.by(() => {
const { width, height } = scaledDimensions;
const horizontalOffset = (container.width - width) / 2;
const verticalOffset = (container.height - height) / 2;
return [
{
name: 'letterbox-left',
width: horizontalOffset + 'px',
height: container.height + 'px',
left: '0px',
top: '0px',
},
{
name: 'letterbox-right',
width: horizontalOffset + 'px',
height: container.height + 'px',
left: container.width - horizontalOffset + 'px',
top: '0px',
},
{
name: 'letterbox-top',
width: width + 'px',
height: verticalOffset + 'px',
left: horizontalOffset + 'px',
top: '0px',
},
{
name: 'letterbox-bottom',
width: width + 'px',
height: verticalOffset + 'px',
left: horizontalOffset + 'px',
top: container.height - verticalOffset + 'px',
},
];
});
</script>
{#if shouldShowLetterboxes}
{#each letterboxes as box (box.name)}
<div
class="absolute"
style:view-transition-name={box.name}
style:left={box.left}
style:top={box.top}
style:width={box.width}
style:height={box.height}
></div>
{/each}
{/if}
@@ -43,12 +43,15 @@
</script> </script>
<div <div
class="absolute left-0 top-0 flex items-center justify-center border-2 border-blue-500 pointer-events-auto cursor-text focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none {isTouch class={[
'absolute left-0 top-0 flex items-center justify-center',
'border-2 border-blue-500 pointer-events-auto cursor-text',
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
isTouch
? 'text-white bg-black/60 select-all' ? 'text-white bg-black/60 select-all'
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3'} {ocrBox.verticalMode === : 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
'none' ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
? 'px-2 py-1 whitespace-nowrap' ]}
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}" style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
data-testid="ocr-box" data-testid="ocr-box"
data-overlay-interactive data-overlay-interactive
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
@@ -41,6 +43,8 @@
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text'; 'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
type Props = { type Props = {
transitionName?: string;
letterboxTransitionName?: string;
panorama: string | { source: string }; panorama: string | { source: string };
originalPanorama?: string | { source: string }; originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
@@ -48,11 +52,21 @@
navbar?: boolean; navbar?: boolean;
}; };
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let {
transitionName,
letterboxTransitionName,
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
}: Props = $props();
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
let animationInProgress: { cancel: () => void } | undefined; let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = []; let previousFaces: Faces[] = [];
@@ -210,6 +224,7 @@
zoomSpeed: 0.5, zoomSpeed: 0.5,
fisheye: false, fisheye: false,
}); });
viewer.addEventListener('ready', () => eventManager.emit('ViewerOpenTransitionReady'), { once: true });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin); const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel is 0-100 // zoomLevel is 0-100
@@ -254,7 +269,15 @@
<AssetViewerEvents {onZoom} /> <AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} /> <svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div> <div
id="sphere"
class="h-full w-full h-dvh w-dvw mb-0"
bind:this={container}
style:view-transition-name={transitionName}
></div>
<!-- Zero-sized letterboxes for view transitions from/to regular photos -->
<Letterboxes {letterboxTransitionName} scaledDimensions={fullscreenDimensions} container={fullscreenDimensions} />
<style> <style>
/* Reset the default tooltip styling */ /* Reset the default tooltip styling */
@@ -8,38 +8,57 @@
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils'; import type { Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { KenBurnsAnimation } from '$lib/utils/ken-burns';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk'; import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui'; import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte'; import type { AssetCursor } from './asset-viewer.svelte';
interface Props { type Props = {
cursor: AssetCursor; cursor: AssetCursor;
element?: HTMLDivElement; element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto; sharedLink?: SharedLinkResponseDto;
onReady?: () => void; transitionName?: string;
letterboxTransitionName?: string;
onError?: () => void; onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void; onSwipe?: (event: SwipeCustomEvent) => void;
} };
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); let {
cursor,
element = $bindable(),
sharedLink,
transitionName,
letterboxTransitionName,
onError,
onSwipe,
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
let visibleImageReady: boolean = $state(false); let visibleImageReady: boolean = $state(false);
const kenBurns = new KenBurnsAnimation();
let adaptiveImage = $state<HTMLDivElement | undefined>();
onMount(() =>
eventManager.on({
ViewTransitionOldSnapshotPending: () => kenBurns.freeze(),
}),
);
let previousAssetId: string | undefined; let previousAssetId: string | undefined;
$effect.pre(() => { $effect.pre(() => {
@@ -49,13 +68,16 @@
} }
previousAssetId = id; previousAssetId = id;
untrack(() => { untrack(() => {
kenBurns.cancel();
assetViewerManager.resetZoomState(); assetViewerManager.resetZoomState();
visibleImageReady = false; visibleImageReady = false;
$boundingBoxesArray = []; $boundingBoxesArray = [];
adaptiveImage?.style.removeProperty('transform');
}); });
}); });
onDestroy(() => { onDestroy(() => {
kenBurns.cancel();
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
@@ -67,13 +89,12 @@
height: containerHeight, height: containerHeight,
}); });
const overlaySize = $derived.by((): Size => { const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { width: 0, height: 0 };
}
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight }); let imageDimensions = $state<Size>({ width: 0, height: 0 });
}); let scaledDimensions = $state<Size>({ width: 0, height: 0 });
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []); const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
@@ -137,20 +158,16 @@
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash, $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
); );
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => { const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>(); const map = new Map<Faces, string | undefined>();
for (const person of asset.people ?? []) { for (const person of asset.people ?? []) {
for (const face of person.faces ?? []) { for (const face of person.faces ?? []) {
map.set(face, person.name); map.set(face, person.name);
} }
} }
if (isFaceEditMode.value) {
for (const face of asset.unassignedFaces ?? []) { for (const face of asset.unassignedFaces ?? []) {
map.set(face, ''); map.set(face, undefined);
}
} }
return map; return map;
}); });
@@ -159,6 +176,47 @@
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize)); const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id))); const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
const unassignedFaces = $derived((asset.unassignedFaces ?? []) as Faces[]);
const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect);
$effect(() => {
if (!kenBurnsActive || !visibleImageReady || !adaptiveImage || !assetViewerManager.imgRef) {
return;
}
assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false };
void kenBurns.startWithSmartCrop(adaptiveImage, {
imgRef: assetViewerManager.imgRef,
faces,
fallbackFaces: unassignedFaces,
contentWidth: overlaySize.width,
contentHeight: overlaySize.height,
containerWidth,
containerHeight,
slideshowLook: $slideshowLook,
isCoverMode,
slideshowDelay: $slideshowDelay,
assetId: asset.id,
});
return () => {
kenBurns.cancel();
if (viewTransitionManager.activeViewTransition === null) {
assetViewerManager.resetZoomState();
}
};
});
$effect(() => {
if (viewTransitionManager.activeViewTransition === null) {
kenBurns.resume();
} else {
kenBurns.pause();
}
});
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} /> <AssetViewerEvents {onCopy} {onZoom} />
@@ -186,18 +244,23 @@
{asset} {asset}
{sharedLink} {sharedLink}
{container} {container}
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'} objectFit={isCoverMode ? 'cover' : 'contain'}
{onUrlChange} {onUrlChange}
onImageReady={() => { onImageReady={() => {
visibleImageReady = true; visibleImageReady = true;
onReady?.(); eventManager.emit('ViewerOpenTransitionReady');
}} }}
onError={() => { onError={() => {
onError?.(); onError?.();
onReady?.(); eventManager.emit('ViewerOpenTransitionReady');
}} }}
bind:imgRef={assetViewerManager.imgRef} bind:imgRef={assetViewerManager.imgRef}
bind:imgNaturalSize={imageDimensions}
bind:imgScaledSize={scaledDimensions}
bind:ref={adaptiveImage} bind:ref={adaptiveImage}
{transitionName}
{letterboxTransitionName}
showLetterboxes={!blurredSlideshow}
> >
{#snippet backdrop()} {#snippet backdrop()}
{#if blurredSlideshow} {#if blurredSlideshow}
@@ -212,14 +275,16 @@
{#each boundingBoxes as boundingbox, index (boundingbox.id)} {#each boundingBoxes as boundingbox, index (boundingbox.id)}
{@const face = faces[index]} {@const face = faces[index]}
{@const name = faceToNameMap.get(face)} {@const name = faceToNameMap.get(face)}
{#if name !== undefined || isEditFacesPanelOpen.value}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="absolute pointer-events-auto outline-none rounded-lg" class="absolute pointer-events-auto outline-none rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {name || $t('unknown')}" aria-label="{$t('person')}: {name ?? $t('unknown')}"
onpointerenter={() => ($boundingBoxesArray = [face])} onpointerenter={() => ($boundingBoxesArray = [face])}
onpointerleave={() => ($boundingBoxesArray = [])} onpointerleave={() => ($boundingBoxesArray = [])}
></div> ></div>
{/if}
{/each} {/each}
{/if} {/if}
@@ -249,6 +314,6 @@
</AdaptiveImage> </AdaptiveImage>
{#if isFaceEditMode.value && assetViewerManager.imgRef} {#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
{/if} {/if}
</div> </div>
@@ -3,6 +3,7 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte'; import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants'; import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { import {
autoPlayVideo, autoPlayVideo,
@@ -11,14 +12,17 @@
videoViewerVolume, videoViewerVolume,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import type { Size } from '$lib/utils/container-utils';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { type Props = {
transitionName?: string;
assetId: string; assetId: string;
imageSize: Size;
loopVideo: boolean; loopVideo: boolean;
cacheKey: string | null; cacheKey: string | null;
playOriginalVideo: boolean; playOriginalVideo: boolean;
@@ -27,10 +31,12 @@
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
onClose?: () => void; onClose?: () => void;
} };
let { let {
transitionName,
assetId, assetId,
imageSize,
loopVideo, loopVideo,
cacheKey, cacheKey,
playOriginalVideo, playOriginalVideo,
@@ -58,7 +64,6 @@
}); });
$effect(() => { $effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) { if (assetFileUrl) {
hasFocused = false; hasFocused = false;
videoPlayer?.load(); videoPlayer?.load();
@@ -139,6 +144,7 @@
</div> </div>
{:else} {:else}
<video <video
style:view-transition-name={transitionName}
bind:this={videoPlayer} bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo} loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo} autoplay={$autoPlayVideo}
@@ -147,6 +153,7 @@
disablePictureInPicture disablePictureInPicture
class="h-full object-contain" class="h-full object-contain"
{...useSwipe(onSwipe)} {...useSwipe(onSwipe)}
onloadedmetadata={() => eventManager.emit('ViewerOpenTransitionReady')}
oncanplay={(e) => handleCanPlay(e.currentTarget)} oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded} onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)} onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
@@ -173,7 +180,7 @@
{/if} {/if}
{#if isFaceEditMode.value} {#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} /> <FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -3,13 +3,13 @@
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui'; import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props { interface Props {
transitionName?: string;
asset: AssetResponseDto; asset: AssetResponseDto;
} }
const { asset }: Props = $props(); const { asset, transitionName }: Props = $props();
const modules = Promise.all([ const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default), import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -19,11 +19,12 @@
]); ]);
</script> </script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <div class="flex h-full select-none place-content-center place-items-center">
{#await modules} {#await modules}
<LoadingSpinner /> <LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]} {:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer <PhotoSphereViewer
{transitionName}
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }} panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }} originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
plugins={[videoPlugin]} plugins={[videoPlugin]}
@@ -4,7 +4,8 @@
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
interface Props { type Props = {
transitionName?: string;
asset: AssetResponseDto; asset: AssetResponseDto;
assetId?: string; assetId?: string;
projectionType: string | null | undefined; projectionType: string | null | undefined;
@@ -16,9 +17,10 @@
onNextAsset?: () => void; onNextAsset?: () => void;
onVideoEnded?: () => void; onVideoEnded?: () => void;
onVideoStarted?: () => void; onVideoStarted?: () => void;
} };
let { let {
transitionName,
asset, asset,
assetId, assetId,
projectionType, projectionType,
@@ -36,12 +38,14 @@
</script> </script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR} {#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {asset} /> <VideoPanoramaViewer {transitionName} {asset} />
{:else} {:else}
<VideoNativeViewer <VideoNativeViewer
{transitionName}
{loopVideo} {loopVideo}
{cacheKey} {cacheKey}
assetId={effectiveAssetId} assetId={effectiveAssetId}
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
{playOriginalVideo} {playOriginalVideo}
{onPreviousAsset} {onPreviousAsset}
{onNextAsset} {onNextAsset}
@@ -16,6 +16,7 @@
circle?: boolean; circle?: boolean;
hidden?: boolean; hidden?: boolean;
border?: boolean; border?: boolean;
highlighted?: boolean;
hiddenIconClass?: string; hiddenIconClass?: string;
class?: ClassValue; class?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
@@ -34,6 +35,7 @@
circle = false, circle = false,
hidden = false, hidden = false,
border = false, border = false,
highlighted = false,
hiddenIconClass = 'text-white', hiddenIconClass = 'text-white',
onComplete = undefined, onComplete = undefined,
class: imageClass = '', class: imageClass = '',
@@ -83,6 +85,10 @@
/> />
{/if} {/if}
{#if highlighted}
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
{/if}
{#if hidden} {#if hidden}
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"> <div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<!-- TODO fix `title` type --> <!-- TODO fix `title` type -->
@@ -27,12 +27,12 @@
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import AssignFaceSidePanel from './assign-face-side-panel.svelte';
interface Props { type Props = {
assetId: string; assetId: string;
assetType: AssetTypeEnum; assetType: AssetTypeEnum;
onClose: () => void; onClose: () => void;
onRefresh: () => void; onRefresh: () => void;
} };
let { assetId, assetType, onClose, onRefresh }: Props = $props(); let { assetId, assetType, onClose, onRefresh }: Props = $props();
@@ -58,6 +58,8 @@
let automaticRefreshTimeout: ReturnType<typeof setTimeout>; let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
const thumbnailWidth = '90px'; const thumbnailWidth = '90px';
const focusHighlightClass =
'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary';
async function loadPeople() { async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
@@ -226,11 +228,13 @@
{:else} {:else}
{#each peopleWithFaces as face, index (face.id)} {#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')} {@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
<div class="relative h-29 w-24"> <div class="relative h-29 w-24">
<div <div
role="button" role="button"
tabindex={index} tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default" data-testid="face-thumbnail"
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onpointerleave={() => ($boundingBoxesArray = [])} onpointerleave={() => ($boundingBoxesArray = [])}
@@ -245,6 +249,8 @@
title={$t('new_person')} title={$t('new_person')}
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else if selectedPersonToReassign[face.id]} {:else if selectedPersonToReassign[face.id]}
<ImageThumbnail <ImageThumbnail
@@ -259,6 +265,8 @@
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
hidden={selectedPersonToReassign[face.id].isHidden} hidden={selectedPersonToReassign[face.id].isHidden}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else if face.person} {:else if face.person}
<ImageThumbnail <ImageThumbnail
@@ -270,6 +278,8 @@
widthStyle={thumbnailWidth} widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth} heightStyle={thumbnailWidth}
hidden={face.person.isHidden} hidden={face.person.isHidden}
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:else} {:else}
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)} {#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
@@ -281,6 +291,8 @@
title={$t('face_unassigned')} title={$t('face_unassigned')}
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{:then data} {:then data}
<ImageThumbnail <ImageThumbnail
@@ -291,6 +303,8 @@
title={$t('face_unassigned')} title={$t('face_unassigned')}
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
highlighted={isHighlighted}
class={focusHighlightClass}
/> />
{/await} {/await}
{/if} {/if}
@@ -6,10 +6,11 @@
import { useActions, type ActionArray } from '$lib/actions/use-actions'; import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte'; import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import type { HeaderButtonActionItem } from '$lib/types'; import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui'; import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -44,12 +45,17 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden'); let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
let isAssetViewer = $derived(appManager.isAssetViewer);
</script> </script>
<header> <header>
{#if !hideNavbar} {#if !hideNavbar && !isAssetViewer}
<NavigationBar onUploadClick={() => openFileUploadDialog()} /> <NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if} {/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header> </header>
<div <div
tabindex="-1" tabindex="-1"
@@ -58,13 +64,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''} {hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}" {hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
> >
{#if sidebar} {#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()} {@render sidebar()}
{:else} {:else}
<UserSidebar /> <UserSidebar />
{/if} {/if}
<main class="relative"> <main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}> <div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,57 +1,32 @@
<script lang="ts"> <script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants'; import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { Size } from '$lib/utils/container-utils';
import { getAssetMediaUrl } from '$lib/utils'; import type { AssetResponseDto } from '@immich/sdk';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte'; import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props { interface Props {
asset: TimelineAsset; asset: AssetResponseDto;
transitionName?: string;
onImageLoad: () => void; onImageLoad: () => void;
onError?: () => void;
} }
const { asset, onImageLoad }: Props = $props(); const { asset, transitionName, onImageLoad, onError }: Props = $props();
let assetFileUrl: string = $state(''); let containerWidth = $state(0);
let imageLoaded: boolean = $state(false); let containerHeight = $state(0);
let loader = $state<HTMLImageElement>();
const onLoadCallback = () => { const container: Size = $derived({ width: containerWidth, height: containerHeight });
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
onImageLoad();
};
onMount(() => {
if (loader?.complete) {
onLoadCallback();
}
loader?.addEventListener('load', onLoadCallback);
return () => {
loader?.removeEventListener('load', onLoadCallback);
};
});
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script> </script>
{#if !imageLoaded} <div
<!-- svelte-ignore a11y_missing_attribute --> class="relative h-full w-full overflow-hidden"
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> bind:clientWidth={containerWidth}
{/if} bind:clientHeight={containerHeight}
>
{#if !imageLoaded} {#if containerWidth > 0 && containerHeight > 0}
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
{:else}
<DelayedLoadingSpinner /> <DelayedLoadingSpinner />
{:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
/>
</div>
{/if} {/if}
</div>
@@ -20,11 +20,14 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants'; import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service'; import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
@@ -52,6 +55,7 @@
} from '@mdi/js'; } from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit'; import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
@@ -64,6 +68,7 @@
let paused = $state(false); let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined); let current = $state<MemoryAsset | undefined>(undefined);
const currentAssetId = $derived(current?.asset.id); const currentAssetId = $derived(current?.asset.id);
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
const currentMemoryAssetFull = $derived.by(async () => const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
); );
@@ -76,6 +81,14 @@
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0); let viewerHeight = $state(0);
let transition = $state({
name: undefined as string | undefined,
previousPanel: undefined as string | undefined,
nextPanel: undefined as string | undefined,
active: false,
});
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
const showNavButtonOverlay = $derived(transition.name === 'hero');
const { isViewing } = assetViewingStore; const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
@@ -86,18 +99,6 @@
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`; const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
return asset;
}
if (!asset) {
return;
}
await goto(asHref(asset));
};
const setProgressDuration = (asset: TimelineAsset) => { const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) { if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number); const timeParts = asset.duration!.split(':').map(Number);
@@ -112,11 +113,177 @@
} }
}; };
const handleNextAsset = () => handleNavigate(current?.next?.asset); const scrollToTop = () => {
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset); if (window.scrollY === 0) {
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); return Promise.resolve();
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); }
const handleEscape = async () => goto(Route.photos()); window.scrollTo({ top: 0, behavior: 'smooth' });
return new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 500);
window.addEventListener(
'scrollend',
() => {
clearTimeout(timeout);
resolve();
},
{ once: true },
);
});
};
const withMemoryTransition = async (
asset: { id: string } | undefined,
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
onFinished?: () => void;
},
) => {
if ($isViewing || !asset) {
return;
}
await scrollToTop();
transition.active = true;
viewTransitionManager
.startTransition({
...config,
onFinished: () => {
transition.previousPanel = undefined;
transition.nextPanel = undefined;
transition.name = undefined;
transition.active = false;
config.onFinished?.();
},
})
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
};
const navigateWithTransition = (asset?: { id: string }) =>
withMemoryTransition(asset, {
types: ['memory-nav'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
});
const handleNextAsset = () => {
const next = current?.next;
if (next && next.memory.id !== current?.memory.id) {
void navigateToMemory('next', next.asset);
} else {
void navigateWithTransition(next?.asset);
}
};
const handlePreviousAsset = () => {
const previous = current?.previous;
if (previous && previous.memory.id !== current?.memory.id) {
void navigateToMemory('previous', previous.asset);
} else {
void navigateWithTransition(previous?.asset);
}
};
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
const isNext = direction === 'next';
const useHeroMorph = !mediaQueryManager.reducedMotion;
return withMemoryTransition(asset, {
types: ['memory'],
prepareOldSnapshot: () => {
if (useHeroMorph) {
if (isNext) {
transition.nextPanel = 'hero';
transition.previousPanel = 'memory-departing';
} else {
transition.previousPanel = 'hero';
transition.nextPanel = 'memory-departing';
}
transition.name = 'hero-out';
} else {
transition.name = 'memory-fade-out';
}
},
performUpdate: async () => {
transition.nextPanel = undefined;
transition.previousPanel = undefined;
if (useHeroMorph) {
if (isNext) {
transition.previousPanel = 'hero-out';
} else {
transition.nextPanel = 'hero-out';
}
}
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
});
};
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
const closeMemoryViewer = () => {
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
const firstAsset = current.memory.assets[0];
void withMemoryTransition(firstAsset, {
types: ['memory-nav', 'memory-nav-fast'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(firstAsset));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
onFinished: () => closeToTimeline(),
});
} else {
closeToTimeline();
}
};
const closeToTimeline = () => {
const memoryId = current?.memory.id;
let cardImage: HTMLElement | null | undefined;
void viewTransitionManager.startTransition({
types: ['memory-enter'],
prepareOldSnapshot: () => {
transition.name = 'hero';
},
performUpdate: async () => {
transition.name = undefined;
await goto(Route.photos());
await tick();
const memoryCard = memoryId
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
: null;
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
cardImage = memoryCard?.querySelector<HTMLElement>('img');
if (cardImage) {
cardImage.style.viewTransitionName = 'hero';
await tick();
}
},
onFinished: () => {
if (cardImage) {
cardImage.style.viewTransitionName = '';
cardImage = null;
}
},
});
};
const handleEscape = closeMemoryViewer;
const handleSelectAll = () => const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []); assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -160,13 +327,17 @@
} }
}; };
const handleProgress = async (progress: number) => { const handleProgress = (progress: number) => {
if (!progressBarController) { if (!progressBarController) {
return; return;
} }
if (progress === 1 && !paused) { if (progress === 1 && !paused && !transition.active) {
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause'))); if (current?.next) {
handleNextAsset();
} else {
handlePromiseError(handleAction('handleProgressLast', 'pause'));
}
} }
}; };
@@ -270,7 +441,18 @@
playerInitialized = false; playerInitialized = false;
}; };
const resetAndPlay = () => { const resolveTransitionIfPending = () => {
if (viewTransitionManager.activeViewTransition) {
transition.name = 'hero';
eventManager.emit('ViewerOpenTransitionReady');
requestAnimationFrame(() => {
transition.name = undefined;
});
}
};
const handleMemoryImageReady = () => {
resolveTransitionIfPending();
handlePromiseError(handleAction('resetAndPlay', 'reset')); handlePromiseError(handleAction('resetAndPlay', 'reset'));
handlePromiseError(handleAction('resetAndPlay', 'play')); handlePromiseError(handleAction('resetAndPlay', 'play'));
}; };
@@ -285,7 +467,7 @@
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause')); handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) { } else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets. // Image assets will start playing when the image is loaded. Only autostart video assets.
resetAndPlay(); handleMemoryImageReady();
} }
playerInitialized = true; playerInitialized = true;
}; };
@@ -313,7 +495,7 @@
$effect(() => { $effect(() => {
if (progressBarController) { if (progressBarController) {
handlePromiseError(handleProgress(progressBarController.current)); handleProgress(progressBarController.current);
} }
}); });
@@ -382,7 +564,7 @@
bind:clientWidth={viewport.width} bind:clientWidth={viewport.width}
> >
{#if current} {#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow> <ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
{#snippet leading()} {#snippet leading()}
{#if current} {#if current}
<p class="text-lg"> <p class="text-lg">
@@ -458,7 +640,11 @@
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden" class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
> >
<!-- PREVIOUS MEMORY --> <!-- PREVIOUS MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}"> <div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
? ''
: 'opacity-0!'}"
>
<button <button
type="button" type="button"
class="relative h-full w-full rounded-2xl" class="relative h-full w-full rounded-2xl"
@@ -471,6 +657,7 @@
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })} src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')} alt={$t('previous_memory')}
draggable="false" draggable="false"
style:view-transition-name={transition.previousPanel}
/> />
{:else} {:else}
<enhanced:img <enhanced:img
@@ -483,7 +670,10 @@
{/if} {/if}
{#if current.previousMemory} {#if current.previousMemory}
<div class="absolute bottom-4 end-4 text-start text-white"> <div
class="absolute bottom-4 end-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p> <p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p> <p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
</div> </div>
@@ -492,10 +682,7 @@
</div> </div>
<!-- CURRENT MEMORY --> <!-- CURRENT MEMORY -->
<div <div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
>
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id} {#key current.asset.id}
{#if current.asset.isVideo} {#if current.asset.isVideo}
<MemoryVideoViewer <MemoryVideoViewer
@@ -504,8 +691,13 @@
videoViewerMuted={$videoViewerMuted} videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume} videoViewerVolume={$videoViewerVolume}
/> />
{:else} {:else if currentAssetDto}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} /> <MemoryPhotoViewer
asset={currentAssetDto}
transitionName={transition.name}
onImageLoad={handleMemoryImageReady}
onError={resolveTransitionIfPending}
/>
{/if} {/if}
{/key} {/key}
@@ -513,6 +705,7 @@
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark" class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView} class:opacity-0={galleryInView}
class:opacity-100={!galleryInView} class:opacity-100={!galleryInView}
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
> >
<div class="flex items-center"> <div class="flex items-center">
<IconButton <IconButton
@@ -566,8 +759,12 @@
</div> </div>
</div> </div>
<!-- CONTROL BUTTONS --> <!-- CONTROL BUTTONS -->
<div
class="absolute inset-0 pointer-events-none"
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
>
{#if current.previous} {#if current.previous}
<div class="absolute top-1/2 start-0 ms-4 dark"> <div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
<IconButton <IconButton
shape="round" shape="round"
aria-label={$t('previous_memory')} aria-label={$t('previous_memory')}
@@ -581,7 +778,7 @@
{/if} {/if}
{#if current.next} {#if current.next}
<div class="absolute top-1/2 end-0 me-4 dark"> <div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
<IconButton <IconButton
shape="round" shape="round"
aria-label={$t('next_memory')} aria-label={$t('next_memory')}
@@ -593,8 +790,12 @@
/> />
</div> </div>
{/if} {/if}
</div>
<div class="absolute start-8 top-4 text-sm font-medium text-white"> <div
class="absolute start-8 top-4 text-sm font-medium text-white"
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
>
<p> <p>
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { {fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale, locale: $locale,
@@ -608,10 +809,13 @@
</p> </p>
</div> </div>
</div> </div>
</div>
<!-- NEXT MEMORY --> <!-- NEXT MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}"> <div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
? ''
: 'opacity-0!'}"
>
<button <button
type="button" type="button"
class="relative h-full w-full rounded-2xl" class="relative h-full w-full rounded-2xl"
@@ -624,6 +828,7 @@
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })} src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')} alt={$t('next_memory')}
draggable="false" draggable="false"
style:view-transition-name={transition.nextPanel}
/> />
{:else} {:else}
<enhanced:img <enhanced:img
@@ -636,7 +841,10 @@
{/if} {/if}
{#if current.nextMemory} {#if current.nextMemory}
<div class="absolute bottom-4 start-4 text-start text-white"> <div
class="absolute bottom-4 start-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p> <p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p> <p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
</div> </div>
@@ -680,8 +888,6 @@
<style> <style>
.main-view { .main-view {
box-shadow: filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
0 4px 4px 0 rgba(0, 0, 0, 0.3),
0 8px 12px 6px rgba(0, 0, 0, 0.15);
} }
</style> </style>
@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`, bottom: `${rootHeight - top}px`,
left: `${left}px`, left: `${left}px`,
width: `${boundary.width}px`, width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset), maxHeight: maxHeight(boundary.top - dropdownOffset),
}; };
} }
const viewportHeight = visualViewport?.height || rootHeight; const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom; const availableHeight = viewportHeight - boundary.bottom;
return { return {
top: `${bottom}px`, top: `${bottom}px`,
left: `${left}px`, left: `${left}px`,
@@ -3,10 +3,13 @@
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte'; import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
@@ -27,9 +30,11 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { startViewerTransition } from '$lib/utils/transition-utils';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@@ -65,6 +70,16 @@
}: Props = $props(); }: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
$effect(() => {
if ($isViewerOpen) {
document.documentElement.style.overflow = 'hidden';
}
return () => {
document.documentElement.style.overflow = '';
};
});
const navigationAssets = $derived(viewerAssets ?? assets); const navigationAssets = $derived(viewerAssets ?? assets);
const geometry = $derived( const geometry = $derived(
@@ -107,6 +122,36 @@
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const scrollGalleryToAsset = async (assetId: string) => {
const index = assets.findIndex((asset) => asset.id === assetId);
if (index === -1) {
return;
}
const assetTopPage = geometry.getTop(index) + slidingWindowOffset;
const assetBottomPage = assetTopPage + geometry.getHeight(index);
const currentScrollTop = document.scrollingElement?.scrollTop ?? 0;
const visibleTop = currentScrollTop + pageHeaderOffset;
const visibleBottom = currentScrollTop + viewport.height;
if (assetTopPage >= visibleTop && assetBottomPage <= visibleBottom) {
return;
}
const distanceToAlignTop = Math.abs(assetTopPage - pageHeaderOffset - currentScrollTop);
const distanceToAlignBottom = Math.abs(assetBottomPage - viewport.height - currentScrollTop);
const newScrollTop =
distanceToAlignTop < distanceToAlignBottom ? assetTopPage - pageHeaderOffset : assetBottomPage - viewport.height;
if (document.scrollingElement) {
document.scrollingElement.scrollTop = newScrollTop;
}
updateSlidingWindow();
await tick();
};
const scrollToAndFocusAsset = async (assetId: string) => {
await scrollGalleryToAsset(assetId);
focusAsset(assetId);
};
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0; let lastIntersectedHeight = 0;
@@ -356,6 +401,63 @@
nextAsset: getNextAsset(navigationAssets, $viewingAsset), nextAsset: getNextAsset(navigationAssets, $viewingAsset),
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset), previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
}); });
let toViewerTransitionId = $state<string | null>(null);
let toGalleryTransitionId = $state<string | null>(null);
const transitionTargetId = $derived(toViewerTransitionId ?? toGalleryTransitionId);
const handleThumbnailClick = (asset: AssetResponseDto, currentAsset: TimelineAsset) => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
const doNavigate = () => void navigateToAsset(asset);
if (!viewTransitionManager.isSupported()) {
doNavigate();
return;
}
startViewerTransition(asset.id, doNavigate, (id) => (toViewerTransitionId = id));
};
const transitionToGalleryCallback = ({ id }: { id: string }) => {
void viewTransitionManager.startTransition({
types: ['timeline'],
prepareOldSnapshot: () => {
void scrollGalleryToAsset(id);
},
performUpdate: async () => {
eventManager.emit('ViewerCloseTransitionReady');
toGalleryTransitionId = id;
await tick();
},
onFinished: () => {
toGalleryTransitionId = null;
focusAsset(id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToGalleryCallback }));
}
const handleClose = async (asset: { id: string }) => {
const useTransition = viewTransitionManager.isSupported();
if (useTransition) {
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
eventManager.emit('ViewerCloseTransition', { id: asset.id });
await transitionReady;
}
assetViewingStore.showAssetViewer(false);
if (!useTransition) {
await tick();
await scrollToAndFocusAsset(asset.id);
}
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true }));
};
</script> </script>
<svelte:document <svelte:document
@@ -375,16 +477,17 @@
{#each assets as asset, i (asset.id + '-' + i)} {#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)} {#if isIntersecting(i)}
{@const currentAsset = toTimelineAsset(asset)} {@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}> {@const transitionName = transitionTargetId === asset.id ? 'hero' : undefined}
<div
class="absolute"
style:overflow="clip"
style={getStyle(i)}
style:view-transition-name={transitionName}
data-transition-name={transitionName}
>
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={() => { onClick={() => handleThumbnailClick(asset, currentAsset)}
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)} onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)} onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon} {showArchiveIcon}
@@ -416,10 +519,7 @@
onAction={handleAction} onAction={handleAction}
onRandom={handleRandom} onRandom={handleRandom}
onAssetChange={updateCurrentAsset} onAssetChange={updateCurrentAsset}
onClose={() => { onClose={(asset) => handleClose(asset)}
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/> />
{/await} {/await}
</Portal> </Portal>
@@ -50,7 +50,11 @@
<svelte:window bind:innerWidth /> <svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm"> <nav
id="dashboard-navbar"
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm relative z-10"
style:view-transition-name="exclude"
>
<SkipLink text={$t('skip_to_content')} /> <SkipLink text={$t('skip_to_content')} />
<div <div
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
@@ -1,20 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = { type Props = {
animationTargetAssetId?: string | null;
suspendTransitions?: boolean;
viewerAssets: ViewerAsset[]; viewerAssets: ViewerAsset[];
width: number; width: number;
height: number; height: number;
manager: VirtualScrollManager;
thumbnail: Snippet< thumbnail: Snippet<
[ [
{ {
@@ -26,14 +25,20 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>; customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
}; };
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props(); let { isUploading } = uploadAssetsStore;
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const {
animationTargetAssetId,
suspendTransitions = false,
viewerAssets,
width,
height,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script> </script>
<!-- Image grid --> <!-- Image grid -->
@@ -41,11 +46,14 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests --> <!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
data-asset-id={asset.id} data-asset-id={asset.id}
class="absolute" class="absolute"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'} style:top={position.top + 'px'}
style:inset-inline-start={position.left + 'px'} style:inset-inline-start={position.left + 'px'}
style:width={position.width + 'px'} style:width={position.width + 'px'}
+36 -10
View File
@@ -1,34 +1,37 @@
<script lang="ts"> <script lang="ts">
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte'; import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte'; import { onMount, tick, type Snippet } from 'svelte';
type Props = { type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>; thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>; customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean; singleSelect: boolean;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
monthGroup: MonthGroup; monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void; onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
}; };
let { let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup, thumbnail: thumbnailWithGroup,
customThumbnailLayout, customThumbnailLayout,
singleSelect, singleSelect,
assetInteraction, assetInteraction,
monthGroup, monthGroup,
manager,
onDayGroupSelect, onDayGroupSelect,
}: Props = $props(); }: Props = $props();
@@ -37,10 +40,6 @@
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
const getDayGroupFullDate = (dayGroup: DayGroup): string => { const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth; const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({ const date = fromTimelinePlainDate({
@@ -50,6 +49,32 @@
}); });
return getDateLocaleString(date); return getDateLocaleString(date);
}; };
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
void viewTransitionManager.startTransition({
types: ['timeline'],
performUpdate: async () => {
eventManager.emit('ViewerCloseTransitionReady');
const event = await eventManager.untilNext('TimelineLoaded');
toTimelineTransitionAssetId = event.id;
await tick();
},
onFinished: () => {
toTimelineTransitionAssetId = null;
focusAsset(asset.id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
}
</script> </script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -93,7 +118,8 @@
</div> </div>
<AssetLayout <AssetLayout
{manager} {animationTargetAssetId}
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
viewerAssets={dayGroup.viewerAssets} viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height} height={dayGroup.height}
width={dayGroup.width} width={dayGroup.width}
@@ -11,6 +11,7 @@
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
invisible: boolean;
/** Offset from the top of the timeline (e.g., for headers) */ /** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number; timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */ /** Offset from the bottom of the timeline (e.g., for footers) */
@@ -39,6 +40,7 @@
} }
let { let {
invisible = false,
timelineTopOffset = 0, timelineTopOffset = 0,
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
@@ -438,7 +440,7 @@
next = forward next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement) ? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement); : (focusable[(index - 1) % focusable.length] as HTMLElement);
next.focus(); next?.focus();
} }
} }
} }
@@ -509,6 +511,7 @@
aria-valuemin={toScrollY(0)} aria-valuemin={toScrollY(0)}
data-id="scrubber" data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize" class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'} style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'} style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width style:width
+47 -26
View File
@@ -11,6 +11,8 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import { startViewerTransition } from '$lib/utils/transition-utils';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -102,6 +104,7 @@
// Overall scroll percentage through the entire timeline (0-1) // Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0); let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0); let scrubberWidth = $state(0);
let toAssetViewerTransitionId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mediaQueryManager.maxMd); const maxMd = $derived(mediaQueryManager.maxMd);
@@ -209,7 +212,7 @@
timelineManager.viewportWidth = rect.width; timelineManager.viewportWidth = rect.width;
} }
} }
const scrollTarget = $gridScrollTarget?.at; const scrollTarget = getScrollTarget();
let scrolled = false; let scrolled = false;
if (scrollTarget) { if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget); scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -221,7 +224,7 @@
await tick(); await tick();
focusAsset(scrollTarget); focusAsset(scrollTarget);
} }
invisible = false; invisible = isAssetViewerRoute(page) ? true : false;
}; };
// note: only modified once in afterNavigate() // note: only modified once in afterNavigate()
@@ -239,10 +242,13 @@
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer; hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
}); });
const getScrollTarget = () => {
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
};
// afterNavigate is only called after navigation to a new URL, {complete} will resolve // afterNavigate is only called after navigation to a new URL, {complete} will resolve
// after successful navigation. // after successful navigation.
afterNavigate(({ complete }) => { afterNavigate(({ complete }) => {
void complete.finally(() => { void complete.finally(async () => {
const isAssetViewerPage = isAssetViewerRoute(page); const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then // Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -251,8 +257,13 @@
if (isDirectNavigation) { if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer; initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
} }
void scrollAfterNavigate(); void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
}); });
}); });
@@ -260,7 +271,7 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
onMount(() => { onMount(() => {
if (!enableRouting) { if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false; invisible = false;
} }
}); });
@@ -524,6 +535,33 @@
} }
}); });
const defaultThumbnailClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleThumbnailClick = (asset: TimelineAsset, dayGroup: DayGroup) => {
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, defaultThumbnailClick);
} else {
defaultThumbnailClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
return;
}
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
startViewerTransition(asset.id, openViewer, (id) => (toAssetViewerTransitionId = id));
};
const assetSelectHandler = ( const assetSelectHandler = (
timelineManager: TimelineManager, timelineManager: TimelineManager,
asset: TimelineAsset, asset: TimelineAsset,
@@ -544,19 +582,6 @@
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length; assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length;
}; };
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script> </script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} /> <svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -587,6 +612,7 @@
{#if timelineManager.months.length > 0} {#if timelineManager.months.length > 0}
<Scrubber <Scrubber
{timelineManager} {timelineManager}
{invisible}
height={timelineManager.viewportHeight} height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight} timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight} timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -618,6 +644,7 @@
id="asset-grid" id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]} class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'} style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
data-initialized={timelineManager.isInitialized || undefined}
tabindex="-1" tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight} bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth} bind:clientWidth={timelineManager.viewportWidth}
@@ -666,11 +693,11 @@
style:width="100%" style:width="100%"
> >
<Month <Month
{toAssetViewerTransitionId}
{assetInteraction} {assetInteraction}
{customThumbnailLayout} {customThumbnailLayout}
{singleSelect} {singleSelect}
{monthGroup} {monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect} onDayGroupSelect={handleGroupSelect}
> >
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })} {#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -684,13 +711,7 @@
{asset} {asset}
{albumUsers} {albumUsers}
{groupIndex} {groupIndex}
onClick={(asset) => { onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={() => { onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle); assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
@@ -4,6 +4,8 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -98,6 +100,12 @@
}; };
const handleClose = async (asset: { id: string }) => { const handleClose = async (asset: { id: string }) => {
if (viewTransitionManager.isSupported()) {
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
eventManager.emit('ViewerCloseTransition', { id: asset.id });
await transitionReady;
}
invisible = true; invisible = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
@@ -0,0 +1,376 @@
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
describe('ViewTransitionManager', () => {
let manager: ViewTransitionManager;
beforeEach(() => {
manager = new ViewTransitionManager();
});
afterEach(() => {
delete (document as Partial<typeof document> & { startViewTransition?: unknown }).startViewTransition;
});
describe('when View Transition API is not supported', () => {
it('should still call performUpdate', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
await manager.startTransition({ performUpdate });
expect(performUpdate).toHaveBeenCalledOnce();
});
it('should call onFinished after performUpdate', async () => {
const callOrder: string[] = [];
const performUpdate = vi.fn().mockImplementation(() => {
callOrder.push('performUpdate');
});
const onFinished = vi.fn().mockImplementation(() => {
callOrder.push('onFinished');
});
await manager.startTransition({ performUpdate, onFinished });
expect(onFinished).toHaveBeenCalledOnce();
expect(callOrder).toEqual(['performUpdate', 'onFinished']);
});
it('should not call prepareOldSnapshot or prepareNewSnapshot', async () => {
const prepareOldSnapshot = vi.fn();
const prepareNewSnapshot = vi.fn();
const performUpdate = vi.fn().mockResolvedValue(undefined);
await manager.startTransition({ performUpdate, prepareOldSnapshot, prepareNewSnapshot });
expect(prepareOldSnapshot).not.toHaveBeenCalled();
expect(prepareNewSnapshot).not.toHaveBeenCalled();
});
});
describe('when a transition is already active', () => {
it('should skip the first transition and run the second', async () => {
let resolveFirstUpdate!: () => void;
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
resolveFirstUpdate = resolve;
});
const firstFinished = new Promise<void>(() => {});
const firstSkipTransition = vi.fn();
let callCount = 0;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
callCount++;
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
if (callCount === 1) {
return {
updateCallbackDone: firstUpdateCallbackDone,
finished: firstFinished,
ready: Promise.resolve(),
skipTransition: firstSkipTransition,
};
}
return {
updateCallbackDone: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
// Start first — it will be blocked on updateCallbackDone
const firstPromise = manager.startTransition({
performUpdate: async () => {},
});
// Flush microtasks so the first transition reaches the startViewTransition call
await new Promise<void>((r) => queueMicrotask(r));
// While first is active, start a second — should skip the first and proceed
await manager.startTransition({ performUpdate: secondPerformUpdate });
expect(firstSkipTransition).toHaveBeenCalledOnce();
expect(secondPerformUpdate).toHaveBeenCalledOnce();
// Clean up first promise
resolveFirstUpdate();
await firstPromise;
});
});
describe('skipTransitions', () => {
it('should return false when no transition is active', () => {
expect(manager.skipTransitions()).toBe(false);
});
it('should call skipTransition on the active transition and return true', async () => {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
const skipTransition = vi.fn();
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition };
});
const promise = manager.startTransition({ performUpdate: async () => {} });
await new Promise<void>((r) => queueMicrotask(r));
const skipped = manager.skipTransitions();
expect(skipped).toBe(true);
expect(skipTransition).toHaveBeenCalledOnce();
resolveUpdate();
resolveFinished();
await promise;
});
it('should allow a new transition after skipping', async () => {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
});
const promise = manager.startTransition({ performUpdate: async () => {} });
await new Promise<void>((r) => queueMicrotask(r));
manager.skipTransitions();
resolveUpdate();
resolveFinished();
await promise;
// Now start a second transition — it should NOT be skipped
const secondUpdate = vi.fn().mockResolvedValue(undefined);
const secondFinished = Promise.resolve();
const secondUpdateDone = Promise.resolve();
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return {
updateCallbackDone: secondUpdateDone,
finished: secondFinished,
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
await manager.startTransition({ performUpdate: secondUpdate });
expect(secondUpdate).toHaveBeenCalledOnce();
});
});
describe('error handling', () => {
it('should propagate error from performUpdate when API is not supported', async () => {
const error = new Error('update failed');
const performUpdate = vi.fn().mockRejectedValue(error);
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
});
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
const error = new Error('update failed');
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
const updateCallbackDone = updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
});
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
'update failed',
);
// Simulate transition finishing after error
resolveFinished();
await new Promise<void>((r) => queueMicrotask(r));
// Manager should accept new transitions after cleanup
const secondUpdate = vi.fn().mockResolvedValue(undefined);
const secondFinished = Promise.resolve();
const secondUpdateDone = Promise.resolve();
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return {
updateCallbackDone: secondUpdateDone,
finished: secondFinished,
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
await manager.startTransition({ performUpdate: secondUpdate });
expect(secondUpdate).toHaveBeenCalledOnce();
});
});
describe('fallback path', () => {
it('should fall back to function argument when object argument throws', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
const prepareNewSnapshot = vi.fn();
const finished = Promise.resolve();
const updateCallbackDone = Promise.resolve();
let callCount = 0;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
callCount++;
if (callCount === 1 && typeof arg !== 'function') {
throw new TypeError('object form not supported');
}
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
});
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
expect(performUpdate).toHaveBeenCalledOnce();
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
// eslint-disable-next-line tscompat/tscompat
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
});
});
describe('abort signal', () => {
it('should pass an AbortSignal to performUpdate', async () => {
const performUpdate = vi.fn().mockResolvedValue(undefined);
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return {
updateCallbackDone: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
await manager.startTransition({ performUpdate });
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
});
it('should abort the signal when transition.ready rejects', async () => {
let capturedSignal: AbortSignal | undefined;
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
});
const readyError = new Error('Transition was aborted because of timeout in DOM update');
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return {
updateCallbackDone,
finished: Promise.reject(readyError),
ready: Promise.reject(readyError),
skipTransition: vi.fn(),
};
});
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
capturedSignal = signal;
return new Promise<void>((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true });
});
});
const promise = manager.startTransition({ performUpdate });
// Flush microtasks so ready rejection fires and aborts the signal
await new Promise<void>((r) => queueMicrotask(r));
await new Promise<void>((r) => queueMicrotask(r));
expect(capturedSignal?.aborted).toBe(true);
resolveUpdate();
await promise;
});
it('should not abort the signal when transition completes normally', async () => {
let capturedSignal: AbortSignal | undefined;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return {
updateCallbackDone: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
await manager.startTransition({
performUpdate: (signal) => {
capturedSignal = signal;
return Promise.resolve();
},
});
expect(capturedSignal?.aborted).toBe(false);
});
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
let capturedSignal: AbortSignal | undefined;
await manager.startTransition({
performUpdate: (signal) => {
capturedSignal = signal;
return Promise.resolve();
},
});
expect(capturedSignal).toBeInstanceOf(AbortSignal);
expect(capturedSignal?.aborted).toBe(false);
});
});
describe('isSupported', () => {
it('should return false when startViewTransition is not in document', () => {
expect(manager.isSupported()).toBe(false);
});
it('should return true when startViewTransition is in document', () => {
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn();
expect(manager.isSupported()).toBe(true);
});
});
});
@@ -0,0 +1,102 @@
import { tick } from 'svelte';
interface TransitionRequest {
types?: string[];
prepareOldSnapshot?: () => void;
performUpdate: (signal: AbortSignal) => Promise<void>;
prepareNewSnapshot?: () => void;
onFinished?: () => void;
}
export class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
#activeOnFinished: (() => void) | undefined;
get activeViewTransition() {
return this.#activeViewTransition;
}
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null;
const onFinished = this.#activeOnFinished;
this.#activeOnFinished = undefined;
onFinished?.();
return skipped;
}
async startTransition({
types,
prepareOldSnapshot,
performUpdate,
prepareNewSnapshot,
onFinished,
}: TransitionRequest) {
if (this.#activeViewTransition) {
this.skipTransitions();
}
if (!this.isSupported()) {
await performUpdate(AbortSignal.timeout(10_000));
onFinished?.();
return;
}
prepareOldSnapshot?.();
await tick();
const abortController = new AbortController();
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({
update: async () => {
await performUpdate(abortController.signal);
prepareNewSnapshot?.();
await tick();
},
types,
});
} catch {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(async () => {
await performUpdate(abortController.signal);
prepareNewSnapshot?.();
await tick();
});
}
this.#activeViewTransition = transition;
this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => {
abortController.abort(error);
});
// Let animation run in the background — don't block the caller.
// This allows skipTransitions() to abort mid-animation for rapid navigation.
// eslint-disable-next-line tscompat/tscompat
void transition.finished
.catch(() => {})
.finally(() => {
if (this.#activeViewTransition === transition) {
this.#activeViewTransition = null;
this.#activeOnFinished = undefined;
onFinished?.();
}
});
// Wait only until the DOM update completes (both snapshots captured),
// not for the animation to finish.
// eslint-disable-next-line tscompat/tscompat
await transition.updateCallbackDone;
}
}
export const viewTransitionManager = new ViewTransitionManager();
@@ -0,0 +1,5 @@
class AppManager {
isAssetViewer = $state(false);
}
export const appManager = new AppManager();
@@ -89,6 +89,16 @@ export type Events = {
ReleaseEvent: [ReleaseEvent]; ReleaseEvent: [ReleaseEvent];
WebsocketConnect: []; WebsocketConnect: [];
TimelineLoaded: [{ id: string | null }];
TimelineScrolledToAsset: [{ id: string }];
ViewerAfterNavigate: [];
ViewerCloseTransition: [{ id: string }];
ViewerCloseTransitionReady: [];
ViewerOpenTransition: [];
ViewerOpenTransitionReady: [];
ViewTransitionOldSnapshotPending: [];
}; };
export const eventManager = new BaseEventManager<Events>(); export const eventManager = new BaseEventManager<Events>();
@@ -2,3 +2,11 @@ import type { TimelineAsset } from './types';
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
export function* filterIntersecting<T extends { intersecting: boolean }>(items: T[]) {
for (const item of items) {
if (item.intersecting) {
yield item;
}
}
}
@@ -1,5 +1,6 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
import { cancelUploadRequests } from '$lib/utils';
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk'; import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
class UploadManager { class UploadManager {
@@ -13,6 +14,7 @@ class UploadManager {
} }
reset() { reset() {
cancelUploadRequests();
uploadAssetsStore.reset(); uploadAssetsStore.reset();
} }
@@ -21,6 +21,7 @@
slideshowTransition, slideshowTransition,
slideshowAutoplay, slideshowAutoplay,
slideshowRepeat, slideshowRepeat,
kenBurnsEffect,
slideshowState, slideshowState,
} = slideshowStore; } = slideshowStore;
@@ -38,6 +39,7 @@
let tempSlideshowTransition = $state($slideshowTransition); let tempSlideshowTransition = $state($slideshowTransition);
let tempSlideshowAutoplay = $state($slideshowAutoplay); let tempSlideshowAutoplay = $state($slideshowAutoplay);
let tempSlideshowRepeat = $state($slideshowRepeat); let tempSlideshowRepeat = $state($slideshowRepeat);
let tempKenBurnsEffect = $state($kenBurnsEffect);
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = { const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') }, [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@@ -70,6 +72,7 @@
$slideshowTransition = tempSlideshowTransition; $slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay; $slideshowAutoplay = tempSlideshowAutoplay;
$slideshowRepeat = tempSlideshowRepeat; $slideshowRepeat = tempSlideshowRepeat;
$kenBurnsEffect = tempKenBurnsEffect;
$slideshowState = SlideshowState.PlaySlideshow; $slideshowState = SlideshowState.PlaySlideshow;
onClose(); onClose();
}; };
@@ -107,6 +110,10 @@
<Switch bind:checked={tempSlideshowTransition} /> <Switch bind:checked={tempSlideshowTransition} />
</Field> </Field>
<Field label={$t('slideshow_ken_burns_effect')}>
<Switch bind:checked={tempKenBurnsEffect} />
</Field>
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}> <Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
<Switch bind:checked={tempSlideshowRepeat} /> <Switch bind:checked={tempSlideshowRepeat} />
</Field> </Field>
+1 -1
View File
@@ -273,7 +273,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
} }
return true; return true;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_delete_album')); handleError(error, $t('errors.unable_to_delete_album'), { notify });
return false; return false;
} }
}; };
@@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const invisible = writable<boolean>(false);
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
setAsset, setAsset,
setAssetId, setAssetId,
showAssetViewer, showAssetViewer,
invisible,
}; };
} }
+1
View File
@@ -1 +1,2 @@
export const isFaceEditMode = $state({ value: false }); export const isFaceEditMode = $state({ value: false });
export const isEditFacesPanelOpen = $state({ value: false });
+2
View File
@@ -41,6 +41,7 @@ function createSlideshowStore() {
const slideshowTransition = persisted<boolean>('slideshow-transition', true); const slideshowTransition = persisted<boolean>('slideshow-transition', true);
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {}); const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false); const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
return { return {
restartProgress: { restartProgress: {
@@ -73,6 +74,7 @@ function createSlideshowStore() {
slideshowTransition, slideshowTransition,
slideshowAutoplay, slideshowAutoplay,
slideshowRepeat, slideshowRepeat,
kenBurnsEffect,
}; };
} }
+25 -2
View File
@@ -78,17 +78,40 @@ export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}; };
let unsubscribeId = 0;
const uploads: Record<number, () => void> = {};
const trackUpload = (unsubscribe: () => void) => {
const id = unsubscribeId++;
uploads[id] = unsubscribe;
return () => {
delete uploads[id];
};
};
export const cancelUploadRequests = () => {
for (const unsubscribe of Object.values(uploads)) {
unsubscribe();
}
};
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => { export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options; const { onUploadProgress: onProgress, data, url } = options;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const unsubscribe = trackUpload(() => xhr.abort());
xhr.addEventListener('error', (error) => {
unsubscribe();
reject(error);
});
xhr.addEventListener('error', (error) => reject(error));
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) { if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
unsubscribe();
resolve({ data: xhr.response as T, status: xhr.status }); resolve({ data: xhr.response as T, status: xhr.status });
} else { } else {
unsubscribe();
reject(new ApiError(xhr.statusText, xhr.status, xhr.response)); reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
} }
}); });
@@ -43,6 +43,50 @@ export class BaseEventManager<Events extends EventsBase> {
}; };
} }
once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
unsubscribe();
return callback(...args);
});
return unsubscribe;
}
untilNext<T extends keyof Events>(
event: T,
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<Events[T] extends [] ? void : Events[T][0]> {
type Result = Events[T] extends [] ? void : Events[T][0];
return new Promise<Result>((resolve, reject) => {
let settled = false;
const settle = () => {
if (settled) {
return false;
}
settled = true;
unsubscribe();
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
return true;
};
const unsubscribe = this.once(event, (...args: Events[T]) => {
if (settle()) {
resolve(args[0] as Result);
}
});
const timer = setTimeout(() => {
if (settle()) {
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
const onAbort = () => {
if (settle()) {
resolve(undefined as Result);
}
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
emit<T extends keyof Events>(event: T, ...params: Events[T]) { emit<T extends keyof Events>(event: T, ...params: Events[T]) {
const listeners = this.getListeners(event); const listeners = this.getListeners(event);
for (const listener of listeners) { for (const listener of listeners) {
+13
View File
@@ -193,6 +193,13 @@ describe('mapNormalizedToContent', () => {
expect(mapNormalizedToContent({ x: 0, y: 0 }, letterboxed)).toEqual({ x: 250, y: 0 }); expect(mapNormalizedToContent({ x: 0, y: 0 }, letterboxed)).toEqual({ x: 250, y: 0 });
expect(mapNormalizedToContent({ x: 1, y: 1 }, letterboxed)).toEqual({ x: 550, y: 600 }); expect(mapNormalizedToContent({ x: 1, y: 1 }, letterboxed)).toEqual({ x: 550, y: 600 });
}); });
it('should accept Size (zero offsets)', () => {
const size = { width: 800, height: 400 };
expect(mapNormalizedToContent({ x: 0, y: 0 }, size)).toEqual({ x: 0, y: 0 });
expect(mapNormalizedToContent({ x: 1, y: 1 }, size)).toEqual({ x: 800, y: 400 });
expect(mapNormalizedToContent({ x: 0.5, y: 0.5 }, size)).toEqual({ x: 400, y: 200 });
});
}); });
describe('mapContentToNatural', () => { describe('mapContentToNatural', () => {
@@ -238,6 +245,12 @@ describe('mapNormalizedRectToContent', () => {
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, letterboxed); const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, letterboxed);
expect(rect).toEqual({ left: 250, top: 0, width: 300, height: 600 }); expect(rect).toEqual({ left: 250, top: 0, width: 300, height: 600 });
}); });
it('should accept Size (zero offsets)', () => {
const size = { width: 800, height: 400 };
const rect = mapNormalizedRectToContent({ x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 }, size);
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
});
}); });
describe('mapContentRectToNatural', () => { describe('mapContentRectToNatural', () => {
+24 -14
View File
@@ -12,22 +12,22 @@
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative // "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords. // to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
export interface Point { export type Point = {
x: number; x: number;
y: number; y: number;
} };
export interface Size { export type Size = {
width: number; width: number;
height: number; height: number;
} };
export interface ContentMetrics { export type ContentMetrics = {
contentWidth: number; contentWidth: number;
contentHeight: number; contentHeight: number;
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
} };
export const scaleToCover = (dimensions: Size, container: Size): Size => { export const scaleToCover = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width; const scaleX = container.width / dimensions.width;
@@ -83,10 +83,16 @@ export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement):
return computeContentMetrics(natural, client); return computeContentMetrics(natural, client);
}; };
export function mapNormalizedToContent(point: Point, metrics: ContentMetrics): Point { export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
return { return {
x: point.x * metrics.contentWidth + metrics.offsetX, x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
y: point.y * metrics.contentHeight + metrics.offsetY, y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
};
}
return {
x: point.x * sizeOrMetrics.width,
y: point.y * sizeOrMetrics.height,
}; };
} }
@@ -97,16 +103,20 @@ export function mapContentToNatural(point: Point, metrics: ContentMetrics, natur
}; };
} }
export interface Rect { export type Rect = {
top: number; top: number;
left: number; left: number;
width: number; width: number;
height: number; height: number;
} };
export function mapNormalizedRectToContent(topLeft: Point, bottomRight: Point, metrics: ContentMetrics): Rect { export function mapNormalizedRectToContent(
const tl = mapNormalizedToContent(topLeft, metrics); topLeft: Point,
const br = mapNormalizedToContent(bottomRight, metrics); bottomRight: Point,
sizeOrMetrics: Size | ContentMetrics,
): Rect {
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
return { return {
top: tl.y, top: tl.y,
left: tl.x, left: tl.x,
+1 -1
View File
@@ -216,7 +216,7 @@ async function fileUploader({
uploadAssetsStore.track('success'); uploadAssetsStore.track('success');
} }
if (albumId) { if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });

Some files were not shown because too many files have changed in this diff Show More