Compare commits

...

26 Commits

Author SHA1 Message Date
Alex Tran c509e5dc73 chore: owner role for album_user 2026-04-02 14:27:40 +00:00
Min Idzelis 2166f07b1f refactor(web): rename DayGroup to TimelineDay (#27446)
Rename DayGroup class to TimelineDay to better convey that it represents
a single day within the timeline. Updates the file, class, and all
references across 13 files.

Change-Id: I9faef9bad73cd5b11f40daaf5eb140dd6a6a6964
2026-04-01 19:30:54 -04:00
Min Idzelis c9e251c78c feat(web): highlight active person thumbnail in detail panel and edit faces panel (#27401)
- Dim non-hovered person thumbnails to 40% opacity when any face is active
- Add ring highlight on the active person's thumbnail
- Add focus-visible outline styling for keyboard navigation
- Apply same treatment to both detail panel people section and edit faces side panel

Change-Id: I4ac10fe4568b95f3e0e8d9104133180f6a6a6964

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-04-01 10:49:09 -05:00
Mees Frensel da4b88fc14 fix(web): transition bg and border-radius (#27438)
* fix(web): transition bg and border-radius

* also transition thumb
2026-04-01 09:34:49 -05:00
okxint d1e2e8ab4e fix(server): use substring matching for person name search (#26903) 2026-04-01 13:31:54 +00:00
Timon 2a619d3c10 fix(web): Enable stack selector in shared album view (#24641) 2026-04-01 15:19:14 +02:00
Brandon Wees c29493e3a0 fix: withFilePath select edited or unedited file (#27328)
* fix: withFilePath select edited or unedited file

* chore: test
2026-04-01 08:19:38 -04:00
renovate[bot] 4ef777d145 chore(deps): update dependency handlebars to v4.7.9 [security] (#27334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 08:17:58 -04:00
renovate[bot] 0b40f4fd76 chore(deps): update dependency happy-dom to v20.8.9 [security] (#27350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 08:16:45 -04:00
bo0tzz ecba4e2a62 chore: tee GITHUB_OUTPUT for debugging (#27378) 2026-04-01 08:15:43 -04:00
Michel Heusschen 4eb531197e fix(web): prevent AssetUpdate from adding unrelated timeline assets (#27369) 2026-04-01 08:14:28 -04:00
Alex 505a07a825 feat: add move to lock folder in folder view (#27384) 2026-04-01 08:10:39 -04:00
Robin Meese 548dbe8ad6 feat(docs): add keycloack example to oauth docs (#27425) 2026-04-01 13:39:36 +02:00
renovate[bot] 0c184940f4 chore(deps): update github-actions (#27416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 15:58:36 +00:00
Channing Bellamy be180fd9da fix: detection of WebM container (#24182) 2026-03-31 11:44:51 -04:00
renovate[bot] 859f58174e chore(deps): update node.js to v24.14.1 (#27412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:46:38 +02:00
renovate[bot] a6c7e76008 chore(deps): update grafana/grafana docker tag to v12.4.2 (#27411)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:39:33 +02:00
renovate[bot] 0ff94213e6 chore(deps): update dependency exiftool-vendored to v35.15.1 (#27415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 12:39:11 +02:00
Yaros 6b1dd6f680 fix(mobile): favorite button not updating state (#27271) 2026-03-30 21:24:56 -05:00
Min Idzelis 7d4286bbc5 fix(web): add drop shadow to asset viewer nav bar and prevent button shrinking (#27404)
- Add subtle drop shadow to the asset viewer nav bar for better visual
  separation from the image behind it
- Add drop shadow to the OCR text recognition button in the lower right
- Prevent nav bar action buttons from shrinking to nothing by adding
  *:shrink-0 to the flex container, with p-1/-m-1 to avoid clipping
  focus outlines

Change-Id: I61cdc0ec66a65cde1c95b40c2c5428006a6a6964
2026-03-30 19:22:10 -05:00
Min Idzelis 18201a26d9 feat(web): OCR overlay interactivity during zoom (#27039)
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
2026-03-30 19:19:53 -05:00
Daniel Dietzler a2e3635ac9 chore: use esm global import (#27408) 2026-03-31 00:22:07 +02:00
Min Idzelis ce346bf956 feat(web): dim photo outside hovered face bounding box (#27402)
When hovering over a detected face in the photo viewer, an SVG mask overlay
dims the rest of the image (40% black) while leaving the hovered face fully
visible. The overlay fades in/out smoothly via CSS opacity transition by
freezing the last highlighted box positions in state, preventing the overlay
from popping off instantly when the mouse leaves.

Change-Id: I07e2eb2b297820ec89812785fe7943846a6a6964
2026-03-30 16:16:38 -05:00
Mert a1a2939868 fix(mobile): low upload timeout on android (#27399)
fix timeout
2026-03-30 16:05:21 -05:00
renovate[bot] e8309585d6 fix(deps): update dependency nodemailer to v8 [security] (#27351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 19:44:39 +02:00
Jason Rasmussen 17d4941089 refactor: asset select manager (#27330) 2026-03-30 15:45:57 +01:00
112 changed files with 1537 additions and 712 deletions
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
close_template:
runs-on: ubuntu-latest
@@ -128,7 +128,7 @@ jobs:
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
echo "remaining=$REMAINING" | tee --append "$GITHUB_OUTPUT"
- name: Reopen PR
if: ${{ steps.check_labels.outputs.remaining == '0' }}
+3 -3
View File
@@ -114,7 +114,7 @@ jobs:
key: build-mobile-gradle-${{ runner.os }}-main
- name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -191,7 +191,7 @@ jobs:
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
uses: ruby/setup-ruby@c515ec17f69368147deb311832da000dd229d338 # v1.297.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@2a37bc82462349c03a533b8b608bebbaf57b3e60 # v0.0.33
uses: oasdiff/oasdiff-action/breaking@1f38ea5ea0b4a2e4e49901c3bcdf4386a05e9ea1 # v0.0.37
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/autobuild@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
+1 -1
View File
@@ -588,7 +588,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@0ca7a949e71ae44c8e688a51c5e7e93b2c87e295 # v2.22.0
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+1 -1
View File
@@ -68,6 +68,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.1-ubuntu@sha256:1a20dea76a2778773df17dbc365db86b1a4f2d57772b8590b6311038a3acb1db
image: grafana/grafana:12.4.2-ubuntu@sha256:78839fe49e1425c02416fa8072591533a72bd9598e563b54a07d78f9e27fb5d3
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+37
View File
@@ -14,6 +14,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
- [Authelia](https://www.authelia.com/integration/openid-connect/immich/)
- [Okta](https://www.okta.com/openid-connect/)
- [Google](https://developers.google.com/identity/openid-connect/openid-connect)
- [Keycloak](https://www.keycloak.org)
## Prerequisites
@@ -253,4 +254,40 @@ Configuration of OAuth in Immich System Settings
</details>
<details>
<summary>Keycloak Example</summary>
### Keycloak Example
Here's an example of OAuth configured for Keycloak:
Create your immich client on your Keycloak Realm.
<img src={require('./img/keycloak-general-settings.webp').default} width='100%' title="Keycloak Client general Settings" />
<img src={require('./img/keycloak-access-settings.webp').default} width='100%' title="Keycloak Client Access Settings" />
<img src={require('./img/keycloak-capability-config.webp').default} width='100%' title="Keycloak Client Capability Configuration" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ----------------------------------------------------- |
| Issuer URL | `https://<KEYCLOAK_DOMAIN>/realms/<YOUR_REALM>` |
| Client ID | immich |
| Client Secret | can be optained from Clients -> immich -> Credentials |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Role Claim | immich_role |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Keycloak (recommended) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disabled |
| Mobile Redirect URI | |
Role Claim can be managed via Client Role. Remember to create a mapper with claim name `immich_role`.
</details>
[oidc]: https://openid.net/connect/
+1 -1
View File
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+1 -1
View File
@@ -58,6 +58,6 @@
"vitest": "^4.0.0"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+55
View File
@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};
@@ -0,0 +1,300 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const PRIMARY_OCR_BOXES = [
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
];
const SECONDARY_OCR_BOXES = [
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
];
test.describe('OCR bounding boxes', () => {
const fixture = setupAssetViewerFixture(920);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
});
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
});
});
test.describe('OCR with stacked assets', () => {
const fixture = setupAssetViewerFixture(921);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
await expect(ocrButton).toBeVisible();
await ocrButton.click();
await expect(ocrBoxes).toHaveCount(1);
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
});
});
test.describe('OCR boxes and zoom', () => {
const fixture = setupAssetViewerFixture(922);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR boxes scale with zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const initialBox = await ocrBox.boundingBox();
expect(initialBox).toBeTruthy();
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -3);
await expect(async () => {
const zoomedBox = await ocrBox.boundingBox();
expect(zoomedBox).toBeTruthy();
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
}).toPass({ timeout: 2000 });
});
});
test.describe('OCR text interaction', () => {
const fixture = setupAssetViewerFixture(923);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
});
test('OCR text box receives focus on click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await ocrBox.click();
await expect(ocrBox).toBeFocused();
});
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const box = await ocrBox.boundingBox();
expect(box).toBeTruthy();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
await page.mouse.up();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const viewerContent = page.locator('[data-viewer-content]');
const viewerBox = await viewerContent.boundingBox();
expect(viewerBox).toBeTruthy();
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
await page.evaluate(
({ viewerCenterX, viewerCenterY, outsideY }) => {
const viewer = document.querySelector('[data-viewer-content]');
if (!viewer) {
return;
}
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
target: viewer,
clientX: x,
clientY: y,
});
};
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
const touchStartEvent = new TouchEvent('touchstart', {
touches: [insideTouch, outsideTouch],
targetTouches: [insideTouch],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
const touchMoveEvent = new TouchEvent('touchmove', {
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
changedTouches: [
createTouch(0, viewerCenterX, viewerCenterY - 30),
createTouch(1, viewerCenterX, outsideY + 30),
],
bubbles: true,
cancelable: true,
});
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
viewer.dispatchEvent(touchStartEvent);
viewer.dispatchEvent(touchMoveEvent);
viewer.dispatchEvent(touchEndEvent);
},
{
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
outsideY: 10, // near the top of the page, outside the viewer
},
);
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
});
+1 -1
View File
@@ -14,7 +14,7 @@ config_roots = [
]
[tools]
node = "24.14.0"
node = "24.14.1"
flutter = "3.35.7"
pnpm = "10.32.1"
terragrunt = "0.99.4"
@@ -22,7 +22,14 @@ class NetworkRepository {
final session = URLSession.fromRawPointer(clientPointer.cast());
_client = CupertinoClient.fromSharedSession(session);
} else {
_client = OkHttpClient.fromJniGlobalRef(clientPointer);
_client = OkHttpClient.fromJniGlobalRef(
clientPointer,
configuration: const OkHttpClientConfiguration(
connectTimeout: Duration(seconds: 30),
readTimeout: Duration(seconds: 60),
writeTimeout: Duration(seconds: 60),
),
);
}
}
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -23,6 +25,12 @@ class FavoriteActionButton extends ConsumerWidget {
final result = await ref.read(actionProvider.notifier).favorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
}
}
return;
}
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -23,6 +25,12 @@ class UnFavoriteActionButton extends ConsumerWidget {
final result = await ref.read(actionProvider.notifier).unFavorite(source);
if (source == ActionSource.viewer) {
if (result.success) {
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
}
}
return;
}
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+1 -1
View File
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+54 -54
View File
@@ -67,7 +67,7 @@ importers:
version: 24.12.0
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -112,10 +112,10 @@ importers:
version: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 0.4.5(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
yaml:
specifier: ^2.3.1
version: 2.8.3
@@ -245,7 +245,7 @@ importers:
version: 63.0.0(eslint@10.1.0(jiti@2.6.1))
exiftool-vendored:
specifier: ^35.0.0
version: 35.13.1
version: 35.15.1
globals:
specifier: ^17.0.0
version: 17.4.0
@@ -287,7 +287,7 @@ importers:
version: 6.1.1(typescript@5.9.3)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
e2e-auth-server:
devDependencies:
@@ -453,7 +453,7 @@ importers:
version: 4.4.0
exiftool-vendored:
specifier: ^35.0.0
version: 35.13.1
version: 35.15.1
express:
specifier: ^5.1.0
version: 5.2.1
@@ -468,7 +468,7 @@ importers:
version: 8.1.6
handlebars:
specifier: ^4.7.8
version: 4.7.8
version: 4.7.9
helmet:
specifier: ^8.1.0
version: 8.1.0
@@ -518,8 +518,8 @@ importers:
specifier: ^7.0.0
version: 7.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
nodemailer:
specifier: ^7.0.0
version: 7.0.13
specifier: ^8.0.0
version: 8.0.4
openid-client:
specifier: ^6.3.3
version: 6.8.2
@@ -676,7 +676,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.4)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.1.0(jiti@2.6.1)
@@ -733,7 +733,7 @@ importers:
version: 6.1.1(typescript@5.9.3)(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.4)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -796,10 +796,10 @@ importers:
version: 0.5.0
handlebars:
specifier: ^4.7.8
version: 4.7.8
version: 4.7.9
happy-dom:
specifier: ^20.0.0
version: 20.8.4
version: 20.8.9
intl-messageformat:
specifier: ^11.0.0
version: 11.2.0
@@ -893,7 +893,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 5.3.1(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -917,7 +917,7 @@ importers:
version: 1.5.6
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))
dotenv:
specifier: ^17.0.0
version: 17.3.1
@@ -980,7 +980,7 @@ importers:
version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -7329,17 +7329,17 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exiftool-vendored.exe@13.52.0:
resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==}
exiftool-vendored.exe@13.53.0:
resolution: {integrity: sha512-CX8w1iVDOdt6iitqoOmUCWLYVmfBVmd59htXGpns/+CItu8LBAT9qVHdBP+Jl0abZyCcDrZf0eaLsfXb9mZOcQ==}
os: [win32]
exiftool-vendored.pl@13.52.0:
resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==}
exiftool-vendored.pl@13.53.0:
resolution: {integrity: sha512-D/3yJymCPeMQPtQA9Q8ou/+vvEeQcTjrNt2jT7GS2A9tE0s0NiMNVc62HaKdwm5reQXQRbPrnp56sNxWpNCHKA==}
os: ['!win32']
hasBin: true
exiftool-vendored@35.13.1:
resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==}
exiftool-vendored@35.15.1:
resolution: {integrity: sha512-ox+pcW9m52MGeXMMuZjbdaKgeha9WmWPE7HhVw6GNZ607a9Hx2HyiAUDQm+XdAzv6Y34sahLReCeJRmS9F70Ww==}
engines: {node: '>=20.0.0'}
expect-type@1.3.0:
@@ -7754,13 +7754,13 @@ packages:
handle-thing@2.0.1:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
handlebars@4.7.9:
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
engines: {node: '>=0.4.7'}
hasBin: true
happy-dom@20.8.4:
resolution: {integrity: sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==}
happy-dom@20.8.9:
resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
engines: {node: '>=20.0.0'}
has-flag@4.0.0:
@@ -9391,8 +9391,8 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@7.0.13:
resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==}
nodemailer@8.0.4:
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
engines: {node: '>=6.0.0'}
nopt@1.0.10:
@@ -16830,14 +16830,14 @@ snapshots:
dependencies:
svelte: 5.54.1
'@testing-library/svelte@5.3.1(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@testing-library/svelte@5.3.1(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.54.1)
svelte: 5.54.1
optionalDependencies:
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
dependencies:
@@ -17536,7 +17536,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.4)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -17551,11 +17551,11 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.4)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0
@@ -17567,9 +17567,9 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0
@@ -17581,7 +17581,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -19855,21 +19855,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.52.0:
exiftool-vendored.exe@13.53.0:
optional: true
exiftool-vendored.pl@13.52.0: {}
exiftool-vendored.pl@13.53.0: {}
exiftool-vendored@35.13.1:
exiftool-vendored@35.15.1:
dependencies:
'@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.52.0
exiftool-vendored.pl: 13.53.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.52.0
exiftool-vendored.exe: 13.53.0
expect-type@1.3.0: {}
@@ -20383,7 +20383,7 @@ snapshots:
handle-thing@2.0.1: {}
handlebars@4.7.8:
handlebars@4.7.9:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
@@ -20392,7 +20392,7 @@ snapshots:
optionalDependencies:
uglify-js: 3.19.3
happy-dom@20.8.4:
happy-dom@20.8.9:
dependencies:
'@types/node': 24.12.0
'@types/whatwg-mimetype': 3.0.2
@@ -22445,7 +22445,7 @@ snapshots:
node-releases@2.0.27: {}
nodemailer@7.0.13: {}
nodemailer@8.0.4: {}
nopt@1.0.10:
dependencies:
@@ -25547,11 +25547,11 @@ snapshots:
optionalDependencies:
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)
vitest-fetch-mock@0.4.5(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))):
vitest-fetch-mock@0.4.5(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.4)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -25579,7 +25579,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.0
happy-dom: 20.8.4
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- jiti
@@ -25595,7 +25595,7 @@ snapshots:
- tsx
- yaml
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -25620,12 +25620,12 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.12.0
happy-dom: 20.8.4
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
- msw
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -25650,12 +25650,12 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.12.0
happy-dom: 20.8.4
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.4)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(happy-dom@20.8.9)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -25680,7 +25680,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.5.0
happy-dom: 20.8.4
happy-dom: 20.8.9
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:
- msw
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+2 -2
View File
@@ -98,7 +98,7 @@
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nodemailer": "^7.0.0",
"nodemailer": "^8.0.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
@@ -174,7 +174,7 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
},
"overrides": {
"sharp": "^0.34.5"
+1 -1
View File
@@ -230,7 +230,7 @@ export const mapAlbum = (
const assets = entity.assets || [];
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0;
const hasSharedUser = albumUsers.some(({ role }) => role !== AlbumUserRole.Owner);
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
+1
View File
@@ -56,6 +56,7 @@ export enum AssetFileType {
export enum AlbumUserRole {
Editor = 'editor',
Viewer = 'viewer',
Owner = 'owner',
}
export enum AssetOrder {
+3 -2
View File
@@ -437,12 +437,13 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = false
) as "previewFile"
from
"asset"
where
"asset"."id" = $2
"asset"."id" = $1
-- AssetJobRepository.getForSyncAssets
select
+4 -3
View File
@@ -637,13 +637,14 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."type" = 'encoded_video'
and "asset_file"."isEdited" = false
) as "encodedVideoPath"
from
"asset"
where
"asset"."id" = $2
and "asset"."type" = $3
"asset"."id" = $1
and "asset"."type" = $2
-- AssetRepository.getForOcr
select
+6 -9
View File
@@ -176,7 +176,7 @@ select
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = $1
and "asset_file"."isEdited" = false
) as "previewPath"
from
"person"
@@ -184,7 +184,7 @@ from
inner join "asset" on "asset_face"."assetId" = "asset"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"person"."id" = $2
"person"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.reassignFace
@@ -200,13 +200,10 @@ select
from
"person"
where
(
"person"."ownerId" = $1
and (
lower("person"."name") like $2
or lower("person"."name") like $3
)
)
"person"."ownerId" = $1
and f_unaccent ("person"."name") %>> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
$4
+3 -1
View File
@@ -91,7 +91,9 @@ class AlbumAccess {
}
const accessRole =
access === AlbumUserRole.Editor ? [AlbumUserRole.Editor] : [AlbumUserRole.Editor, AlbumUserRole.Viewer];
access === AlbumUserRole.Editor
? [AlbumUserRole.Editor, AlbumUserRole.Owner]
: [AlbumUserRole.Editor, AlbumUserRole.Viewer, AlbumUserRole.Owner];
return this.db
.selectFrom('album')
+32 -10
View File
@@ -14,6 +14,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -213,12 +214,25 @@ export class AlbumRepository {
.selectAll('album')
.where((eb) =>
eb.or([
// Albums I own that have non-owner shared users
eb.and([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '!=', AlbumUserRole.Owner),
),
]),
// Albums shared with me (I'm in album_user but not the owner)
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
.where('album_user.userId', '=', ownerId)
.where('album_user.role', '!=', AlbumUserRole.Owner),
),
// Albums with shared links
eb.exists(
eb
.selectFrom('shared_link')
@@ -245,7 +259,16 @@ export class AlbumRepository {
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '!=', AlbumUserRole.Owner),
),
),
)
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
.orderBy('album.createdAt', 'desc')
@@ -322,14 +345,13 @@ export class AlbumRepository {
await this.addAssets(tx, newAlbum.id, assetIds);
}
if (albumUsers.length > 0) {
await tx
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
)
.execute();
}
// Always insert the owner with Owner role in album_user
const allAlbumUsers = [
{ albumId: newAlbum.id, userId: album.ownerId, role: AlbumUserRole.Owner },
...albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
];
await tx.insertInto('album_user').values(allAlbumUsers).execute();
return tx
.selectFrom('album')
+18 -10
View File
@@ -1,5 +1,19 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
import {
allowInsecureRequests,
authorizationCodeGrant,
buildAuthorizationUrl,
calculatePKCECodeChallenge,
ClientSecretBasic,
ClientSecretPost,
discovery,
fetchUserInfo,
None,
randomPKCECodeVerifier,
randomState,
skipSubjectCheck,
type UserInfoResponse,
} from 'openid-client';
import { OAuthTokenEndpointAuthMethod } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -24,8 +38,6 @@ export class OAuthRepository {
}
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } =
await import('openid-client');
const client = await this.getClient(config);
state ??= randomState();
@@ -64,7 +76,6 @@ export class OAuthRepository {
expectedState: string,
codeVerifier: string,
): Promise<OAuthProfile> {
const { authorizationCodeGrant, fetchUserInfo, ...oidc } = await import('openid-client');
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -77,7 +88,7 @@ export class OAuthRepository {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
} else {
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
}
if (!profile.sub) {
@@ -124,7 +135,6 @@ export class OAuthRepository {
timeout,
}: OAuthConfig) {
try {
const { allowInsecureRequests, discovery } = await import('openid-client');
return await discovery(
new URL(issuerUrl),
clientId,
@@ -134,7 +144,7 @@ export class OAuthRepository {
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm,
},
await this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
{
execute: [allowInsecureRequests],
timeout,
@@ -146,9 +156,7 @@ export class OAuthRepository {
}
}
private async getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
const { None, ClientSecretPost, ClientSecretBasic } = await import('openid-client');
private getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
if (!clientSecret) {
return None();
}
+6 -20
View File
@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { removeUndefinedKeys } from 'src/utils/database';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@@ -282,15 +282,7 @@ export class PersonRepository {
'asset.originalPath',
'asset_exif.orientation as exifOrientation',
])
.select((eb) =>
eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(AssetFileType.Preview))
.where('asset_file.isEdited', '=', false)
.as('previewPath'),
)
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
.where('person.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
@@ -320,16 +312,10 @@ export class PersonRepository {
return this.db
.selectFrom('person')
.selectAll('person')
.where((eb) =>
eb.and([
eb('person.ownerId', '=', userId),
eb.or([
eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`),
eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`),
]),
]),
)
.limit(1000)
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %>> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
}
-3
View File
@@ -125,9 +125,6 @@ export const album_delete_audit = registerFunction({
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "idx_person_name_trigram" ON "person" USING gin (f_unaccent("name") gin_trgm_ops);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_idx_person_name_trigram', '{"type":"index","name":"idx_person_name_trigram","sql":"CREATE INDEX \\"idx_person_name_trigram\\" ON \\"person\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "idx_person_name_trigram";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_idx_person_name_trigram';`.execute(db);
}
@@ -0,0 +1,44 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Backfill: insert album owner into album_user with 'owner' role for all existing albums
await sql`
INSERT INTO "album_user" ("albumId", "userId", "role")
SELECT "id", "ownerId", 'owner' FROM "album"
ON CONFLICT ("albumId", "userId") DO NOTHING
`.execute(db);
// Make album_delete_audit a no-op since the owner is now in album_user
// and the CASCADE delete will trigger album_user_delete_audit instead
await sql`
CREATE OR REPLACE FUNCTION album_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL AS $$
BEGIN
RETURN NULL;
END $$
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
// Restore album_delete_audit to insert owner audit entries
await sql`
CREATE OR REPLACE FUNCTION album_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL AS $$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END $$
`.execute(db);
// Remove owner entries from album_user
await sql`
DELETE FROM "album_user"
WHERE ("albumId", "userId") IN (
SELECT "id", "ownerId" FROM "album"
)
`.execute(db);
}
+6
View File
@@ -5,6 +5,7 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
@@ -16,6 +17,11 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@Index({
name: 'idx_person_name_trigram',
using: 'gin',
expression: 'f_unaccent("name") gin_trgm_ops',
})
@UpdatedAtTrigger('person_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
+24 -10
View File
@@ -283,16 +283,19 @@ describe(AlbumService.name, () => {
);
});
it('should throw an error if the userId is the ownerId', async () => {
it('should silently filter the owner from albumUsers', async () => {
const album = AlbumFactory.create();
mocks.user.get.mockResolvedValue(album.owner);
await expect(
sut.create(AuthFactory.create(album.owner), {
albumName: 'Empty album',
albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.create).not.toHaveBeenCalled();
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.getMetadata.mockResolvedValue([]);
await sut.create(AuthFactory.create(album.owner), {
albumName: 'Empty album',
albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }],
});
expect(mocks.album.create).toHaveBeenCalledWith(
expect.objectContaining({ ownerId: album.owner.id }),
[],
[],
);
});
});
@@ -420,7 +423,7 @@ describe(AlbumService.name, () => {
sut.addUsers(AuthFactory.create(album.owner), album.id, {
albumUsers: [{ userId: album.owner.id }],
}),
).rejects.toBeInstanceOf(BadRequestException);
).rejects.toThrow('User already added');
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.user.get).not.toHaveBeenCalled();
});
@@ -537,6 +540,7 @@ describe(AlbumService.name, () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(album.owner), album.id, user.id, { role: AlbumUserRole.Viewer });
@@ -546,6 +550,16 @@ describe(AlbumService.name, () => {
{ role: AlbumUserRole.Viewer },
);
});
it('should not allow changing the album owner role', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.updateUser(AuthFactory.create(album.owner), album.id, album.owner.id, { role: AlbumUserRole.Viewer }),
).rejects.toThrow('Cannot change album owner role');
});
});
describe('getAlbumInfo', () => {
+30 -23
View File
@@ -17,7 +17,7 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -80,7 +80,8 @@ export class AlbumService extends BaseService {
const album = await this.findOrFail(id, { withAssets });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
const hasSharedUsers =
album.albumUsers && album.albumUsers.some(({ role }) => role !== AlbumUserRole.Owner);
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
const isShared = hasSharedUsers || hasSharedLink;
@@ -98,16 +99,19 @@ export class AlbumService extends BaseService {
const albumUsers = dto.albumUsers || [];
for (const { userId } of albumUsers) {
if (userId == auth.user.id) {
continue;
}
const exists = await this.userRepository.get(userId, {});
if (!exists) {
throw new BadRequestException('User not found');
}
if (userId == auth.user.id) {
throw new BadRequestException('Cannot share album with owner');
}
}
// Filter out the owner from albumUsers since they are always added by the repository
const filteredAlbumUsers = albumUsers.filter(({ userId }) => userId !== auth.user.id);
const allowedAssetIdsSet = await this.checkAccess({
auth,
permission: Permission.AssetShare,
@@ -126,10 +130,10 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
albumUsers,
filteredAlbumUsers,
);
for (const { userId } of albumUsers) {
for (const { userId } of filteredAlbumUsers) {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
}
@@ -188,9 +192,9 @@ export class AlbumService extends BaseService {
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
const allUsersExceptUs = album.albumUsers
.map(({ user }) => user.id)
.filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
@@ -248,9 +252,9 @@ export class AlbumService extends BaseService {
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
const allUsersExceptUs = album.albumUsers
.map(({ user }) => user.id)
.filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
}
@@ -288,10 +292,6 @@ export class AlbumService extends BaseService {
const album = await this.findOrFail(id, { withAssets: false });
for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (exists) {
throw new BadRequestException('User already added');
@@ -316,13 +316,13 @@ export class AlbumService extends BaseService {
const album = await this.findOrFail(id, { withAssets: false });
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
const albumUser = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!albumUser) {
throw new BadRequestException('Album not shared with user');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
if (albumUser.role === AlbumUserRole.Owner) {
throw new BadRequestException('Cannot remove album owner');
}
// non-admin can remove themselves
@@ -335,6 +335,13 @@ export class AlbumService extends BaseService {
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const albumUser = album.albumUsers.find(({ user: { id } }) => id === userId);
if (albumUser?.role === AlbumUserRole.Owner) {
throw new BadRequestException('Cannot change album owner role');
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
+7 -1
View File
@@ -756,7 +756,13 @@ export class MediaService extends BaseService {
return false;
}
const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.Mov : (formatName as VideoContainer);
const formatLongNameMapping: Record<string, VideoContainer> = {
'QuickTime / MOV': VideoContainer.Mov,
'Matroska / WebM': VideoContainer.Webm,
};
const name = (formatLongName ? formatLongNameMapping[formatLongName] : undefined) ?? (formatName as VideoContainer);
return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name);
}
+3 -2
View File
@@ -126,12 +126,13 @@ export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileTy
).as('files');
}
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
return eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', type);
.where('asset_file.type', '=', sql.lit(type))
.where('asset_file.isEdited', '=', sql.lit(isEdited));
}
export function withFacesAndPeople(
+12 -3
View File
@@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumUserFactory } from 'test/factories/album-user.factory';
@@ -76,11 +76,20 @@ export class AlbumFactory {
}
build() {
const owner = this.#owner.build();
const ownerAlbumUser = AlbumUserFactory.from({
albumId: this.value.id,
userId: owner.id,
role: AlbumUserRole.Owner,
})
.user(owner)
.build();
return {
...this.value,
owner: this.#owner.build(),
owner,
assets: this.#assets.map((asset) => asset.build()),
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
albumUsers: [ownerAlbumUser, ...this.#albumUsers.map((albumUser) => albumUser.build())],
sharedLinks: this.#sharedLinks,
};
}
@@ -115,4 +115,33 @@ describe(AssetJobRepository.name, () => {
);
});
});
describe('getForOcr', () => {
it('should not return the edited preview file', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_edited.jpg',
isEdited: true,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_unedited.jpg',
isEdited: false,
});
const result = await sut.getForOcr(asset.id);
expect(result).toEqual(
expect.objectContaining({
previewFile: 'preview_unedited.jpg',
}),
);
});
});
});
@@ -25,6 +25,15 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
userId: auth.user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -47,6 +56,10 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({
data: expect.objectContaining({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Owner }),
type: SyncEntityType.AlbumUserV1,
}),
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -136,6 +149,10 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({
data: expect.objectContaining({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Owner }),
type: SyncEntityType.AlbumUserV1,
}),
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -163,6 +180,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -201,6 +219,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -233,8 +252,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const { user: user2 } = await ctx.newUser();
const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id });
// backfill album user
await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.Editor });
// owner (user1) is already in album_user from album creation
await wait(2);
// initial album user
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
@@ -244,6 +262,10 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({
data: expect.objectContaining({ albumId: album2.id, userId: user1.id, role: AlbumUserRole.Owner }),
type: SyncEntityType.AlbumUserV1,
}),
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -261,14 +283,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
// get access to the backfill album user
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
// should backfill the album user
// should backfill the album user (owner user1 is already there from album creation)
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Editor,
role: AlbumUserRole.Owner,
userId: user1.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
+1 -1
View File
@@ -1 +1 @@
24.14.0
24.14.1
+1 -1
View File
@@ -110,6 +110,6 @@
"vitest": "^4.0.0"
},
"volta": {
"node": "24.14.0"
"node": "24.14.1"
}
}
+118 -28
View File
@@ -1,11 +1,18 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
type TouchEventLike = {
touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
targetTouches: ArrayLike<unknown>;
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: null,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
@@ -13,47 +20,130 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const onInteractionStart = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
const isOverlayEvent = (event: Event) => !!(event.target as HTMLElement).closest('[data-overlay-interactive]');
const isOverlayAtPoint = (x: number, y: number) =>
!!document.elementFromPoint(x, y)?.closest('[data-overlay-interactive]');
// Pointer event interception: track pointers that start on overlays and intercept the entire gesture.
const overlayPointers = new Set<number>();
const interceptedPointers = new Set<number>();
const interceptOverlayPointerDown = (event: PointerEvent) => {
if (isOverlayEvent(event) || isOverlayAtPoint(event.clientX, event.clientY)) {
overlayPointers.add(event.pointerId);
interceptedPointers.add(event.pointerId);
event.stopPropagation();
} else if (overlayPointers.size > 0) {
// Split gesture (e.g. pinch with one finger on overlay) — intercept entirely.
interceptedPointers.add(event.pointerId);
event.stopPropagation();
}
assetViewerManager.cancelZoomAnimation();
};
const interceptOverlayPointerEvent = (event: PointerEvent) => {
if (interceptedPointers.has(event.pointerId)) {
event.stopPropagation();
}
};
const interceptOverlayPointerEnd = (event: PointerEvent) => {
overlayPointers.delete(event.pointerId);
if (interceptedPointers.delete(event.pointerId)) {
event.stopPropagation();
}
};
node.addEventListener('pointerdown', interceptOverlayPointerDown, { capture: true, signal });
node.addEventListener('pointermove', interceptOverlayPointerEvent, { capture: true, signal });
node.addEventListener('pointerup', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('pointerleave', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('wheel', onInteractionStart, { capture: true });
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
// Once intercepted, stays intercepted until all fingers are lifted.
let touchGestureIntercepted = false;
const interceptOverlayTouchEvent = (event: Event) => {
if (touchGestureIntercepted) {
event.stopPropagation();
return;
}
const { touches, targetTouches } = asTouchEvent(event);
if (touches && targetTouches) {
if (touches.length > targetTouches.length) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
for (const touch of touches) {
if (isOverlayAtPoint(touch.clientX, touch.clientY)) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
}
} else if (isOverlayEvent(event)) {
event.stopPropagation();
}
};
const resetTouchGesture = (event: Event) => {
const { touches } = asTouchEvent(event);
if (touches.length === 0) {
touchGestureIntercepted = false;
}
};
node.addEventListener('touchstart', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchmove', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchend', resetTouchGesture, { capture: true, signal });
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
// Wheel and dblclick interception on overlay elements.
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
// on double-tap, which conflicts with zoom-image's touch zoom handler).
let lastPointerWasTouch = false;
const trackPointerType = (event: PointerEvent) => {
lastPointerWasTouch = event.pointerType === 'touch';
};
const suppressTouchDblClick = (event: MouseEvent) => {
if (lastPointerWasTouch) {
event.stopImmediatePropagation();
}
};
node.addEventListener('pointerdown', trackPointerType, { capture: true });
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
capture: true,
signal,
});
node.addEventListener(
'wheel',
(event) => {
if (isOverlayEvent(event)) {
event.stopPropagation();
}
},
{ capture: true, signal },
);
node.addEventListener(
'dblclick',
(event) => {
if (lastPointerWasTouch || isOverlayEvent(event)) {
event.stopImmediatePropagation();
}
},
{ capture: true, signal },
);
// Allow zoomed content to render outside the container bounds
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = 'transform';
}
node.style.overflow = 'visible';
// Prevent browser handling of touch gestures so zoom-image can manage them
node.style.touchAction = 'none';
return {
update(newOptions?: { disabled?: boolean }) {
update(newOptions?: { zoomTarget?: HTMLElement }) {
options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
},
destroy() {
controller.abort();
if (options?.zoomTarget) {
options.zoomTarget.style.willChange = '';
}
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
node.removeEventListener('wheel', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
zoomInstance.cleanup();
},
};
+52 -70
View File
@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
@@ -17,10 +17,7 @@
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: {
width: number;
height: number;
};
container: Size;
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
@@ -149,81 +146,66 @@
(quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
});
const zoomTransform = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
return undefined;
}
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
});
</script>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()}
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
<div
class="absolute inset-0 pointer-events-none"
style:transform={zoomTransform}
style:transform-origin={zoomTransform ? '0 0' : undefined}
>
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
</div>
@@ -48,7 +48,7 @@
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
: timelineManager.months[0]?.timelineDays[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(
assetViewerManager.setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)),
@@ -104,13 +104,16 @@
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)]"
>
<div class="dark">
<ActionButton action={Close} />
</div>
<div class="flex items-center gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<div
class="flex p-1 -m-1 items-center gap-2 overflow-x-auto *:shrink-0 dark"
data-testid="asset-viewer-navbar-actions"
>
{#if assetViewerManager.isImageLoading}
<Tooltip text={$t('loading')}>
{#snippet child({ props })}
@@ -564,7 +564,7 @@
{/if}
{#if showOcrButton}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<div class="absolute bottom-0 end-0 mb-6 me-6 drop-shadow-[0_0_1px_rgba(0,0,0,0.4)]">
<OcrButton />
</div>
{/if}
@@ -227,8 +227,9 @@
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="w-22"
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
@@ -245,6 +246,8 @@
widthStyle="90px"
heightStyle="90px"
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>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
@@ -12,12 +12,12 @@
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
assetId: string;
}
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
@@ -304,6 +304,7 @@
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
@@ -1,4 +1,5 @@
<script lang="ts">
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
@@ -8,6 +9,7 @@
let { ocrBox }: Props = $props();
const isTouch = $derived(mediaQueryManager.pointerCoarse);
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
@@ -15,13 +17,23 @@
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
);
const handleSelectStart = (event: Event) => {
const target = event.currentTarget as HTMLElement;
requestAnimationFrame(() => {
const selection = globalThis.getSelection();
if (selection) {
selection.selectAllChildren(target);
}
});
};
const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) {
case 'cjk': {
return ' writing-mode: vertical-rl;';
return 'writing-mode: vertical-rl;';
}
case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;';
return 'writing-mode: vertical-rl; text-orientation: sideways;';
}
default: {
return '';
@@ -30,17 +42,23 @@
});
</script>
<div class="absolute left-0 top-0">
<div
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
'none'
? '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;{verticalStyle}"
tabindex="0"
role="button"
aria-label={ocrBox.text}
>
{ocrBox.text}
</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
? '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 === 'none' ? '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}"
data-testid="ocr-box"
data-overlay-interactive
tabindex="0"
role="button"
aria-label={ocrBox.text}
onselectstart={isTouch ? handleSelectStart : undefined}
>
{ocrBox.text}
</div>
@@ -128,10 +128,8 @@
}
const boxes = getOcrBoundingBoxes(ocrData, {
contentWidth: viewer.state.textureData.panoData.croppedWidth,
contentHeight: viewer.state.textureData.panoData.croppedHeight,
offsetX: 0,
offsetY: 0,
width: viewer.state.textureData.panoData.croppedWidth,
height: viewer.state.textureData.panoData.croppedHeight,
});
for (const [index, box] of boxes.entries()) {
@@ -13,10 +13,10 @@
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getBoundingBox, type BoundingBox } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
@@ -24,14 +24,14 @@
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
type Props = {
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
}
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
@@ -66,23 +66,27 @@
height: containerHeight,
});
const overlayMetrics = $derived.by((): ContentMetrics => {
const overlaySize = $derived.by((): Size => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
return { width: 0, height: 0 };
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
return {
contentWidth: scaled.width,
contentHeight: scaled.height,
offsetX: 0,
offsetY: 0,
};
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => {
if (isHighlighting) {
visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
}
});
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -150,6 +154,8 @@
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
@@ -180,7 +186,7 @@
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlayMetrics);
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
@@ -214,7 +220,7 @@
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ disabled: assetViewerManager.isFaceEditMode || ocrManager.showOverlay }}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
<AdaptiveImage
@@ -232,6 +238,7 @@
onReady?.();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}
>
{#snippet backdrop()}
{#if blurredSlideshow}
@@ -242,21 +249,37 @@
{/if}
{/snippet}
{#snippet overlays()}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get($boundingBoxesArray[index])}
<div
class="absolute inset-0 pointer-events-none transition-opacity duration-150"
style:opacity={isHighlighting ? 1 : 0}
>
<svg class="absolute inset-0 w-full h-full">
<defs>
<mask id="face-dim-mask">
<rect width="100%" height="100%" fill="white" />
{#each visibleBoxes as box (box.id)}
<rect x={box.left} y={box.top} width={box.width} height={box.height} fill="black" rx="8" />
{/each}
</mask>
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{faceToNameMap.get($boundingBoxesArray[index])}
</div>
{/if}
{/each}
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{faceToNameMap.get(visibleBoundingBoxes[index])}
</div>
{/if}
{/each}
</div>
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
@@ -16,6 +16,7 @@
circle?: boolean;
hidden?: boolean;
border?: boolean;
highlighted?: boolean;
hiddenIconClass?: string;
class?: ClassValue;
brokenAssetClass?: ClassValue;
@@ -34,6 +35,7 @@
circle = false,
hidden = false,
border = false,
highlighted = false,
hiddenIconClass = 'text-white',
onComplete = undefined,
class: imageClass = '',
@@ -60,6 +62,8 @@
shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square',
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
'transition-shadow duration-150',
highlighted && 'ring-4 ring-immich-primary dark:ring-immich-dark-primary',
]);
let style = $derived(
@@ -208,7 +208,11 @@
</script>
<div
class={['group focus-visible:outline-none flex overflow-hidden', backgroundColorClass, { 'rounded-xl': selected }]}
class={[
'group focus-visible:outline-none flex overflow-hidden transition-[background-color,border-radius]',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
@@ -248,8 +252,16 @@
]}
>
<ImageThumbnail
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
class={[
'absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
imageClass,
]}
brokenAssetClass={[
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
brokenAssetClass,
]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
@@ -225,6 +225,7 @@
{:else}
{#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
<div class="relative h-29 w-24">
<div
role="button"
@@ -239,6 +240,7 @@
<ImageThumbnail
curve
shadow
highlighted={isHighlighted}
url={selectedPersonToCreate[face.id]}
altText={$t('new_person')}
title={$t('new_person')}
@@ -249,6 +251,7 @@
<ImageThumbnail
curve
shadow
highlighted={isHighlighted}
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id])}
altText={selectedPersonToReassign[face.id].name}
title={$getPersonNameWithHiddenValue(
@@ -263,6 +266,7 @@
<ImageThumbnail
curve
shadow
highlighted={isHighlighted}
url={getPeopleThumbnailUrl(face.person)}
altText={face.person.name}
title={$getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
@@ -275,6 +279,7 @@
<ImageThumbnail
curve
shadow
highlighted={isHighlighted}
url="/src/lib/assets/no-thumbnail.png"
altText={$t('face_unassigned')}
title={$t('face_unassigned')}
@@ -285,6 +290,7 @@
<ImageThumbnail
curve
shadow
highlighted={isHighlighted}
url={data === null ? '/src/lib/assets/no-thumbnail.png' : data}
altText={$t('face_unassigned')}
title={$t('face_unassigned')}
@@ -337,7 +337,7 @@
{#if assetMultiSelectManager.selectionActive}
<div class="sticky top-0 z-1 dark">
<AssetSelectControlBar forceDark>
{@const Actions = getAssetBulkActions($t, assetMultiSelectManager.asControlContext())}
{@const Actions = getAssetBulkActions($t)}
<CreateSharedLink />
<IconButton
shape="round"
@@ -118,7 +118,7 @@
</aside>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t, assetMultiSelectManager.asControlContext())}
{@const Actions = getAssetBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
@@ -1,7 +1,6 @@
<script lang="ts">
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { setAssetControlContext } from '$lib/utils/context';
import { mdiClose } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -13,8 +12,6 @@
let { children, forceDark }: Props = $props();
setAssetControlContext(assetMultiSelectManager.asControlContext());
const onClose = () => assetMultiSelectManager.clear();
const assets = $derived(assetMultiSelectManager.assets);
+26 -25
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
@@ -19,7 +19,7 @@
{
asset: TimelineAsset;
position: CommonPosition;
dayGroup: DayGroup;
timelineDay: TimelineDay;
groupIndex: number;
},
]
@@ -29,7 +29,7 @@
assetInteraction: AssetMultiSelectManager;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
onTimelineDaySelect: (timelineDay: TimelineDay, assets: TimelineAsset[]) => void;
};
let {
thumbnail: thumbnailWithGroup,
@@ -38,27 +38,27 @@
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
onTimelineDaySelect,
}: Props = $props();
let { isUploading } = uploadAssetsStore;
let hoveredDayGroup = $state<string | null>(null);
let hoveredTimelineDay = $state<string | null>(null);
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const getTimelineDayFullDate = (timelineDay: TimelineDay): string => {
const { month, year } = timelineDay.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
day: timelineDay.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIsInOrNearViewport(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
{#each filterIsInOrNearViewport(monthGroup.timelineDays) as timelineDay, groupIndex (timelineDay.day)}
{@const isTimelineDaySelected = assetInteraction.selectedGroup.has(timelineDay.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
@@ -67,24 +67,25 @@
]}
data-group
style:position="absolute"
style:inset-inline-start={dayGroup.start + 'px'}
style:top={dayGroup.top + 'px'}
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)}
style:inset-inline-start={timelineDay.start + 'px'}
style:top={timelineDay.top + 'px'}
onmouseenter={() => (hoveredTimelineDay = timelineDay.groupTitle)}
onmouseleave={() => (hoveredTimelineDay = null)}
>
<!-- Day title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
style:width={timelineDay.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={hoveredDayGroup === dayGroup.groupTitle || assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
class:w-8={hoveredTimelineDay === timelineDay.groupTitle ||
assetInteraction.selectedGroup.has(timelineDay.groupTitle)}
onclick={() => onTimelineDaySelect(timelineDay, assetsSnapshot(timelineDay.getAssets()))}
onkeydown={() => onTimelineDaySelect(timelineDay, assetsSnapshot(timelineDay.getAssets()))}
>
{#if isDayGroupSelected}
{#if isTimelineDaySelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" class="text-light-500" />
@@ -92,20 +93,20 @@
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
<span class="w-full truncate first-letter:capitalize" title={getTimelineDayFullDate(timelineDay)}>
{timelineDay.groupTitle}
</span>
</div>
<AssetLayout
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
viewerAssets={timelineDay.viewerAssets}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex })}
{/snippet}
</AssetLayout>
</section>
+19 -17
View File
@@ -13,7 +13,7 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -52,7 +52,7 @@
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
timelineDay: TimelineDay,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
@@ -390,8 +390,8 @@
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
const handleGroupSelect = (timelineDay: TimelineDay, assets: TimelineAsset[]) => {
const group = timelineDay.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
@@ -468,12 +468,12 @@
const monthGroup = monthGroups[index];
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
for (const timelineDay of monthGroup.timelineDays) {
const timelineDayTitle = timelineDay.groupTitle;
if (timelineDay.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(timelineDayTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
assetInteraction.removeGroupFromMultiselectGroup(timelineDayTitle);
}
}
}
@@ -524,16 +524,18 @@
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
assetsInTimelineDay: TimelineAsset[],
groupTitle: string,
) => {
void onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter(({ id }) => assetInteraction.hasSelectedAsset(id)).length;
let selectedAssetsInGroupCount = assetsInTimelineDay.filter(({ id }) =>
assetInteraction.hasSelectedAsset(id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount === assetsInDayGroup.length) {
if (selectedAssetsInGroupCount === assetsInTimelineDay.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
@@ -668,9 +670,9 @@
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
@@ -683,14 +685,14 @@
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
return;
}
void onSelectAssets(asset);
@@ -1,18 +1,18 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { AssetVisibility } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
onArchive?: OnArchive;
menuItem?: boolean;
unarchive?: boolean;
}
};
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
@@ -21,16 +21,14 @@
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
const assets = assetMultiSelectManager.getOwnedAssets().filter((asset) => asset.visibility !== visibility);
loading = true;
const ids = await archiveAssets(assets, visibility as AssetVisibility);
if (ids) {
onArchive?.(ids, visibility);
clearSelect();
assetMultiSelectManager.clear();
}
loading = false;
};
@@ -1,28 +1,28 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte';
import { getAssetControlContext } from '$lib/utils/context';
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { modalManager } from '@immich/ui';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
menuItem?: boolean;
}
};
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleChangeDate = async () => {
const assets = getOwnedAssets();
const assets = assetMultiSelectManager.ownedAssets;
const initialDate = assets.length === 1 ? fromTimelinePlainDateTime(assets[0].localDateTime) : DateTime.now();
const success = await modalManager.show(AssetSelectionChangeDateModal, {
initialDate,
assets,
});
if (success) {
clearSelect();
assetMultiSelectManager.clear();
}
};
</script>
@@ -1,8 +1,8 @@
<script lang="ts">
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import AssetUpdateDescriptionConfirmModal from '$lib/modals/AssetUpdateDescriptionConfirmModal.svelte';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager } from '@immich/ui';
@@ -15,19 +15,18 @@
}
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleUpdateDescription = async () => {
const description = await modalManager.show(AssetUpdateDescriptionConfirmModal);
if (description) {
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, description } });
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_change_description'));
}
clearSelect();
}
};
</script>
@@ -1,9 +1,9 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
@@ -15,7 +15,6 @@
};
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, {});
@@ -23,12 +22,12 @@
return;
}
const ids = getOwnedAssetsWithWarning(getOwnedAssets(), $user);
const ids = getOwnedAssetsWithWarning(assetMultiSelectManager.assets, $user);
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
toastManager.primary();
clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
@@ -1,14 +1,12 @@
<script lang="ts">
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetControlContext } from '$lib/utils/context';
import { IconButton, modalManager } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
const { getAssets } = getAssetControlContext();
const handleClick = async () => {
await modalManager.show(SharedLinkCreateModal, { assetIds: [...getAssets()].map(({ id }) => id) });
await modalManager.show(SharedLinkCreateModal, { assetIds: assetMultiSelectManager.assets.map(({ id }) => id) });
};
</script>
@@ -1,10 +1,10 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { IconButton, modalManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -22,10 +22,8 @@
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const onAction = async () => {
const assets = getOwnedAssets();
const assets = assetMultiSelectManager.ownedAssets;
if (force && $showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: assets.length });
@@ -36,7 +34,7 @@
loading = true;
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
clearSelect();
assetMultiSelectManager.clear();
loading = false;
};
</script>
@@ -1,15 +1,15 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleDownloadAsset } from '$lib/services/asset.service';
import { downloadArchive } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDownload } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
filename?: string;
@@ -18,18 +18,16 @@
let { filename = 'immich.zip', menuItem = false }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const handleDownloadFiles = async () => {
const assets = [...getAssets()];
const assets = assetMultiSelectManager.assets;
if (assets.length === 1) {
clearSelect();
assetMultiSelectManager.clear();
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
await handleDownloadAsset(asset, { edited: true });
return;
}
clearSelect();
assetMultiSelectManager.clear();
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
};
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnFavorite } from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
@@ -21,14 +21,12 @@
let loading = $state(false);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleFavorite = async () => {
const isFavorite = !removeFavorite;
loading = true;
try {
const assets = [...getOwnedAssets()].filter((asset) => asset.isFavorite !== isFavorite);
const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite);
const ids = assets.map(({ id }) => id);
@@ -48,7 +46,7 @@
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
);
clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
} finally {
@@ -1,15 +1,15 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, updateAsset } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
onLink: OnLink;
@@ -25,12 +25,10 @@
let text = $derived(unlink ? $t('unlink_motion_video') : $t('link_motion_video'));
let icon = $derived(unlink ? mdiLinkOff : mdiMotionPlayOutline);
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const onClick = () => (unlink ? handleUnlink() : handleLink());
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
let [still, motion] = assetMultiSelectManager.ownedAssets;
if ((still as TimelineAsset).isVideo) {
[still, motion] = [motion, still];
}
@@ -39,7 +37,7 @@
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video'));
} finally {
@@ -48,7 +46,7 @@
};
const handleUnlink = async () => {
const [still] = [...getOwnedAssets()];
const [still] = assetMultiSelectManager.ownedAssets;
if (!still) {
return;
}
@@ -61,7 +59,7 @@
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const motionResponse = await getAssetInfo({ ...authManager.params, id: motionId });
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video'));
} finally {
@@ -1,11 +1,11 @@
<script lang="ts">
import { getAssetControlContext } from '$lib/utils/context';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
album: AlbumResponseDto;
@@ -16,10 +16,8 @@
let { album = $bindable(), onRemove, assetIds, menuItem = false }: Props = $props();
const context = getAssetControlContext();
const removeFromAlbum = async () => {
const ids = assetIds ?? context?.getAssets().map(({ id }) => id) ?? [];
const ids = assetIds ?? assetMultiSelectManager.assets.map(({ id }) => id) ?? [];
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: ids.length } }),
@@ -42,7 +40,7 @@
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
context?.clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.error_removing_assets_from_album'));
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
import { getAssetControlContext } from '$lib/utils/context';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDeleteOutline } from '@mdi/js';
@@ -13,13 +13,11 @@
let { sharedLink = $bindable() }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const handleSelect = async () => {
const assetIds = getAssets().map(({ id }) => id);
const assetIds = assetMultiSelectManager.assets.map(({ id }) => id);
const success = await handleRemoveSharedLinkAssets(sharedLink, assetIds);
if (success) {
clearSelect();
assetMultiSelectManager.clear();
}
};
</script>
@@ -1,6 +1,6 @@
<script lang="ts">
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnRestore } from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { restoreAssets } from '@immich/sdk';
import { Button, toastManager } from '@immich/ui';
@@ -13,19 +13,17 @@
let { onRestore }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
let loading = $state(false);
const handleRestore = async () => {
loading = true;
try {
const ids = [...getAssets()].map((a) => a.id);
const ids = assetMultiSelectManager.assets.map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onRestore?.(ids);
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
clearSelect();
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
} finally {
@@ -1,7 +1,7 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnSetVisibility } from '$lib/utils/actions';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets } from '@immich/sdk';
import { Button, modalManager } from '@immich/ui';
@@ -16,7 +16,6 @@
let { onVisibilitySet, menuItem = false, unlock = false }: Props = $props();
let loading = $state(false);
const { getAssets } = getAssetControlContext();
const setLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({
@@ -33,7 +32,7 @@
try {
loading = true;
const assetIds = getAssets().map(({ id }) => id);
const assetIds = assetMultiSelectManager.assets.map(({ id }) => id);
await updateAssets({
assetBulkUpdateDto: {
@@ -1,8 +1,8 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -15,17 +15,14 @@
let { unstack = false, onStack, onUnstack }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleStack = async () => {
const selectedAssets = [...getOwnedAssets()];
const result = await stackAssets(selectedAssets);
const result = await stackAssets(assetMultiSelectManager.ownedAssets);
onStack?.(result);
clearSelect();
assetMultiSelectManager.clear();
};
const handleUnstack = async () => {
const selectedAssets = [...getOwnedAssets()];
const selectedAssets = assetMultiSelectManager.ownedAssets;
if (selectedAssets.length !== 1) {
return;
}
@@ -37,7 +34,7 @@
if (unstackedAssets) {
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
clearSelect();
assetMultiSelectManager.clear();
};
</script>
@@ -1,11 +1,11 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { getAssetControlContext } from '$lib/utils/context';
import { IconButton, modalManager } from '@immich/ui';
import { mdiTagMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
menuItem?: boolean;
@@ -16,13 +16,11 @@
const text = $t('tag');
const icon = mdiTagMultipleOutline;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const assets = assetMultiSelectManager.ownedAssets;
const didUpdate = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (didUpdate) {
clearSelect();
assetMultiSelectManager.clear();
}
};
</script>
@@ -1,7 +1,6 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { user } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store';
@@ -22,7 +21,10 @@ export class AssetMultiSelectManager {
candidates = $state<TimelineAsset[]>([]);
selectionActive = $derived(this.#selectedMap.size > 0);
assets = $derived(Array.from(this.#selectedMap.values()));
ownedAssets = $derived(this.#userId ? this.assets.filter((asset) => asset.ownerId === this.#userId) : this.assets);
isAllTrashed = $derived(this.assets.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.assets.every((asset) => asset.visibility === AssetVisibility.Archive));
isAllFavorite = $derived(this.assets.every((asset) => asset.isFavorite));
@@ -41,13 +43,8 @@ export class AssetMultiSelectManager {
this.#unsubscribe?.();
}
asControlContext(): AssetControlContext {
return {
getOwnedAssets: () =>
this.#userId ? this.assets.filter((asset) => asset.ownerId === this.#userId) : this.assets,
getAssets: () => this.assets,
clearSelect: () => this.clear(),
};
getOwnedAssets() {
return this.#userId ? this.assets.filter((asset) => asset.ownerId === this.#userId) : this.assets;
}
hasSelectedAsset(assetId: string) {
@@ -1,64 +1,64 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
export class GroupInsertionCache {
#lookupCache: {
[year: number]: { [month: number]: { [day: number]: DayGroup } };
[year: number]: { [month: number]: { [day: number]: TimelineDay } };
} = {};
unprocessedAssets: TimelineAsset[] = [];
// eslint-disable-next-line svelte/prefer-svelte-reactivity
changedDayGroups = new Set<DayGroup>();
changedTimelineDays = new Set<TimelineDay>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
newDayGroups = new Set<DayGroup>();
newTimelineDays = new Set<TimelineDay>();
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
getTimelineDay({ year, month, day }: TimelineDate): TimelineDay | undefined {
return this.#lookupCache[year]?.[month]?.[day];
}
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelineDate) {
setTimelineDay(timelineDay: TimelineDay, { year, month, day }: TimelineDate) {
if (!this.#lookupCache[year]) {
this.#lookupCache[year] = {};
}
if (!this.#lookupCache[year][month]) {
this.#lookupCache[year][month] = {};
}
this.#lookupCache[year][month][day] = dayGroup;
this.#lookupCache[year][month][day] = timelineDay;
}
get existingDayGroups() {
return setDifference(this.changedDayGroups, this.newDayGroups);
get existingTimelineDays() {
return setDifference(this.changedTimelineDays, this.newTimelineDays);
}
get updatedBuckets() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.changedDayGroups) {
for (const group of this.changedTimelineDays) {
updated.add(group.monthGroup);
}
return updated;
}
get bucketsWithNewDayGroups() {
get bucketsWithNewTimelineDays() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.newDayGroups) {
for (const group of this.newTimelineDays) {
updated.add(group.monthGroup);
}
return updated;
}
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDayGroups) {
for (const group of this.changedTimelineDays) {
group.sortAssets(sortOrder);
}
for (const group of this.newDayGroups) {
for (const group of this.newTimelineDays) {
group.sortAssets(sortOrder);
}
if (this.newDayGroups.size > 0) {
monthGroup.sortDayGroups();
if (this.newTimelineDays.size > 0) {
monthGroup.sortTimelineDays();
}
}
}
@@ -25,41 +25,41 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
let cumulativeWidth = 0;
let currentRowHeight = 0;
let dayGroupRow = 0;
let dayGroupCol = 0;
let timelineDayRow = 0;
let timelineDayCol = 0;
const options = timelineManager.justifiedLayoutOptions;
for (const dayGroup of month.dayGroups) {
dayGroup.layout(options, noDefer);
for (const timelineDay of month.timelineDays) {
timelineDay.layout(options, noDefer);
// Calculate space needed for this item (including gap if not first in row)
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0);
const spaceNeeded = timelineDay.width + (timelineDayCol > 0 ? timelineManager.gap : 0);
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
if (fitsInCurrentRow) {
dayGroup.row = dayGroupRow;
dayGroup.col = dayGroupCol++;
dayGroup.start = cumulativeWidth;
dayGroup.top = cumulativeHeight;
timelineDay.row = timelineDayRow;
timelineDay.col = timelineDayCol++;
timelineDay.start = cumulativeWidth;
timelineDay.top = cumulativeHeight;
cumulativeWidth += dayGroup.width + timelineManager.gap;
cumulativeWidth += timelineDay.width + timelineManager.gap;
} else {
// Move to next row
cumulativeHeight += currentRowHeight;
cumulativeWidth = 0;
dayGroupRow++;
dayGroupCol = 0;
timelineDayRow++;
timelineDayCol = 0;
// Position at start of new row
dayGroup.row = dayGroupRow;
dayGroup.col = dayGroupCol;
dayGroup.start = 0;
dayGroup.top = cumulativeHeight;
timelineDay.row = timelineDayRow;
timelineDay.col = timelineDayCol;
timelineDay.start = 0;
timelineDay.top = cumulativeHeight;
dayGroupCol++;
cumulativeWidth += dayGroup.width + timelineManager.gap;
timelineDayCol++;
cumulativeWidth += timelineDay.width + timelineManager.gap;
}
currentRowHeight = dayGroup.height + timelineManager.headerHeight;
currentRowHeight = timelineDay.height + timelineManager.headerHeight;
}
// Add the height of the final row
@@ -60,10 +60,10 @@ async function getAssetByAssetOffset(
monthGroup: MonthGroup,
direction: Direction,
) {
const dayGroup = monthGroup.findDayGroupForAsset(asset);
const timelineDay = monthGroup.findTimelineDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup: monthGroup,
startDayGroup: dayGroup,
startTimelineDay: timelineDay,
startAsset: asset,
direction,
})) {
@@ -79,10 +79,10 @@ async function getAssetByDayOffset(
monthGroup: MonthGroup,
direction: Direction,
) {
const dayGroup = monthGroup.findDayGroupForAsset(asset);
const timelineDay = monthGroup.findTimelineDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup: monthGroup,
startDayGroup: dayGroup,
startTimelineDay: timelineDay,
startAsset: asset,
direction,
})) {
@@ -127,10 +127,10 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
}
const range: TimelineAsset[] = [];
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset);
const startTimelineDay = startMonthGroup.findTimelineDayForAsset(startAsset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonthGroup,
startDayGroup,
startTimelineDay,
startAsset,
})) {
range.push(targetAsset);
@@ -23,8 +23,8 @@ import {
isInViewport as isInViewportUtil,
} from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte';
import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
@@ -32,7 +32,7 @@ import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
#viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport);
isLoaded: boolean = $state(false);
dayGroups: DayGroup[] = $state([]);
timelineDays: TimelineDay[] = $state([]);
readonly timelineManager: TimelineManager;
#height: number = $state(0);
@@ -44,7 +44,7 @@ export class MonthGroup {
assetsCount: number = $derived(
this.isLoaded
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
? this.timelineDays.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
: this.#initialCount,
);
loader: CancellableTask | undefined;
@@ -72,7 +72,7 @@ export class MonthGroup {
this.isLoaded = true;
},
() => {
this.dayGroups = [];
this.timelineDays = [];
this.isLoaded = false;
},
this.#handleLoadError,
@@ -103,25 +103,28 @@ export class MonthGroup {
return isInViewportUtil(this.#viewportProximity);
}
get lastDayGroup() {
return this.dayGroups.at(-1);
get lastTimelineDay() {
return this.timelineDays.at(-1);
}
getFirstAsset() {
return this.dayGroups[0]?.getFirstAsset();
return this.timelineDays[0]?.getFirstAsset();
}
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
return this.timelineDays.reduce(
(accumulator: TimelineAsset[], g: TimelineDay) => accumulator.concat(g.getAssets()),
[],
);
}
sortDayGroups() {
sortTimelineDays() {
if (this.#sortOrder === AssetOrder.Asc) {
return this.dayGroups.sort((a, b) => a.day - b.day);
return this.timelineDays.sort((a, b) => a.day - b.day);
}
return this.dayGroups.sort((a, b) => b.day - a.day);
return this.timelineDays.sort((a, b) => b.day - a.day);
}
runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
@@ -133,15 +136,15 @@ export class MonthGroup {
changedGeometry: false,
};
}
const { dayGroups } = this;
const { timelineDays } = this;
let combinedChangedGeometry = false;
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: MoveAsset[][] = [];
let index = dayGroups.length;
let index = timelineDays.length;
while (index--) {
if (idsToProcess.size > 0) {
const group = dayGroups[index];
const group = timelineDays[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
@@ -152,7 +155,7 @@ export class MonthGroup {
}
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
if (group.viewerAssets.length === 0) {
dayGroups.splice(index, 1);
timelineDays.splice(index, 1);
combinedChangedGeometry = true;
}
}
@@ -215,12 +218,12 @@ export class MonthGroup {
return addContext.unprocessedAssets;
}
for (const group of addContext.existingDayGroups) {
for (const group of addContext.existingTimelineDays) {
group.sortAssets(this.#sortOrder);
}
if (addContext.newDayGroups.size > 0) {
this.sortDayGroups();
if (addContext.newTimelineDays.size > 0) {
this.sortTimelineDays();
}
addContext.sort(this, this.#sortOrder);
@@ -237,20 +240,20 @@ export class MonthGroup {
return;
}
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day);
if (dayGroup) {
addContext.setDayGroup(dayGroup, localDateTime);
let timelineDay = addContext.getTimelineDay(localDateTime) || this.findTimelineDayByDay(localDateTime.day);
if (timelineDay) {
addContext.setTimelineDay(timelineDay, localDateTime);
} else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
this.dayGroups.push(dayGroup);
addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup);
timelineDay = new TimelineDay(this, this.timelineDays.length, localDateTime.day, groupTitle);
this.timelineDays.push(timelineDay);
addContext.setTimelineDay(timelineDay, localDateTime);
addContext.newTimelineDays.add(timelineDay);
}
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset);
dayGroup.viewerAssets.push(viewerAsset);
addContext.changedDayGroups.add(dayGroup);
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay);
}
get viewId() {
@@ -312,21 +315,21 @@ export class MonthGroup {
handleError(error, _$t('errors.failed_to_load_assets'));
}
findDayGroupForAsset(asset: TimelineAsset) {
for (const group of this.dayGroups) {
findTimelineDayForAsset(asset: TimelineAsset) {
for (const group of this.timelineDays) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
return group;
}
}
}
findDayGroupByDay(day: number) {
return this.dayGroups.find((group) => group.day === day);
findTimelineDayByDay(day: number) {
return this.timelineDays.find((group) => group.day === day);
}
findAssetAbsolutePosition(assetId: string) {
this.timelineManager.clearDeferredLayout(this);
for (const group of this.dayGroups) {
for (const group of this.timelineDays) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
@@ -341,18 +344,18 @@ export class MonthGroup {
}
}
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
*assetsIterator(options?: { startTimelineDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
const direction = options?.direction ?? 'earlier';
let { startAsset } = options ?? {};
const isEarlier = direction === 'earlier';
let groupIndex = options?.startDayGroup
? this.dayGroups.indexOf(options.startDayGroup)
let groupIndex = options?.startTimelineDay
? this.timelineDays.indexOf(options.startTimelineDay)
: isEarlier
? 0
: this.dayGroups.length - 1;
: this.timelineDays.length - 1;
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) {
const group = this.dayGroups[groupIndex];
while (groupIndex >= 0 && groupIndex < this.timelineDays.length) {
const group = this.timelineDays[groupIndex];
yield* group.assetsIterator({ startAsset, direction });
startAsset = undefined;
groupIndex += isEarlier ? 1 : -1;
@@ -9,7 +9,7 @@ import type { MonthGroup } from './month-group.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup {
export class TimelineDay {
readonly monthGroup: MonthGroup;
readonly index: number;
readonly groupTitle: string;
@@ -151,7 +151,7 @@ export class DayGroup {
}
}
get absoluteDayGroupTop() {
get absoluteTimelineDayTop() {
return this.monthGroup.top + this.#top;
}
}
@@ -1,9 +1,10 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { assetFactory, timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types';
@@ -442,6 +443,48 @@ describe('TimelineManager', () => {
});
});
describe('AssetUpdate events', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
await timelineManager.updateOptions({ albumId: 'album-id' });
});
afterEach(() => {
timelineManager.destroy();
});
it('ignores unknown assets for album timelines', () => {
eventManager.emit('AssetUpdate', assetFactory.build());
expect(timelineManager.assetCount).toEqual(0);
expect(timelineManager.months).toHaveLength(0);
});
it('updates existing assets in the timeline', () => {
const existing = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
timelineManager.upsertAssets([existing]);
eventManager.emit(
'AssetUpdate',
assetFactory.build({
id: existing.id,
ownerId: existing.ownerId,
isFavorite: true,
isTrashed: existing.isTrashed,
visibility: existing.visibility,
}),
);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
});
});
describe('removeAssets', () => {
let timelineManager: TimelineManager;
@@ -26,9 +26,9 @@ import {
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import { TimelineDay } from './timeline-day.svelte';
import type {
AssetDescriptor,
Direction,
@@ -113,7 +113,7 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]),
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
}),
);
}
@@ -138,16 +138,16 @@ export class TimelineManager extends VirtualScrollManager {
async *assetsIterator(options?: {
startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup;
startTimelineDay?: TimelineDay;
startAsset?: TimelineAsset;
direction?: Direction;
}) {
const direction = options?.direction ?? 'earlier';
let { startDayGroup, startAsset } = options ?? {};
let { startTimelineDay, startAsset } = options ?? {};
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
startDayGroup = startAsset = undefined;
yield* monthGroup.assetsIterator({ startTimelineDay, startAsset, direction });
startTimelineDay = startAsset = undefined;
}
}
@@ -226,10 +226,10 @@ export class TimelineManager extends VirtualScrollManager {
}
clearDeferredLayout(month: MonthGroup) {
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
const hasDeferred = month.timelineDays.some((group) => group.deferredLayout);
if (hasDeferred) {
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
for (const group of month.dayGroups) {
for (const group of month.timelineDays) {
group.deferredLayout = false;
}
}
@@ -428,8 +428,8 @@ export class TimelineManager extends VirtualScrollManager {
}
await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false });
let randomDay: DayGroup | undefined = undefined;
for (const day of randomMonth.dayGroups) {
let randomDay: TimelineDay | undefined = undefined;
for (const day of randomMonth.timelineDays) {
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
randomDay = day;
break;
@@ -618,16 +618,16 @@ export class TimelineManager extends VirtualScrollManager {
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
for (const group of context.existingTimelineDays) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
for (const monthGroup of context.bucketsWithNewTimelineDays) {
monthGroup.sortTimelineDays();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
month.sortTimelineDays();
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateViewportProximities();
@@ -1,15 +1,15 @@
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: DayGroup;
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
@@ -17,7 +17,7 @@ export class ViewerAsset {
}
const store = this.#group.monthGroup.timelineManager;
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
@@ -30,7 +30,7 @@ export class ViewerAsset {
asset: TimelineAsset = <TimelineAsset>$state();
id: string = $derived(this.asset.id);
constructor(group: DayGroup, asset: TimelineAsset) {
constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
this.asset = asset;
}
+4 -4
View File
@@ -1,4 +1,5 @@
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -6,7 +7,6 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
@@ -48,14 +48,14 @@ import {
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlContext) => {
const ownedAssets = ctx.getOwnedAssets();
export const getAssetBulkActions = ($t: MessageFormatter) => {
const ownedAssets = assetMultiSelectManager.ownedAssets;
const assetIds = ownedAssets.map((asset) => asset.id);
const isAllVideos = ownedAssets.every((asset) => asset.isVideo);
const onAction = async (name: AssetJobName) => {
await handleRunAssetJob({ name, assetIds });
ctx.clearSelect();
assetMultiSelectManager.clear();
};
const AddToAlbum: ActionItem = {
+1 -1
View File
@@ -407,7 +407,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
}
assetInteraction.selectAssets([...monthGroup.assetsIterator()]);
for (const dateGroup of monthGroup.dayGroups) {
for (const dateGroup of monthGroup.timelineDays) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
}
}
+86 -1
View File
@@ -1,4 +1,11 @@
import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import {
getContentMetrics,
getNaturalSize,
mapNormalizedRectToContent,
mapNormalizedToContent,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
const mockImage = (props: {
naturalWidth: number;
@@ -92,3 +99,81 @@ describe('getNaturalSize', () => {
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
});
});
describe('scaleToCover', () => {
it('should scale up to cover container when image is smaller', () => {
expect(scaleToCover({ width: 400, height: 300 }, { width: 800, height: 600 })).toEqual({
width: 800,
height: 600,
});
});
it('should use height scale when image is wider than container', () => {
expect(scaleToCover({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
width: 1200,
height: 600,
});
});
it('should use width scale when image is taller than container', () => {
expect(scaleToCover({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
width: 800,
height: 1600,
});
});
});
describe('mapNormalizedToContent', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
it('should map top-left corner', () => {
expect(mapNormalizedToContent({ x: 0, y: 0 }, metrics)).toEqual({ x: 0, y: 100 });
});
it('should map bottom-right corner', () => {
expect(mapNormalizedToContent({ x: 1, y: 1 }, metrics)).toEqual({ x: 800, y: 500 });
});
it('should map center point', () => {
expect(mapNormalizedToContent({ x: 0.5, y: 0.5 }, metrics)).toEqual({ x: 400, y: 300 });
});
it('should apply offsets correctly for letterboxed content', () => {
const letterboxed = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 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 });
});
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('mapNormalizedRectToContent', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
it('should map a normalized rect to content pixel coordinates', () => {
const rect = mapNormalizedRectToContent({ x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 }, metrics);
expect(rect).toEqual({ left: 200, top: 200, width: 400, height: 200 });
});
it('should map full image rect', () => {
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, metrics);
expect(rect).toEqual({ left: 0, top: 100, width: 800, height: 400 });
});
it('should handle letterboxed content with horizontal offsets', () => {
const letterboxed = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, letterboxed);
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 });
});
});
+65 -12
View File
@@ -1,14 +1,35 @@
export interface ContentMetrics {
// Coordinate spaces used throughout the viewer:
//
// "Normalized": 01 range, (0,0) = top-left, (1,1) = bottom-right. Resolution-independent.
// Example: OCR coordinates, or face coords after dividing by metadata dimensions.
//
// "Content": pixel position within the container after scaling (scaleToFit/scaleToCover)
// and centering. Used for DOM overlay positioning (face boxes, OCR text).
//
// "Natural": pixel position in the original full-resolution image file (e.g. 4000×3000).
// Used when cropping or drawing on the source image.
//
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
export type Point = {
x: number;
y: number;
};
export type Size = {
width: number;
height: number;
};
export type ContentMetrics = {
contentWidth: number;
contentHeight: number;
offsetX: number;
offsetY: number;
}
};
export const scaleToCover = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
export const scaleToCover = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.max(scaleX, scaleY);
@@ -18,10 +39,7 @@ export const scaleToCover = (
};
};
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
): { width: number; height: number } => {
export const scaleToFit = (dimensions: Size, container: Size): Size => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
@@ -31,14 +49,14 @@ export const scaleToFit = (
};
};
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
}
@@ -56,3 +74,38 @@ export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement):
offsetY: (client.height - contentHeight) / 2,
};
};
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
return {
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
};
}
return {
x: point.x * sizeOrMetrics.width,
y: point.y * sizeOrMetrics.height,
};
}
export type Rect = {
top: number;
left: number;
width: number;
height: number;
};
export function mapNormalizedRectToContent(
topLeft: Point,
bottomRight: Point,
sizeOrMetrics: Size | ContentMetrics,
): Rect {
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
return {
top: tl.y,
left: tl.x,
width: br.x - tl.x,
height: br.y - tl.y,
};
}
-3
View File
@@ -1,4 +1,3 @@
import type { AssetControlContext } from '$lib/types';
import { getContext, setContext } from 'svelte';
export function createContext<T>(key: string | symbol = Symbol()) {
@@ -7,5 +6,3 @@ export function createContext<T>(key: string | symbol = Symbol()) {
set: (context: T) => setContext<T>(key, context),
};
}
export const { get: getAssetControlContext, set: setAssetControlContext } = createContext<AssetControlContext>();
+13 -14
View File
@@ -1,5 +1,5 @@
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
import type { ContentMetrics } from '$lib/utils/container-utils';
import type { Size } from '$lib/utils/container-utils';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
describe('getOcrBoundingBoxes', () => {
@@ -21,9 +21,9 @@ describe('getOcrBoundingBoxes', () => {
text: 'hello',
},
];
const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 };
const imageSize: Size = { width: 1000, height: 500 };
const boxes = getOcrBoundingBoxes(ocrData, metrics);
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
expect(boxes).toHaveLength(1);
expect(boxes[0].id).toBe('box1');
@@ -37,7 +37,7 @@ describe('getOcrBoundingBoxes', () => {
]);
});
it('should apply offsets for letterboxed images', () => {
it('should map full-image box to full display area', () => {
const ocrData: OcrBoundingBox[] = [
{
id: 'box1',
@@ -55,21 +55,20 @@ describe('getOcrBoundingBoxes', () => {
text: 'test',
},
];
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 };
const imageSize: Size = { width: 600, height: 400 };
const boxes = getOcrBoundingBoxes(ocrData, metrics);
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
expect(boxes[0].points).toEqual([
{ x: 100, y: 50 },
{ x: 700, y: 50 },
{ x: 700, y: 450 },
{ x: 100, y: 450 },
{ x: 0, y: 0 },
{ x: 600, y: 0 },
{ x: 600, y: 400 },
{ x: 0, y: 400 },
]);
});
it('should return empty array for empty input', () => {
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
expect(getOcrBoundingBoxes([], metrics)).toEqual([]);
expect(getOcrBoundingBoxes([], { width: 800, height: 600 })).toEqual([]);
});
it('should handle multiple boxes', () => {
@@ -105,9 +104,9 @@ describe('getOcrBoundingBoxes', () => {
text: 'second',
},
];
const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 };
const imageSize: Size = { width: 200, height: 200 };
const boxes = getOcrBoundingBoxes(ocrData, metrics);
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
expect(boxes).toHaveLength(2);
expect(boxes[0].text).toBe('first');
+8 -15
View File
@@ -1,23 +1,19 @@
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
import type { ContentMetrics } from '$lib/utils/container-utils';
import { mapNormalizedToContent, type Point, type Size } from '$lib/utils/container-utils';
import { clamp } from 'lodash-es';
export type Point = {
x: number;
y: number;
};
export type { Point } from '$lib/utils/container-utils';
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
export type VerticalMode = 'none' | 'cjk' | 'rotated';
export interface OcrBox {
export type OcrBox = {
id: string;
points: Point[];
text: string;
confidence: number;
verticalMode: VerticalMode;
}
};
const CJK_PATTERN =
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
@@ -38,7 +34,7 @@ const getVerticalMode = (width: number, height: number, text: string): VerticalM
* @param points - Array of 4 corner points of the bounding box
* @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div.
*/
export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => {
export const calculateBoundingBoxMatrix = (points: Point[]): Size & { matrix: number[] } => {
const [topLeft, topRight, bottomRight, bottomLeft] = points;
const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight));
@@ -163,7 +159,7 @@ export const calculateFittedFontSize = (
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
};
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], imageSize: Size): OcrBox[] => {
const boxes: OcrBox[] = [];
for (const ocr of ocrData) {
const points = [
@@ -171,10 +167,7 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
{ x: ocr.x2, y: ocr.y2 },
{ x: ocr.x3, y: ocr.y3 },
{ x: ocr.x4, y: ocr.y4 },
].map((point) => ({
x: point.x * metrics.contentWidth + metrics.offsetX,
y: point.y * metrics.contentHeight + metrics.offsetY,
}));
].map((point) => mapNormalizedToContent(point, imageSize));
const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2]));
const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2]));
@@ -188,7 +181,7 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
});
}
const rowThreshold = metrics.contentHeight * 0.02;
const rowThreshold = imageSize.height * 0.02;
boxes.sort((a, b) => {
const yDifference = a.points[0].y - b.points[0].y;
if (Math.abs(yDifference) < rowThreshold) {

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