mirror of
https://github.com/immich-app/immich.git
synced 2026-03-19 08:38:36 -07:00
Compare commits
18 Commits
fix-stack-
...
fix/server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476e6bdf3a | ||
|
|
77020e742a | ||
|
|
38b135ff36 | ||
|
|
cda4a2a5fc | ||
|
|
88002cf7fe | ||
|
|
694ea151f5 | ||
|
|
b092c8b601 | ||
|
|
48e6e17829 | ||
|
|
0519833d75 | ||
|
|
34caed3b2b | ||
|
|
677cb660f5 | ||
|
|
9b0b2bfcf2 | ||
|
|
ac6938a629 | ||
|
|
16749ff8ba | ||
|
|
bba4a00eb1 | ||
|
|
9dafc8e8e9 | ||
|
|
4e44fb9cf7 | ||
|
|
82db581cc5 |
2
.github/workflows/check-openapi.yml
vendored
2
.github/workflows/check-openapi.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
4
.github/workflows/cli.yml
vendored
4
.github/workflows/cli.yml
vendored
@@ -42,10 +42,10 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
|
||||
# ℹ️ 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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
4
.github/workflows/docs-build.yml
vendored
4
.github/workflows/docs-build.yml
vendored
@@ -67,10 +67,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
4
.github/workflows/fix-format.yml
vendored
4
.github/workflows/fix-format.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
6
.github/workflows/prepare-release.yml
vendored
6
.github/workflows/prepare-release.yml
vendored
@@ -63,13 +63,13 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
4
.github/workflows/sdk.yml
vendored
4
.github/workflows/sdk.yml
vendored
@@ -30,10 +30,10 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
54
.github/workflows/test.yml
vendored
54
.github/workflows/test.yml
vendored
@@ -75,9 +75,9 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -119,9 +119,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -166,9 +166,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './cli/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -208,9 +208,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -252,9 +252,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -290,9 +290,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './web/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -338,9 +338,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -385,9 +385,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -424,9 +424,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -496,9 +496,9 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -620,7 +620,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install dependencies
|
||||
@@ -661,9 +661,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './.github/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -712,9 +712,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -774,9 +774,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
|
||||
|
||||
const [error] = await withError(getMyUser());
|
||||
if (isHttpError(error)) {
|
||||
logError(error, 'Failed to connect to server');
|
||||
logError(error, `Failed to connect to server ${url}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,20 @@ test.describe('Photo Viewer', () => {
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('right-click targets the img element', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const box = await preview.boundingBox();
|
||||
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
|
||||
x: box!.x + box!.width / 2,
|
||||
y: box!.y + box!.height / 2,
|
||||
});
|
||||
expect(tagAtCenter).toBe('IMG');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
|
||||
|
||||
@@ -284,11 +284,7 @@ const createDefaultOwner = (ownerId: string) => {
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(
|
||||
asset: MockTimelineAsset,
|
||||
owner?: UserResponseDto,
|
||||
overrides?: Partial<Pick<AssetResponseDto, 'people' | 'unassignedFaces'>>,
|
||||
): AssetResponseDto {
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
@@ -342,8 +338,8 @@ export function toAssetResponseDto(
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: overrides?.people ?? [],
|
||||
unassignedFaces: overrides?.unassignedFaces ?? [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import {
|
||||
type AssetFaceResponseDto,
|
||||
type AssetFaceWithoutPersonResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonWithFacesResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
@@ -131,117 +125,3 @@ export const setupFaceEditorMockApiRoutes = async (
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export type MockFaceSpec = {
|
||||
personId: string;
|
||||
personName: string;
|
||||
faceId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY2: number;
|
||||
};
|
||||
|
||||
export const createMockFaceData = (
|
||||
faceSpecs: MockFaceSpec[],
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): { people: PersonWithFacesResponseDto[]; unassignedFaces: AssetFaceWithoutPersonResponseDto[] } => {
|
||||
const people: PersonWithFacesResponseDto[] = faceSpecs.map((spec) => ({
|
||||
id: spec.personId,
|
||||
name: spec.personName,
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
faces: [
|
||||
{
|
||||
id: spec.faceId,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boundingBoxX1: spec.boundingBoxX1,
|
||||
boundingBoxY1: spec.boundingBoxY1,
|
||||
boundingBoxX2: spec.boundingBoxX2,
|
||||
boundingBoxY2: spec.boundingBoxY2,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return { people, unassignedFaces: [] };
|
||||
};
|
||||
|
||||
export const setupFaceOverlayMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
assetDto: AssetResponseDto,
|
||||
faceSpecs: MockFaceSpec[],
|
||||
) => {
|
||||
const faceResponseMap = new Map<string, AssetFaceResponseDto>();
|
||||
for (const spec of faceSpecs) {
|
||||
faceResponseMap.set(spec.faceId, {
|
||||
id: spec.faceId,
|
||||
imageWidth: assetDto.width ?? 3000,
|
||||
imageHeight: assetDto.height ?? 4000,
|
||||
boundingBoxX1: spec.boundingBoxX1,
|
||||
boundingBoxY1: spec.boundingBoxY1,
|
||||
boundingBoxX2: spec.boundingBoxX2,
|
||||
boundingBoxY2: spec.boundingBoxY2,
|
||||
person: {
|
||||
id: spec.personId,
|
||||
name: spec.personName,
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await context.route(`**/api/assets/${assetDto.id}`, async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assetDto,
|
||||
});
|
||||
});
|
||||
|
||||
await context.route(`**/api/faces?id=${assetDto.id}`, async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: [...faceResponseMap.values()],
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/faces/*', async (route, request) => {
|
||||
if (request.method() !== 'DELETE') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const faceId = url.pathname.split('/').at(-1);
|
||||
if (faceId) {
|
||||
faceResponseMap.delete(faceId);
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/plain',
|
||||
body: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||
if (!route.request().serviceWorker()) {
|
||||
return route.continue();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: await randomThumbnail('person-thumb', 1),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockFaceData,
|
||||
type MockFaceSpec,
|
||||
setupFaceOverlayMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test.describe('face removal auto-close', () => {
|
||||
const fixture = setupAssetViewerFixture(903);
|
||||
const singleFaceSpec: MockFaceSpec[] = [
|
||||
{
|
||||
personId: 'person-solo',
|
||||
personName: 'Solo Person',
|
||||
faceId: 'face-solo',
|
||||
boundingBoxX1: 1000,
|
||||
boundingBoxY1: 500,
|
||||
boundingBoxX2: 1500,
|
||||
boundingBoxY2: 1200,
|
||||
},
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const faceData = createMockFaceData(
|
||||
singleFaceSpec,
|
||||
fixture.primaryAssetDto.width ?? 3000,
|
||||
fixture.primaryAssetDto.height ?? 4000,
|
||||
);
|
||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, singleFaceSpec);
|
||||
});
|
||||
|
||||
test('person side panel closes when last face is removed', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const editPeopleButton = page.locator('#detail-panel').getByLabel('Edit people');
|
||||
await expect(editPeopleButton).toBeVisible();
|
||||
await editPeopleButton.click();
|
||||
|
||||
const personName = page.locator('text=Solo Person');
|
||||
await expect(personName.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const deleteButton = page.getByLabel('Delete face');
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await deleteButton.click();
|
||||
|
||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
||||
await expect(confirmButton).toBeVisible();
|
||||
await confirmButton.click();
|
||||
|
||||
await expect(page.locator('text=Edit faces')).toBeHidden({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { type 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 {
|
||||
createMockPeople,
|
||||
FaceCreateCapture,
|
||||
MockPerson,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('stack face-tag selection preservation', () => {
|
||||
const fixture = setupAssetViewerFixture(910);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
let mockPeople: MockPerson[];
|
||||
let faceCreateCapture: FaceCreateCapture;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.originalFileName = 'second-stacked-asset.jpg';
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
mockPeople = createMockPeople(3);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
faceCreateCapture = { requests: [] };
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||
});
|
||||
|
||||
test('selected stacked asset is preserved after tagging a face', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const stackSlideshow = page.locator('#stack-slideshow');
|
||||
await expect(stackSlideshow).toBeVisible();
|
||||
|
||||
const stackThumbnails = stackSlideshow.locator('[data-asset]');
|
||||
await expect(stackThumbnails).toHaveCount(2);
|
||||
|
||||
await stackThumbnails.nth(1).click();
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
|
||||
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
|
||||
|
||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
||||
await expect(confirmButton).toBeVisible();
|
||||
await confirmButton.click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(secondAssetDto.id);
|
||||
|
||||
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
|
||||
|
||||
const selectedThumbnail = stackSlideshow.locator(`[data-asset="${secondAssetDto.id}"]`);
|
||||
await expect(selectedThumbnail).toBeVisible();
|
||||
});
|
||||
|
||||
test('primary asset stays selected after tagging a face without switching', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
|
||||
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
|
||||
|
||||
const confirmButton = page.getByRole('button', { name: /confirm/i });
|
||||
await expect(confirmButton).toBeVisible();
|
||||
await confirmButton.click();
|
||||
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(primaryAssetDto.id);
|
||||
|
||||
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
|
||||
});
|
||||
});
|
||||
@@ -1651,6 +1651,7 @@
|
||||
"only_favorites": "Only favorites",
|
||||
"open": "Open",
|
||||
"open_calendar": "Open calendar",
|
||||
"open_in_browser": "Open in browser",
|
||||
"open_in_map_view": "Open in map view",
|
||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||
"open_the_search_filters": "Open the search filters",
|
||||
|
||||
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
|
||||
if (confirm != true) return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
false;
|
||||
if (!confirm) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'delete_permanently_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
|
||||
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class OpenInBrowserActionButton extends ConsumerWidget {
|
||||
final String remoteId;
|
||||
final TimelineOrigin origin;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final Color? iconColor;
|
||||
|
||||
const OpenInBrowserActionButton({
|
||||
super.key,
|
||||
required this.remoteId,
|
||||
required this.origin,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
void _onTap() async {
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
||||
|
||||
String originPath = '';
|
||||
switch (origin) {
|
||||
case TimelineOrigin.favorite:
|
||||
originPath = '/favorites';
|
||||
break;
|
||||
case TimelineOrigin.trash:
|
||||
originPath = '/trash';
|
||||
break;
|
||||
case TimelineOrigin.archive:
|
||||
originPath = '/archive';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
final url = '$serverEndpoint$originPath/photos/$remoteId';
|
||||
if (await canLaunchUrl(Uri.parse(url))) {
|
||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
label: 'open_in_browser'.t(context: context),
|
||||
iconData: Icons.open_in_browser,
|
||||
iconColor: iconColor,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: _onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'remove_from_album_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
|
||||
@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).trash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
|
||||
late int _currentPage = widget.initialIndex;
|
||||
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
bool _assetReloadRequested = false;
|
||||
|
||||
void _onTapNavigate(int direction) {
|
||||
final page = _pageController.page?.toInt();
|
||||
if (page == null) return;
|
||||
final target = page + direction;
|
||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||
final maxPage = _totalAssets - 1;
|
||||
if (target >= 0 && target <= maxPage) {
|
||||
_currentPage = target;
|
||||
_pageController.jumpToPage(target);
|
||||
_onAssetChanged(target);
|
||||
}
|
||||
@@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
final page = _pageController.page?.round();
|
||||
if (page != null && page != _currentPage) {
|
||||
_currentPage = page;
|
||||
_onAssetChanged(page);
|
||||
}
|
||||
return false;
|
||||
@@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final asset = await timelineService.getAssetAsync(index);
|
||||
_currentPage = index;
|
||||
|
||||
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
|
||||
if (asset == null) return;
|
||||
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
@@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
case ViewerReloadAssetEvent():
|
||||
_assetReloadRequested = true;
|
||||
_onViewerReloadEvent();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void _onViewerReloadEvent() {
|
||||
if (_totalAssets <= 1) return;
|
||||
|
||||
final index = _pageController.page?.round() ?? 0;
|
||||
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
|
||||
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
|
||||
_onAssetChanged(target);
|
||||
}
|
||||
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
@@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
return;
|
||||
}
|
||||
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||
if (newIndex != null && newIndex != index) {
|
||||
index = newIndex;
|
||||
_currentPage = index;
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
}
|
||||
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
|
||||
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
_currentPage = index;
|
||||
if (index != _currentPage) {
|
||||
_pageController.jumpToPage(index);
|
||||
_onAssetChanged(index);
|
||||
} else if (currentAsset != null && assetIndex == null) {
|
||||
_onAssetChanged(index);
|
||||
}
|
||||
|
||||
if (_assetReloadRequested) {
|
||||
_assetReloadRequested = false;
|
||||
_onAssetReloadEvent(index);
|
||||
if (_totalAssets != totalAssets) {
|
||||
setState(() {
|
||||
_totalAssets = totalAssets;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onAssetReloadEvent(int index) async {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
|
||||
_onAssetChanged(index);
|
||||
}
|
||||
|
||||
void _setSystemUIMode(bool controls, bool details) {
|
||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||
? SystemUiMode.immersiveSticky
|
||||
@@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||
itemCount: _totalAssets,
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
@@ -75,6 +76,7 @@ enum ActionButtonType {
|
||||
viewInTimeline,
|
||||
download,
|
||||
upload,
|
||||
openInBrowser,
|
||||
unstack,
|
||||
archive,
|
||||
unarchive,
|
||||
@@ -149,6 +151,7 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.isStacked,
|
||||
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
|
||||
ActionButtonType.likeActivity =>
|
||||
!context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
@@ -236,6 +239,13 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
||||
remoteId: context.asset.remoteId!,
|
||||
origin: context.timelineOrigin,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
iconColor: context.originalTheme?.iconTheme.color,
|
||||
),
|
||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||
assetId: (context.asset as RemoteAsset).id,
|
||||
iconOnly: iconOnly,
|
||||
|
||||
@@ -15,13 +15,12 @@ log_message() {
|
||||
|
||||
log_message "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
|
||||
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
|
||||
# if [ -f "$lib_path" ]; then
|
||||
# export LD_PRELOAD="$lib_path"
|
||||
# else
|
||||
# echo "skipping libmimalloc - path not found $lib_path"
|
||||
# fi
|
||||
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
|
||||
if [ -f "$lib_path" ]; then
|
||||
export LD_PRELOAD="$lib_path"
|
||||
else
|
||||
echo "skipping libmimalloc - path not found $lib_path"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
|
||||
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
@@ -52,6 +52,11 @@ export class MaintenanceWorkerController {
|
||||
return this.service.getSystemConfig();
|
||||
}
|
||||
|
||||
@Get('server/ping')
|
||||
pingServer(): ServerPingResponse {
|
||||
return this.service.ping();
|
||||
}
|
||||
|
||||
@Get('server/version')
|
||||
getServerVersion(): ServerVersionResponseDto {
|
||||
return this.service.getVersion();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
@@ -121,6 +121,10 @@ export class MaintenanceWorkerService {
|
||||
return ServerVersionResponseDto.fromSemVer(serverVersion);
|
||||
}
|
||||
|
||||
ping(): ServerPingResponse {
|
||||
return { res: 'pong' };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _ApiService.ssr}
|
||||
*/
|
||||
|
||||
@@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||
import { NextFunction, RequestHandler } from 'express';
|
||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
||||
import multer from 'multer';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream';
|
||||
import { Observable } from 'rxjs';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { RouteKey } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
@@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) {
|
||||
};
|
||||
}
|
||||
|
||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
||||
|
||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
||||
|
||||
interface Callback<T> {
|
||||
@@ -35,34 +36,21 @@ interface Callback<T> {
|
||||
(error: null, result: T): void;
|
||||
}
|
||||
|
||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
||||
try {
|
||||
return callback(null, target());
|
||||
} catch (error: Error | any) {
|
||||
return callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadInterceptor implements NestInterceptor {
|
||||
private handlers: {
|
||||
userProfile: RequestHandler;
|
||||
assetUpload: RequestHandler;
|
||||
};
|
||||
private defaultStorage: StorageEngine;
|
||||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetMediaService,
|
||||
private storageRepository: StorageRepository,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(FileUploadInterceptor.name);
|
||||
|
||||
this.defaultStorage = diskStorage({
|
||||
filename: this.filename.bind(this),
|
||||
destination: this.destination.bind(this),
|
||||
});
|
||||
|
||||
const instance = multer({
|
||||
fileFilter: this.fileFilter.bind(this),
|
||||
storage: {
|
||||
@@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
try {
|
||||
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
|
||||
} catch (error: Error | any) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
|
||||
request.on('error', (error) => {
|
||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
||||
});
|
||||
|
||||
if (!this.isAssetUploadFile(file)) {
|
||||
this.defaultStorage._handleFile(request, file, callback);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...info, checksum: hash.digest() });
|
||||
}
|
||||
});
|
||||
const uploadRequest = asUploadRequest(request, file);
|
||||
|
||||
const path = join(
|
||||
this.assetService.getUploadFolder(uploadRequest),
|
||||
this.assetService.getUploadFilename(uploadRequest),
|
||||
);
|
||||
|
||||
const writeStream = this.storageRepository.createWriteStream(path);
|
||||
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
|
||||
|
||||
let size = 0;
|
||||
|
||||
file.stream.on('data', (chunk) => {
|
||||
hash?.update(chunk);
|
||||
size += chunk.length;
|
||||
});
|
||||
|
||||
pipeline(file.stream, writeStream, (error) => {
|
||||
if (error) {
|
||||
hash?.destroy();
|
||||
return callback(error);
|
||||
}
|
||||
callback(null, {
|
||||
path,
|
||||
size,
|
||||
checksum: hash?.digest(),
|
||||
});
|
||||
});
|
||||
} catch (error: Error | any) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.defaultStorage._removeFile(request, file, callback);
|
||||
}
|
||||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.storageRepository
|
||||
.unlink(file.path)
|
||||
.then(() => callback(null))
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
private getHandler(route: RouteKey) {
|
||||
|
||||
@@ -63,7 +63,7 @@ export class StorageRepository {
|
||||
}
|
||||
|
||||
createWriteStream(filepath: string): Writable {
|
||||
return createWriteStream(filepath, { flags: 'w' });
|
||||
return createWriteStream(filepath, { flags: 'w', flush: true });
|
||||
}
|
||||
|
||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotAcceptableException } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -72,6 +72,13 @@ export class ApiService {
|
||||
return next();
|
||||
}
|
||||
|
||||
const responseType = request.accepts('text/html');
|
||||
if (!responseType) {
|
||||
throw new NotAcceptableException(
|
||||
`The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
|
||||
);
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let html = index;
|
||||
|
||||
@@ -105,7 +112,7 @@ export class ApiService {
|
||||
html = render(index, meta);
|
||||
}
|
||||
|
||||
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
|
||||
res.status(status).type(responseType).header('Cache-Control', 'no-store').send(html);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
|
||||
duration: null,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
|
||||
duration: null,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
|
||||
@@ -155,6 +155,33 @@ describe('transformFaceBoundingBox', () => {
|
||||
expect(result.boundingBoxX2).toBe(50);
|
||||
expect(result.boundingBoxY2).toBe(50);
|
||||
});
|
||||
|
||||
it('should always return whole numbers', () => {
|
||||
const edits: AssetEditActionItem[] = [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 250, height: 250 } },
|
||||
];
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 1000, height: 400 })).toMatchObject({
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 50,
|
||||
});
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 1001, height: 401 })).toMatchObject({
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 50,
|
||||
});
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 999, height: 399 })).toMatchObject({
|
||||
boundingBoxX1: 49,
|
||||
boundingBoxY1: -0,
|
||||
boundingBoxX2: 149,
|
||||
boundingBoxY2: 49,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,10 +179,10 @@ export const transformFaceBoundingBox = (
|
||||
// Ensure x1,y1 is top-left and x2,y2 is bottom-right
|
||||
const [p1, p2] = transformedPoints;
|
||||
return {
|
||||
boundingBoxX1: Math.min(p1.x, p2.x),
|
||||
boundingBoxY1: Math.min(p1.y, p2.y),
|
||||
boundingBoxX2: Math.max(p1.x, p2.x),
|
||||
boundingBoxY2: Math.max(p1.y, p2.y),
|
||||
boundingBoxX1: Math.trunc(Math.min(p1.x, p2.x)),
|
||||
boundingBoxY1: Math.trunc(Math.min(p1.y, p2.y)),
|
||||
boundingBoxX2: Math.trunc(Math.max(p1.x, p2.x)),
|
||||
boundingBoxY2: Math.trunc(Math.max(p1.y, p2.y)),
|
||||
imageWidth: currentWidth,
|
||||
imageHeight: currentHeight,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
UserLike,
|
||||
} from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AssetFactory {
|
||||
#owner!: UserFactory;
|
||||
@@ -43,10 +43,12 @@ export class AssetFactory {
|
||||
|
||||
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
return new AssetFactory({
|
||||
id,
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
createdAt: new Date(now++),
|
||||
updatedAt: new Date(now++),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
@@ -55,14 +57,14 @@ export class AssetFactory {
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
fileCreatedAt: new Date(now++),
|
||||
fileModifiedAt: new Date(now++),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
localDateTime: new Date(now),
|
||||
originalFileName,
|
||||
originalPath: `/data/library/${originalFileName}`,
|
||||
ownerId: newUuid(),
|
||||
|
||||
@@ -591,10 +591,10 @@ describe(PersonService.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: expect.closeTo(25, 1),
|
||||
boundingBoxY1: expect.closeTo(50, 1),
|
||||
boundingBoxX2: expect.closeTo(100, 1),
|
||||
boundingBoxY2: expect.closeTo(100, 1),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 49,
|
||||
boundingBoxX2: 99,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
onLoad={() => adaptiveImageLoader.onLoad(quality)}
|
||||
onError={() => adaptiveImageLoader.onError(quality)}
|
||||
bind:ref
|
||||
class="h-full w-full bg-transparent"
|
||||
class="h-full w-full bg-transparent pointer-events-auto"
|
||||
{alt}
|
||||
{role}
|
||||
draggable={false}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
switch (dto.command) {
|
||||
case QueueCommand.Empty: {
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
try {
|
||||
await unlinkAllOAuthAccountsAdmin();
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
|
||||
toastManager.primary($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
|
||||
|
||||
if (!disabled) {
|
||||
await handleSystemConfigSave({ notifications: configToEdit.notifications });
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
{#each albums as album, index (album.id)}
|
||||
<a
|
||||
href={Route.viewAlbum(album)}
|
||||
class="h-fit"
|
||||
animate:flip={{ duration: 400 }}
|
||||
oncontextmenu={(event) => oncontextmenu(event, album)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -36,14 +37,22 @@
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const styles = tv({
|
||||
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
|
||||
variants: {
|
||||
isOwned: {
|
||||
true: 'hover:border-gray-400',
|
||||
false: 'hover:border-transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<input
|
||||
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
|
||||
onblur={handleUpdateName}
|
||||
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
|
||||
? 'hover:border-gray-400'
|
||||
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
|
||||
class={styles({ isOwned })}
|
||||
type="text"
|
||||
bind:value={newAlbumName}
|
||||
disabled={!isOwned}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
||||
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
toastManager.success($t('permanently_deleted_asset'));
|
||||
toastManager.primary($t('permanently_deleted_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_asset'));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
|
||||
asset.isTrashed = false;
|
||||
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
|
||||
toastManager.success($t('restored_asset'));
|
||||
toastManager.primary($t('restored_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_assets'));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
});
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
toastManager.success($t('album_cover_updated'));
|
||||
toastManager.primary($t('album_cover_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_album_cover'));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
person,
|
||||
});
|
||||
|
||||
toastManager.success($t('feature_photo_updated'));
|
||||
toastManager.primary($t('feature_photo_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_feature_photo'));
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
[ReactionType.Comment]: $t('comment_deleted'),
|
||||
[ReactionType.Like]: $t('like_deleted'),
|
||||
};
|
||||
toastManager.success(deleteMessages[reaction.type]);
|
||||
toastManager.primary(deleteMessages[reaction.type]);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_reaction'));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
@@ -88,7 +89,7 @@
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
|
||||
$if: () => !!onClose,
|
||||
$if: () => !!onClose && !isFaceEditMode.value,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
@@ -101,11 +100,10 @@
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset: AssetResponseDto | undefined = $state();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
const asset = $derived(previewStackedAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
@@ -118,25 +116,17 @@
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
|
||||
const selectStackedAsset = async (id: string) => {
|
||||
selectedStackAsset = await assetCacheManager.getAsset({ id });
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
selectedStackAsset = undefined;
|
||||
return;
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
}
|
||||
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
||||
if (primaryAsset) {
|
||||
await selectStackedAsset(primaryAsset.id);
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,21 +184,11 @@
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const refreshPreservingSelection = async () => {
|
||||
const id = asset.id;
|
||||
assetCacheManager.invalidateAsset(id);
|
||||
if (selectedStackAsset) {
|
||||
await selectStackedAsset(id);
|
||||
} else {
|
||||
const asset = await assetCacheManager.getAsset({ id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
}
|
||||
onAssetChange?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
await refreshPreservingSelection();
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
@@ -308,6 +288,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
@@ -320,7 +304,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack ?? undefined;
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
@@ -387,7 +371,7 @@
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
cursor.current;
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
@@ -551,12 +535,7 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
onTagFace={refreshPreservingSelection}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -606,7 +585,7 @@
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<div class="w-100 h-full">
|
||||
@@ -619,14 +598,10 @@
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div
|
||||
role="presentation"
|
||||
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||
onmouseleave={() => (previewStackedAsset = undefined)}
|
||||
>
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -634,25 +609,22 @@
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={async () => {
|
||||
await selectStackedAsset(stackedAsset.id);
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={async ({ isMouseOver }) => {
|
||||
if (isMouseOver) {
|
||||
previewStackedAsset = stackedAsset;
|
||||
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
|
||||
}
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
|
||||
</div>
|
||||
{#if stackedAsset.id === asset.id}
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description } });
|
||||
toastManager.success($t('asset_description_updated'));
|
||||
toastManager.primary($t('asset_description_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -46,10 +52,9 @@
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
onRefreshPeople?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -115,6 +120,11 @@
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
showEditFaces = false;
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
// Remove the last part of the path to get the parent path
|
||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||
@@ -565,6 +575,6 @@
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => (showEditFaces = false)}
|
||||
onRefresh={() => void onRefreshPeople?.()}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { clamp } from 'lodash-es';
|
||||
@@ -16,10 +18,9 @@
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
onTagFace?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
@@ -280,7 +281,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
await onTagFace?.();
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
@@ -289,6 +290,8 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
|
||||
|
||||
<div
|
||||
id="face-editor-data"
|
||||
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||
|
||||
@@ -32,10 +32,9 @@
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
onTagFace?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
@@ -267,12 +266,6 @@
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor
|
||||
htmlElement={assetViewerManager.imgRef}
|
||||
{containerWidth}
|
||||
{containerHeight}
|
||||
assetId={asset.id}
|
||||
{onTagFace}
|
||||
/>
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
if (failCount > 0) {
|
||||
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
|
||||
}
|
||||
toastManager.success($t('visibility_changed', { values: { count: successCount } }));
|
||||
toastManager.primary($t('visibility_changed', { values: { count: successCount } }));
|
||||
}
|
||||
|
||||
for (const person of people) {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
});
|
||||
const mergedPerson = await getPerson({ id: person.id });
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.success($t('merged_people_count', { values: { count } }));
|
||||
toastManager.primary($t('merged_people_count', { values: { count } }));
|
||||
onMerge(mergedPerson);
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_merge_people'));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -125,7 +126,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
toastManager.success($t('people_edits_count', { values: { count: numberOfChanges } }));
|
||||
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_apply_changes'));
|
||||
}
|
||||
@@ -178,10 +179,7 @@
|
||||
|
||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||
|
||||
onRefresh();
|
||||
if (peopleWithFaces.length === 0) {
|
||||
onClose();
|
||||
}
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, $t('error_delete_face'));
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
disableButtons = true;
|
||||
const data = await createPerson({ personCreateDto: {} });
|
||||
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
|
||||
toastManager.success($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
|
||||
toastManager.primary($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reassign_assets_new_person'));
|
||||
} finally {
|
||||
@@ -88,7 +88,7 @@
|
||||
disableButtons = true;
|
||||
if (selectedPerson) {
|
||||
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
|
||||
toastManager.success(
|
||||
toastManager.primary(
|
||||
$t('reassigned_assets_to_existing_person', {
|
||||
values: { count: assetIds.length, name: selectedPerson.name || null },
|
||||
}),
|
||||
|
||||
@@ -63,8 +63,9 @@
|
||||
let playerInitialized = $state(false);
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
let currentMemoryAssetFull = $derived.by(async () =>
|
||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||
const currentAssetId = $derived(current?.asset.id);
|
||||
const currentMemoryAssetFull = $derived.by(async () =>
|
||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
|
||||
let viewerAssets = $derived([
|
||||
@@ -205,7 +206,7 @@
|
||||
}
|
||||
|
||||
await memoryStore.deleteMemory(current.memory.id);
|
||||
toastManager.success($t('removed_memory'));
|
||||
toastManager.primary($t('removed_memory'));
|
||||
init(page);
|
||||
};
|
||||
|
||||
@@ -216,7 +217,7 @@
|
||||
|
||||
const newSavedState = !current.memory.isSaved;
|
||||
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
|
||||
toastManager.success(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
|
||||
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
|
||||
init(page);
|
||||
};
|
||||
|
||||
@@ -550,14 +551,18 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IconButton
|
||||
href={Route.photos({ at: current.asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
? openFileUploadDialog()
|
||||
: fileUploadHandler({ files }));
|
||||
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_assets_to_shared_link'));
|
||||
}
|
||||
|
||||
@@ -345,8 +345,10 @@
|
||||
{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: (event) => {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
if (isOpen) {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import SearchHistoryBox from './search-history-box.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
value?: string;
|
||||
grayTheme: boolean;
|
||||
searchQuery?: MetadataSearchDto | SmartSearchDto;
|
||||
}
|
||||
};
|
||||
|
||||
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchCameraFilter {
|
||||
make?: string;
|
||||
model?: string;
|
||||
lensModel?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import type { SearchCameraFilter } from '$lib/types';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
filters: SearchCameraFilter;
|
||||
}
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDateFilter {
|
||||
takenBefore?: DateTime;
|
||||
takenAfter?: DateTime;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { SearchDateFilter } from '$lib/types';
|
||||
import { DatePicker, Text } from '@immich/ui';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
filters: SearchDateFilter;
|
||||
}
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDisplayFilters {
|
||||
isNotInAlbum: boolean;
|
||||
isArchive: boolean;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { SearchDisplayFilters } from '$lib/types';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
filters: SearchDisplayFilters;
|
||||
}
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchLocationFilter {
|
||||
country?: string;
|
||||
state?: string;
|
||||
city?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import type { SearchLocationFilter } from '$lib/types';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
filters: SearchLocationFilter;
|
||||
}
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
interface Props {
|
||||
selectedPeople: SvelteSet<string>;
|
||||
@@ -49,6 +50,16 @@
|
||||
const nameLower = name.toLowerCase();
|
||||
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
|
||||
};
|
||||
|
||||
const styles = tv({
|
||||
base: 'flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all',
|
||||
variants: {
|
||||
selected: {
|
||||
true: 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white',
|
||||
false: 'border-transparent',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await peoplePromise}
|
||||
@@ -74,11 +85,7 @@
|
||||
{#each peopleList as person (person.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
|
||||
person.id,
|
||||
)
|
||||
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||
: 'border-transparent'}"
|
||||
class={styles({ selected: selectedPeople.has(person.id) })}
|
||||
onclick={() => togglePersonSelection(person.id)}
|
||||
>
|
||||
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
children?: import('svelte').Snippet<[{ itemCount: number }]>;
|
||||
}
|
||||
|
||||
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
|
||||
let { class: className, itemCount = $bindable(1), children }: Props = $props();
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let contentRect: DOMRectReadOnly | undefined = $state();
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
if ($stats.errors > 0) {
|
||||
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
|
||||
} else if ($stats.success > 0) {
|
||||
toastManager.success($t('upload_success'));
|
||||
toastManager.primary($t('upload_success'));
|
||||
}
|
||||
if ($stats.duplicates > 0) {
|
||||
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
onFavorite?.(ids, isFavorite);
|
||||
|
||||
toastManager.success(
|
||||
toastManager.primary(
|
||||
isFavorite
|
||||
? $t('added_to_favorites_count', { values: { count: ids.length } })
|
||||
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
onRemove?.(ids);
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.success($t('assets_removed_count', { values: { count } }));
|
||||
toastManager.primary($t('assets_removed_count', { values: { count } }));
|
||||
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
const ids = [...getAssets()].map((a) => a.id);
|
||||
await restoreAssets({ bulkIdsDto: { ids } });
|
||||
onRestore?.(ids);
|
||||
toastManager.success($t('assets_restored_count', { values: { count: ids.length } }));
|
||||
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_assets'));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
try {
|
||||
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||
resetForm();
|
||||
toastManager.success($t('pin_code_changed_successfully'));
|
||||
toastManager.primary($t('pin_code_changed_successfully'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_change_pin_code'));
|
||||
} finally {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
isLoading = true;
|
||||
try {
|
||||
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
|
||||
toastManager.success($t('pin_code_setup_successfully'));
|
||||
toastManager.primary($t('pin_code_setup_successfully'));
|
||||
onCreated?.(newPinCode);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
|
||||
@@ -49,25 +49,22 @@
|
||||
|
||||
const handleInput = (event: Event, index: number) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
let currentPinValue = target.value;
|
||||
const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index);
|
||||
|
||||
if (target.value.length > 1) {
|
||||
currentPinValue = value.slice(0, 1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(Number(value))) {
|
||||
if (digits.length === 0) {
|
||||
pinValues[index] = '';
|
||||
target.value = '';
|
||||
value = pinValues.join('').trim();
|
||||
return;
|
||||
}
|
||||
|
||||
pinValues[index] = currentPinValue;
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
pinValues[index + i] = digits[i];
|
||||
}
|
||||
|
||||
value = pinValues.join('').trim();
|
||||
|
||||
if (value && index < pinLength - 1) {
|
||||
focusNext(index);
|
||||
}
|
||||
const lastFilledIndex = Math.min(index + digits.length, pinLength - 1);
|
||||
pinCodeInputElements[lastFilledIndex]?.focus();
|
||||
|
||||
if (value.length === pinLength) {
|
||||
onFilled?.(value);
|
||||
@@ -104,12 +101,6 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
if (Number.isNaN(Number(event.key))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -125,7 +116,6 @@
|
||||
{type}
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="1"
|
||||
bind:this={pinCodeInputElements[index]}
|
||||
id="pin-code-{index}"
|
||||
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
try {
|
||||
await deleteSession({ id: device.id });
|
||||
toastManager.success($t('logged_out_device'));
|
||||
toastManager.primary($t('logged_out_device'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_log_out_device'));
|
||||
} finally {
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
try {
|
||||
await deleteAllSessions();
|
||||
toastManager.success($t('logged_out_all_devices'));
|
||||
toastManager.primary($t('logged_out_all_devices'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_log_out_all_devices'));
|
||||
} finally {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
});
|
||||
$preferences = newPreferences;
|
||||
|
||||
toastManager.success($t('saved_settings'));
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
$preferences = { ...data };
|
||||
|
||||
toastManager.success($t('saved_settings'));
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
|
||||
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
|
||||
|
||||
toastManager.success($t('saved_settings'));
|
||||
toastManager.primary($t('saved_settings'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_settings'));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
try {
|
||||
loading = true;
|
||||
user = await oauth.link(globalThis.location);
|
||||
toastManager.success($t('linked_oauth_account'));
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_link_oauth_account'));
|
||||
} finally {
|
||||
@@ -36,7 +36,7 @@
|
||||
const handleUnlink = async () => {
|
||||
try {
|
||||
user = await oauth.unlink();
|
||||
toastManager.success($t('unlinked_oauth_account'));
|
||||
toastManager.primary($t('unlinked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_unlink_account'));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
Object.assign(editedUser, data);
|
||||
$user = data;
|
||||
|
||||
toastManager.success($t('saved_profile'));
|
||||
toastManager.primary($t('saved_profile'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_profile'));
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export class EditManager {
|
||||
|
||||
eventManager.emit('AssetEditsApplied', assetId);
|
||||
|
||||
toastManager.success(t('editor_edits_applied_success'));
|
||||
toastManager.primary(t('editor_edits_applied_success'));
|
||||
this.hasAppliedEdits = true;
|
||||
|
||||
return true;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
await deleteProfileImage();
|
||||
}
|
||||
|
||||
toastManager.success($t('saved_profile'));
|
||||
toastManager.primary($t('saved_profile'));
|
||||
|
||||
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
|
||||
onClose();
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
id: personToBeMergedInto.id,
|
||||
mergePersonDto: { ids: [personToMerge.id] },
|
||||
});
|
||||
toastManager.success($t('merge_people_successfully'));
|
||||
toastManager.primary($t('merge_people_successfully'));
|
||||
onClose([personToMerge, personToBeMergedInto]);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_name'));
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
}
|
||||
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
|
||||
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
|
||||
toastManager.success($t('profile_picture_set'));
|
||||
toastManager.primary($t('profile_picture_set'));
|
||||
$user.profileImagePath = profileImagePath;
|
||||
$user.profileChangedAt = profileChangedAt;
|
||||
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||
import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
|
||||
import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
|
||||
import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
|
||||
|
||||
export type SearchFilter = {
|
||||
query: string;
|
||||
ocr?: string;
|
||||
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
|
||||
personIds: SvelteSet<string>;
|
||||
tagIds: SvelteSet<string> | null;
|
||||
location: SearchLocationFilter;
|
||||
camera: SearchCameraFilter;
|
||||
date: SearchDateFilter;
|
||||
display: SearchDisplayFilters;
|
||||
mediaType: MediaType;
|
||||
rating?: number | null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SearchCameraSection, {
|
||||
type SearchCameraFilter,
|
||||
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
|
||||
import SearchCameraSection from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
|
||||
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
|
||||
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
|
||||
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
|
||||
@@ -31,7 +8,9 @@
|
||||
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
|
||||
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
|
||||
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
|
||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import type { SearchFilter } from '$lib/types';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
@@ -41,10 +20,10 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
searchQuery: MetadataSearchDto | SmartSearchDto;
|
||||
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { searchQuery, onClose }: Props = $props();
|
||||
|
||||
@@ -66,52 +45,57 @@
|
||||
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
|
||||
}
|
||||
|
||||
let query = '';
|
||||
if ('query' in searchQuery && searchQuery.query) {
|
||||
query = searchQuery.query;
|
||||
}
|
||||
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
|
||||
query = searchQuery.originalFileName;
|
||||
}
|
||||
const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => {
|
||||
let query = '';
|
||||
if ('query' in searchQuery && searchQuery.query) {
|
||||
query = searchQuery.query;
|
||||
}
|
||||
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
|
||||
query = searchQuery.originalFileName;
|
||||
}
|
||||
|
||||
let filter: SearchFilter = $state({
|
||||
query,
|
||||
ocr: searchQuery.ocr,
|
||||
queryType: defaultQueryType(),
|
||||
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||
tagIds:
|
||||
'tagIds' in searchQuery
|
||||
? searchQuery.tagIds === null
|
||||
? null
|
||||
: new SvelteSet(searchQuery.tagIds)
|
||||
: new SvelteSet(),
|
||||
location: {
|
||||
country: withNullAsUndefined(searchQuery.country),
|
||||
state: withNullAsUndefined(searchQuery.state),
|
||||
city: withNullAsUndefined(searchQuery.city),
|
||||
},
|
||||
camera: {
|
||||
make: withNullAsUndefined(searchQuery.make),
|
||||
model: withNullAsUndefined(searchQuery.model),
|
||||
lensModel: withNullAsUndefined(searchQuery.lensModel),
|
||||
},
|
||||
date: {
|
||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
|
||||
},
|
||||
display: {
|
||||
isArchive: searchQuery.visibility === AssetVisibility.Archive,
|
||||
isFavorite: searchQuery.isFavorite ?? false,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
|
||||
},
|
||||
mediaType:
|
||||
searchQuery.type === AssetTypeEnum.Image
|
||||
? MediaType.Image
|
||||
: searchQuery.type === AssetTypeEnum.Video
|
||||
? MediaType.Video
|
||||
: MediaType.All,
|
||||
rating: searchQuery.rating,
|
||||
});
|
||||
return {
|
||||
query,
|
||||
ocr: searchQuery.ocr,
|
||||
queryType: defaultQueryType(),
|
||||
queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined,
|
||||
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||
tagIds:
|
||||
'tagIds' in searchQuery
|
||||
? searchQuery.tagIds === null
|
||||
? null
|
||||
: new SvelteSet(searchQuery.tagIds)
|
||||
: new SvelteSet(),
|
||||
location: {
|
||||
country: withNullAsUndefined(searchQuery.country),
|
||||
state: withNullAsUndefined(searchQuery.state),
|
||||
city: withNullAsUndefined(searchQuery.city),
|
||||
},
|
||||
camera: {
|
||||
make: withNullAsUndefined(searchQuery.make),
|
||||
model: withNullAsUndefined(searchQuery.model),
|
||||
lensModel: withNullAsUndefined(searchQuery.lensModel),
|
||||
},
|
||||
date: {
|
||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
|
||||
},
|
||||
display: {
|
||||
isArchive: searchQuery.visibility === AssetVisibility.Archive,
|
||||
isFavorite: searchQuery.isFavorite ?? false,
|
||||
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
|
||||
},
|
||||
mediaType:
|
||||
searchQuery.type === AssetTypeEnum.Image
|
||||
? MediaType.Image
|
||||
: searchQuery.type === AssetTypeEnum.Video
|
||||
? MediaType.Video
|
||||
: MediaType.All,
|
||||
rating: searchQuery.rating,
|
||||
};
|
||||
};
|
||||
|
||||
let filter: SearchFilter = $state(asFilter(searchQuery));
|
||||
|
||||
const resetForm = () => {
|
||||
filter = {
|
||||
@@ -145,6 +129,7 @@
|
||||
|
||||
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||
query: filter.queryType === 'smart' ? query : undefined,
|
||||
queryAssetId: filter.queryAssetId || undefined,
|
||||
ocr: filter.queryType === 'ocr' ? query : undefined,
|
||||
originalFileName: filter.queryType === 'metadata' ? query : undefined,
|
||||
description: filter.queryType === 'description' ? query : undefined,
|
||||
|
||||
@@ -163,7 +163,7 @@ const notifyAddToAlbums = (
|
||||
} else if (results.error) {
|
||||
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
|
||||
} else {
|
||||
toastManager.success(
|
||||
toastManager.primary(
|
||||
$t('assets_added_to_albums_count', {
|
||||
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
|
||||
}),
|
||||
@@ -269,7 +269,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
|
||||
await deleteAlbum({ id: album.id });
|
||||
eventManager.emit('AlbumDelete', album);
|
||||
if (notify) {
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpda
|
||||
try {
|
||||
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
|
||||
eventManager.emit('ApiKeyUpdate', response);
|
||||
toastManager.success($t('saved_api_key'));
|
||||
toastManager.primary($t('saved_api_key'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_api_key'));
|
||||
@@ -98,7 +98,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
|
||||
try {
|
||||
await deleteApiKey({ id: apiKey.id });
|
||||
eventManager.emit('ApiKeyDelete', apiKey);
|
||||
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
|
||||
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_api_key'));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { vitest } from 'vitest';
|
||||
|
||||
vitest.mock('@immich/ui', () => ({
|
||||
toastManager: {
|
||||
success: vitest.fn(),
|
||||
primary: vitest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('AssetService', () => {
|
||||
const asset = assetFactory.build({ originalFileName: 'asset.heic' });
|
||||
await handleDownloadAsset(asset, { edited: false });
|
||||
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
|
||||
expect(toastManager.success).toHaveBeenCalledWith('formatter');
|
||||
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
|
||||
});
|
||||
|
||||
it('should use the motion asset originalFileName when showing toasts', async () => {
|
||||
@@ -79,7 +79,7 @@ describe('AssetService', () => {
|
||||
await handleDownloadAsset(asset, { edited: false });
|
||||
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
|
||||
expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } });
|
||||
expect(toastManager.success).toHaveBeenCalledWith('formatter');
|
||||
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,7 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
}
|
||||
|
||||
try {
|
||||
toastManager.success($t('downloading_asset_filename', { values: { filename } }));
|
||||
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
||||
downloadUrl(
|
||||
getBaseUrl() +
|
||||
`/assets/${id}/original` +
|
||||
@@ -352,7 +352,7 @@ const handleFavorite = async (asset: AssetResponseDto) => {
|
||||
|
||||
try {
|
||||
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
|
||||
toastManager.success($t('added_to_favorites'));
|
||||
toastManager.primary($t('added_to_favorites'));
|
||||
eventManager.emit('AssetUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
|
||||
@@ -364,7 +364,7 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
|
||||
|
||||
try {
|
||||
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
|
||||
toastManager.success($t('removed_from_favorites'));
|
||||
toastManager.primary($t('removed_from_favorites'));
|
||||
eventManager.emit('AssetUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
|
||||
@@ -387,7 +387,7 @@ const handleRunAssetJob = async (dto: AssetJobsDto) => {
|
||||
|
||||
try {
|
||||
await runAssetJobs({ assetJobsDto: dto });
|
||||
toastManager.success(getAssetJobMessage($t, dto.name));
|
||||
toastManager.primary(getAssetJobMessage($t, dto.name));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const handleCreateJob = async (dto: JobCreateDto) => {
|
||||
|
||||
try {
|
||||
await createJob({ jobCreateDto: dto });
|
||||
toastManager.success($t('admin.job_created'));
|
||||
toastManager.primary($t('admin.job_created'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
|
||||
@@ -161,7 +161,7 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||
try {
|
||||
const library = await createLibrary({ createLibraryDto: dto });
|
||||
eventManager.emit('LibraryCreate', library);
|
||||
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||
toastManager.primary($t('admin.library_created', { values: { library: library.name } }));
|
||||
return library;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_library'));
|
||||
@@ -174,7 +174,7 @@ export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: Upda
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
@@ -205,7 +205,7 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
||||
try {
|
||||
await deleteLibrary({ id: library.id });
|
||||
eventManager.emit('LibraryDelete', { id: library.id });
|
||||
toastManager.success($t('admin.library_deleted'));
|
||||
toastManager.primary($t('admin.library_deleted'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_library'));
|
||||
}
|
||||
@@ -225,7 +225,7 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
|
||||
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
@@ -246,7 +246,7 @@ export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldVa
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
|
||||
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
}
|
||||
@@ -293,7 +293,7 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
|
||||
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
@@ -314,7 +314,7 @@ export const handleEditExclusionPattern = async (library: LibraryResponseDto, ol
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
@@ -339,7 +339,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
|
||||
},
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
toastManager.primary($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const handleFavoritePerson = async (person: { id: string }) => {
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: true } });
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
toastManager.success($t('added_to_favorites'));
|
||||
toastManager.primary($t('added_to_favorites'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const handleUnfavoritePerson = async (person: { id: string }) => {
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: false } });
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
toastManager.success($t('removed_from_favorites'));
|
||||
toastManager.primary($t('removed_from_favorites'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const handleHidePerson = async (person: { id: string }) => {
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: true } });
|
||||
toastManager.success($t('changed_visibility_successfully'));
|
||||
toastManager.primary($t('changed_visibility_successfully'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_hide_person'));
|
||||
@@ -92,7 +92,7 @@ const handleShowPerson = async (person: { id: string }) => {
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: false } });
|
||||
toastManager.success($t('changed_visibility_successfully'));
|
||||
toastManager.primary($t('changed_visibility_successfully'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
@@ -104,7 +104,7 @@ export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, bir
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
|
||||
toastManager.success($t('date_of_birth_saved'));
|
||||
toastManager.primary($t('date_of_birth_saved'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -129,7 +129,7 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
|
||||
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
|
||||
const response = await getQueue({ name: queue.name });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
@@ -155,7 +155,7 @@ const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
|
||||
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
|
||||
const response = await getQueue({ name: queue.name });
|
||||
eventManager.emit('QueueUpdate', response);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
|
||||
const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto });
|
||||
|
||||
eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response });
|
||||
toastManager.success($t('saved'));
|
||||
toastManager.primary($t('saved'));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -118,7 +118,7 @@ const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
|
||||
try {
|
||||
await removeSharedLink({ id: sharedLink.id });
|
||||
eventManager.emit('SharedLinkDelete', sharedLink);
|
||||
toastManager.success($t('deleted_shared_link'));
|
||||
toastManager.primary($t('deleted_shared_link'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_shared_link'));
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons
|
||||
}
|
||||
|
||||
const count = results.filter((item) => item.success).length;
|
||||
toastManager.success($t('assets_removed_count', { values: { count } }));
|
||||
toastManager.primary($t('assets_removed_count', { values: { count } }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));
|
||||
|
||||
@@ -62,7 +62,7 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
|
||||
const newConfig = await updateConfig({ systemConfigDto });
|
||||
|
||||
eventManager.emit('SystemConfigUpdate', newConfig);
|
||||
toastManager.success($t('settings_saved'));
|
||||
toastManager.primary($t('settings_saved'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_settings'));
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const handleCreateTag = async (tagValue: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
|
||||
toastManager.primary($t('tag_created', { values: { tag: tag.value } }));
|
||||
eventManager.emit('TagCreate', tag);
|
||||
|
||||
return true;
|
||||
@@ -61,7 +61,7 @@ export const handleUpdateTag = async (tag: TreeNode, dto: TagUpdateDto) => {
|
||||
try {
|
||||
const response = await updateTag({ id: tag.id, tagUpdateDto: dto });
|
||||
|
||||
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
|
||||
toastManager.primary($t('tag_updated', { values: { tag: tag.value } }));
|
||||
eventManager.emit('TagUpdate', response);
|
||||
|
||||
return true;
|
||||
@@ -91,7 +91,7 @@ const handleDeleteTag = async (tag: TreeNode) => {
|
||||
try {
|
||||
await deleteTag({ id: tagId });
|
||||
eventManager.emit('TagDelete', tag);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const handleEmptyTrash = async () => {
|
||||
|
||||
try {
|
||||
const { count } = await emptyTrash();
|
||||
toastManager.success($t('assets_permanently_deleted_count', { values: { count } }));
|
||||
toastManager.primary($t('assets_permanently_deleted_count', { values: { count } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_empty_trash'));
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const handleRestoreTrash = async () => {
|
||||
|
||||
try {
|
||||
const { count } = await restoreTrash();
|
||||
toastManager.success($t('assets_restored_count', { values: { count } }));
|
||||
toastManager.primary($t('assets_restored_count', { values: { count } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_trash'));
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
||||
try {
|
||||
const response = await createUserAdmin({ userAdminCreateDto: dto });
|
||||
eventManager.emit('UserAdminCreate', response);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_user'));
|
||||
@@ -122,7 +122,7 @@ export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: Use
|
||||
try {
|
||||
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_user'));
|
||||
@@ -136,7 +136,7 @@ export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: Use
|
||||
try {
|
||||
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto });
|
||||
eventManager.emit('UserAdminDelete', result);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_user'));
|
||||
@@ -149,7 +149,7 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
try {
|
||||
const response = await restoreUserAdmin({ id: user.id });
|
||||
eventManager.emit('UserAdminRestore', response);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_restore_user'));
|
||||
@@ -190,7 +190,7 @@ const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
const dto = { password: generatePassword(), shouldChangePassword: true };
|
||||
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success();
|
||||
toastManager.primary();
|
||||
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_password'));
|
||||
@@ -208,7 +208,7 @@ const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
|
||||
try {
|
||||
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
|
||||
eventManager.emit('UserAdminUpdate', response);
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
toastManager.primary($t('pin_code_reset_successfully'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_pin_code'));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const handleResetPinCode = async (dto: PinCodeResetDto) => {
|
||||
|
||||
try {
|
||||
await resetPinCode({ pinCodeResetDto: dto });
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
toastManager.primary($t('pin_code_reset_successfully'));
|
||||
eventManager.emit('UserPinCodeReset');
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -52,7 +52,7 @@ export const handleChangePassword = async (dto: ChangePasswordDto) => {
|
||||
|
||||
try {
|
||||
await changePassword({ changePasswordDto: dto });
|
||||
toastManager.success($t('updated_password'));
|
||||
toastManager.primary($t('updated_password'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_password'));
|
||||
|
||||
@@ -397,7 +397,7 @@ export const handleToggleWorkflowEnabled = async (
|
||||
});
|
||||
|
||||
eventManager.emit('WorkflowUpdate', updated);
|
||||
toastManager.success($t('workflow_updated'));
|
||||
toastManager.primary($t('workflow_updated'));
|
||||
return updated;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_workflow'));
|
||||
@@ -419,7 +419,7 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
|
||||
try {
|
||||
await deleteWorkflow({ id: workflow.id });
|
||||
eventManager.emit('WorkflowDelete', workflow);
|
||||
toastManager.success($t('workflow_deleted'));
|
||||
toastManager.primary($t('workflow_deleted'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_workflow'));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user