merge: remote-tracking branch 'origin/main' into feat/database-restores

This commit is contained in:
izzy 2025-11-28 12:04:43 +00:00
commit db7169ea01
No known key found for this signature in database
GPG Key ID: 5059F398521BB0F6
53 changed files with 2349 additions and 2137 deletions

View File

@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:9c905a4ff69f00c4b2f98b40b6090ab3ab18d1a15ed1379733b8691aa1fcb271
image: ghcr.io/immich-app/mdq:main@sha256:73a05fc805dfd3bd29bebc08442aedfec5c419c5ad3421ec73edc5647233891a
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: '/language:${{matrix.language}}'

View File

@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read
@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read

View File

@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@ -62,7 +62,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@ -30,7 +30,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@ -571,7 +571,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:

View File

@ -52,7 +52,7 @@
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",

View File

@ -28,7 +28,7 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",

View File

@ -299,7 +299,7 @@ describe('crawl', () => {
.map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.sort();
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {

View File

@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@ -35,8 +35,8 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"exiftool-vendored": "^31.1.0",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@ -45,7 +45,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",

View File

@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
}
}
return [...selectedDays].sort((a, b) => b - a);
return [...selectedDays].toSorted((a, b) => b - a);
}
/**

View File

@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@ -67,6 +67,7 @@
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
"create_job": "Create job",
"cron_expression": "Cron expression",
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@ -74,6 +75,8 @@
"disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"export_config_as_json_description": "Download the current system config as a JSON file",
"external_libraries_page_description": "Admin external library page",
"external_library_management": "External Library Management",
"face_detection": "Face detection",
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
@ -102,6 +105,7 @@
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"image_thumbnail_title": "Thumbnail Settings",
"import_config_from_json_description": "Import system config by uploading a JSON config file",
"job_concurrency": "{job} concurrency",
"job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.",
@ -110,6 +114,7 @@
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_page_description": "Admin jobs page",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_details": "Library details",
@ -191,6 +196,7 @@
"maintenance_upload_backup": "Upload database backup file",
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
"manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
"map_enable_description": "Enable map features",
@ -296,8 +302,10 @@
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
"server_settings": "Server Settings",
"server_settings_description": "Manage server settings",
"server_stats_page_description": "Admin server statistics page",
"server_welcome_message": "Welcome message",
"server_welcome_message_description": "A message that is displayed on the login page.",
"settings_page_description": "Admin settings page",
"sidecar_job": "Sidecar metadata",
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
"slideshow_duration_description": "Number of seconds to display each image",
@ -416,6 +424,8 @@
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
"user_settings": "User Settings",
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check",
@ -736,6 +746,7 @@
"collapse_all": "Collapse all",
"color": "Color",
"color_theme": "Color theme",
"command": "Command",
"comment_deleted": "Comment deleted",
"comment_options": "Comment options",
"comments_and_likes": "Comments & likes",
@ -1542,6 +1553,7 @@
"other_variables": "Other variables",
"owned": "Owned",
"owner": "Owner",
"page": "Page",
"partner": "Partner",
"partner_can_access": "{partner} can access",
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
@ -2102,6 +2114,7 @@
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"toggle_theme_description": "Toggle theme",
"total": "Total",
"total_usage": "Total usage",
"trash": "Trash",

View File

@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM builder-cpu AS builder-openvino
@ -22,7 +22,7 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1"
@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM prod-cpu AS prod-armnn

3598
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

538
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@ onlyBuiltDependencies:
- bcrypt
overrides:
canvas: 2.11.2
sharp: ^0.34.4
sharp: ^0.34.5
packageExtensions:
nestjs-kysely:
dependencies:

View File

@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202511181104@sha256:fd445b91d4db131aae71b143b647d2262818dac80946078ce231c79cb9acecba AS builder
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@ -69,7 +69,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511181104@sha256:1bc2b7cebc4fd3296dc33a5779411e1c7d854ea713066c1e024d54f45f176f89
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202511181104@sha256:fd445b91d4db131aae71b143b647d2262818dac80946078ce231c79cb9acecba AS dev
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
@ -44,18 +44,18 @@ FROM dev-container-server AS dev-container-mobile
USER root
# Enable multiarch for arm64 if necessary
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg \
qemu-user-static \
libc6:amd64 \
libstdc++6:amd64 \
libgcc1:amd64; \
dpkg --add-architecture amd64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg \
qemu-user-static \
libc6:amd64 \
libstdc++6:amd64 \
libgcc1:amd64; \
else \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg; \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg; \
fi
# Flutter SDK

View File

@ -70,7 +70,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.3",
"exiftool-vendored": "^31.1.0",
"exiftool-vendored": "^33.0.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@ -105,7 +105,7 @@
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
@ -148,10 +148,10 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^11.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",
@ -169,6 +169,6 @@
"node": "24.11.1"
},
"overrides": {
"sharp": "^0.34.4"
"sharp": "^0.34.5"
}
}

View File

@ -50,6 +50,7 @@ export class AssetDeltaSyncResponseDto {
export const extraSyncModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
// eslint-disable-next-line unicorn/consistent-function-scoping
return (object: Function) => {
extraSyncModels.push(object);
};

View File

@ -257,7 +257,7 @@ describe('getEnv', () => {
expect(telemetry).toEqual({
apiPort: 8081,
microservicesPort: 8082,
metrics: new Set([]),
metrics: new Set(),
});
});

View File

@ -669,7 +669,7 @@ describe(AlbumService.name, () => {
});
it('should not allow a shared user with viewer access to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([]));
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
await expect(

View File

@ -278,7 +278,7 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
const response = await sut.handleQueueSyncAssets({ id: library.id });
@ -296,7 +296,7 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
const response = await sut.handleQueueSyncAssets({ id: library.id });
@ -311,7 +311,7 @@ describe(LibraryService.name, () => {
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external]));
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(0) });
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external]));
const response = await sut.handleQueueSyncAssets({ id: library.id });

View File

@ -223,7 +223,14 @@ export class LibraryService extends BaseService {
ownerId: dto.ownerId,
name: dto.name ?? 'New External Library',
importPaths: dto.importPaths ?? [],
exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'],
exclusionPatterns: dto.exclusionPatterns ?? [
'**/@eaDir/**',
'**/._*',
'**/#recycle/**',
'**/#snapshot/**',
'**/.stversions/**',
'**/.stfolder/**',
],
});
return mapLibrary(library);
}

View File

@ -561,7 +561,7 @@ export class MediaService extends BaseService {
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
return streams
.filter((stream) => stream.codecName !== 'unknown')
.sort((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0];
.toSorted((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0];
}
private getTranscodeTarget(

View File

@ -37,7 +37,6 @@ import { upsertTags } from 'src/utils/tag';
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
'SubSecDateTimeOriginal',
'SubSecCreateDate',
'SubSecMediaCreateDate',
'DateTimeOriginal',
'CreationDate',
'CreateDate',

View File

@ -217,7 +217,7 @@ describe(TagService.name, () => {
describe('addAssets', () => {
it('should handle invalid ids', async () => {
mocks.tag.getAssetIds.mockResolvedValue(new Set([]));
mocks.tag.getAssetIds.mockResolvedValue(new Set());
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'no_permission' },
]);

View File

@ -46,7 +46,7 @@ export const setIsEqual = (source: Set<unknown>, target: Set<unknown>) =>
source.size === target.size && [...source].every((x) => target.has(x));
export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => {
return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? []));
return setIsEqual(new Set(sourceColumns), new Set(targetColumns));
};
export const haveEqualOverrides = <T extends { override?: DatabaseOverride }>(source: T, target: T) => {

View File

@ -704,8 +704,7 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
}
getBitrateOptions() {
const options = [];
options.push(`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`);
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);

View File

@ -139,7 +139,7 @@ function sortKeys<T>(target: T): T {
}
const result: Partial<T> = {};
const keys = Object.keys(target).sort() as Array<keyof T>;
const keys = Object.keys(target).toSorted() as Array<keyof T>;
for (const key of keys) {
result[key] = sortKeys(target[key]);
}
@ -178,10 +178,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
throw new Error(`Invalid number format: ${schemaName}.${key}=float (use double instead). `);
}
}
if (schema.required) {
schema.required = schema.required.sort();
}
schema.required?.sort();
}
}
}

View File

@ -176,7 +176,7 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
ack: expect.stringMatching(new RegExp(String.raw`${SyncEntityType.PartnerAssetExifBackfillV1}\|.+?\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),
@ -226,7 +226,7 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
expect(newResponse).toEqual([
{
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
ack: expect.stringMatching(new RegExp(String.raw`${SyncEntityType.PartnerAssetExifBackfillV1}\|.+?\|.+`)),
data: expect.objectContaining({
assetId: assetUser3.id,
}),

View File

@ -38,6 +38,7 @@ export const makeMockWatcher =
return () => close();
}
// eslint-disable-next-line unicorn/consistent-function-scoping
return () => Promise.resolve();
};

View File

@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.49.1",
"@immich/ui": "^0.49.2",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@ -88,7 +88,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-compat": "^6.0.2",
"eslint-plugin-svelte": "^3.12.4",
"eslint-plugin-unicorn": "^61.0.2",
"eslint-plugin-unicorn": "^62.0.0",
"factory.ts": "^1.4.1",
"globals": "^16.0.0",
"happy-dom": "^20.0.0",

View File

@ -46,8 +46,7 @@ export class AlbumModalRowConverter {
): AlbumModalRow[] {
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
const rows: AlbumModalRow[] = [];
rows.push({ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 });
const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }];
const filteredAlbums = sortAlbums(
search.length > 0 && albums.length > 0

View File

@ -39,7 +39,7 @@
checked={selectAllSubItems}
onCheckedChange={handleSelectAllSubItems}
/>
<Label label={title} for={title} class="font-mono text-primary text-lg" />
<Label label={title} for="permission-{title}" class="font-mono text-primary text-lg" />
</div>
<div class="mx-6 mt-3 grid grid-cols-3 gap-2">
{#each subItems as item (item)}
@ -50,7 +50,7 @@
checked={selectedItems.includes(item)}
onCheckedChange={() => handleToggleItem(item)}
/>
<Label label={item} for={item} class="text-sm font-mono" />
<Label label={item} for="permission-{item}" class="text-sm font-mono" />
</div>
{/each}
</div>

View File

@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
export interface ThemeSetting {
value: Theme;
@ -71,6 +72,8 @@ class ThemeManager {
this.#theme.current = theme;
uiTheme.value = theme.value as unknown as UiTheme;
eventManager.emit('ThemeChange', theme);
}
}

View File

@ -23,17 +23,22 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getLibrariesActions = ($t: MessageFormatter) => {
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
const ScanAll: ActionItem = {
title: $t('scan_all_libraries'),
type: $t('command'),
icon: mdiSync,
onAction: () => void handleScanAllLibraries(),
shortcuts: { shift: true, key: 'r' },
$if: () => libraries.length > 0,
};
const Create: ActionItem = {
title: $t('create_library'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void handleCreateLibrary(),
shortcuts: { shift: true, key: 'n' },
};
return { ScanAll, Create };
@ -42,33 +47,41 @@ export const getLibrariesActions = ($t: MessageFormatter) => {
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('rename'),
onAction: () => void modalManager.show(LibraryRenameModal, { library }),
shortcuts: { key: 'r' },
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
color: 'danger',
onAction: () => void handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
};
const AddFolder: ActionItem = {
icon: mdiPlusBoxOutline,
type: $t('command'),
title: $t('add'),
onAction: () => void modalManager.show(LibraryFolderAddModal, { library }),
};
const AddExclusionPattern: ActionItem = {
icon: mdiPlusBoxOutline,
type: $t('command'),
title: $t('add'),
onAction: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
};
const Scan: ActionItem = {
icon: mdiSync,
type: $t('command'),
title: $t('scan_library'),
onAction: () => void handleScanLibrary(library),
shortcuts: { shift: true, key: 'r' },
};
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
@ -77,12 +90,14 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('edit'),
onAction: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
onAction: () => void handleDeleteLibraryFolder(library, folder),
};
@ -97,12 +112,14 @@ export const getLibraryExclusionPatternActions = (
) => {
const Edit: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
title: $t('edit'),
onAction: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
type: $t('command'),
title: $t('delete'),
onAction: () => void handleDeleteExclusionPattern(library, exclusionPattern),
};

View File

@ -17,21 +17,33 @@ export const getSystemConfigActions = (
) => {
const CopyToClipboard: ActionItem = {
title: $t('copy_to_clipboard'),
description: $t('admin.copy_config_to_clipboard_description'),
type: $t('command'),
icon: mdiContentCopy,
onAction: () => void handleCopyToClipboard(config),
shortcuts: { shift: true, key: 'c' },
};
const Download: ActionItem = {
title: $t('export_as_json'),
description: $t('admin.export_config_as_json_description'),
type: $t('command'),
icon: mdiDownload,
onAction: () => handleDownloadConfig(config),
shortcuts: [
{ shift: true, key: 's' },
{ shift: true, key: 'd' },
],
};
const Upload: ActionItem = {
title: $t('import_from_json'),
description: $t('admin.import_config_from_json_description'),
type: $t('command'),
icon: mdiUpload,
$if: () => !featureFlags.configFile,
onAction: () => handleUploadConfig(),
shortcuts: { shift: true, key: 'u' },
};
return { CopyToClipboard, Download, Upload };

View File

@ -34,8 +34,10 @@ import { get } from 'svelte/store';
export const getUserAdminsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_user'),
type: $t('command'),
icon: mdiPlusBoxOutline,
onAction: () => void modalManager.show(UserCreateModal, {}),
shortcuts: { shift: true, key: 'n' },
};
return { Create };
@ -45,34 +47,39 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
onAction: () => void modalManager.show(UserEditModal, { user }),
onAction: () => modalManager.show(UserEditModal, { user }),
};
const Delete: ActionItem = {
icon: mdiTrashCanOutline,
title: $t('delete'),
type: $t('command'),
color: 'danger',
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => void modalManager.show(UserDeleteConfirmModal, { user }),
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
shortcuts: { key: 'Backspace' },
};
const Restore: ActionItem = {
icon: mdiDeleteRestore,
title: $t('restore'),
type: $t('command'),
color: 'primary',
$if: () => !!user.deletedAt && user.status === UserStatus.Deleted,
onAction: () => void modalManager.show(UserRestoreConfirmModal, { user }),
onAction: () => modalManager.show(UserRestoreConfirmModal, { user }),
};
const ResetPassword: ActionItem = {
icon: mdiLockReset,
title: $t('reset_password'),
type: $t('command'),
$if: () => get(authUser).id !== user.id,
onAction: () => void handleResetPasswordUserAdmin(user),
};
const ResetPinCode: ActionItem = {
icon: mdiLockSmart,
type: $t('command'),
title: $t('reset_pin_code'),
onAction: () => void handleResetPinCodeUserAdmin(user),
};

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@ -11,6 +11,7 @@
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import { user } from '$lib/stores/user.store';
@ -19,7 +20,8 @@
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { modalManager, setTranslations } from '@immich/ui';
import { CommandPaletteContext, modalManager, setTranslations, type ActionItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import '../app.css';
@ -120,9 +122,57 @@
});
}
});
const userCommands: ActionItem[] = [
{
title: $t('theme'),
description: $t('toggle_theme_description'),
type: $t('command'),
icon: mdiThemeLightDark,
onAction: () => themeManager.toggleTheme(),
shortcuts: { shift: true, key: 't' },
isGlobal: true,
},
];
const adminCommands: ActionItem[] = [
{
title: $t('users'),
description: $t('admin.users_page_description'),
icon: mdiAccountMultipleOutline,
onAction: () => goto(AppRoute.ADMIN_USERS),
},
{
title: $t('jobs'),
description: $t('admin.jobs_page_description'),
icon: mdiSync,
onAction: () => goto(AppRoute.ADMIN_JOBS),
},
{
title: $t('settings'),
description: $t('admin.jobs_page_description'),
icon: mdiCog,
onAction: () => goto(AppRoute.ADMIN_SETTINGS),
},
{
title: $t('external_libraries'),
description: $t('admin.external_libraries_page_description'),
icon: mdiBookshelf,
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
},
{
title: $t('server_stats'),
description: $t('admin.server_stats_page_description'),
icon: mdiServer,
onAction: () => goto(AppRoute.ADMIN_STATS),
},
].map((route) => ({ ...route, type: $t('page'), isGlobal: true, $if: () => $user?.isAdmin }));
const commands = $derived([...userCommands, ...adminCommands]);
</script>
<OnEvents {onReleaseEvent} />
<CommandPaletteContext {commands} />
<svelte:head>
<title>{page.data.meta?.title || 'Web'} - Immich</title>

View File

@ -2,6 +2,7 @@ import { goto } from '$app/navigation';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { init } from '$lib/utils/server';
import { commandPaletteManager } from '@immich/ui';
import type { LayoutLoad } from './$types';
export const ssr = false;
@ -21,6 +22,8 @@ export const load = (async ({ fetch, url }) => {
error = initError;
}
commandPaletteManager.enable();
return {
error,
meta: {

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import JobsPanel from '$lib/components/jobs/JobsPanel.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import { AppRoute } from '$lib/constants';
@ -12,7 +13,7 @@
runQueueCommandLegacy,
type QueuesResponseLegacyDto,
} from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { Button, CommandPaletteContext, HStack, modalManager, Text, type ActionItem } from '@immich/ui';
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
@ -46,6 +47,27 @@
}
};
const handleCreateJob = () => modalManager.show(JobCreateModal);
const jobConcurrencyLink = `${AppRoute.ADMIN_SETTINGS}?isOpen=job`;
const commands: ActionItem[] = [
{
title: $t('admin.create_job'),
type: $t('command'),
icon: mdiPlus,
onAction: () => void handleCreateJob(),
shortcuts: { shift: true, key: 'n' },
},
{
title: $t('admin.manage_concurrency'),
description: $t('admin.manage_concurrency_description'),
type: $t('page'),
icon: mdiCog,
onAction: () => goto(jobConcurrencyLink),
},
];
onMount(async () => {
while (running) {
jobs = await getQueuesLegacy();
@ -58,6 +80,8 @@
});
</script>
<CommandPaletteContext {commands} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={0}>
@ -74,22 +98,10 @@
</Text>
</Button>
{/if}
<Button
leadingIcon={mdiPlus}
onclick={() => modalManager.show(JobCreateModal, {})}
size="small"
variant="ghost"
color="secondary"
>
<Button leadingIcon={mdiPlus} onclick={handleCreateJob} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('admin.create_job')}</Text>
</Button>
<Button
leadingIcon={mdiCog}
href="{AppRoute.ADMIN_SETTINGS}?isOpen=job"
size="small"
variant="ghost"
color="secondary"
>
<Button leadingIcon={mdiCog} href={jobConcurrencyLink} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('admin.manage_concurrency')}</Text>
</Button>
</HStack>

View File

@ -9,7 +9,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { Button, CommandPaletteContext } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
@ -49,7 +49,7 @@
delete owners[id];
};
const { Create, ScanAll } = $derived(getLibrariesActions($t));
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
</script>
<OnEvents
@ -58,12 +58,12 @@
onLibraryDelete={handleDeleteLibrary}
/>
<CommandPaletteContext commands={[Create, ScanAll]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<div class="flex justify-end gap-2">
{#if libraries.length > 0}
<HeaderButton action={ScanAll} />
{/if}
<HeaderButton action={ScanAll} />
<HeaderButton action={Create} />
</div>
{/snippet}

View File

@ -15,7 +15,18 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Card, CardBody, CardHeader, CardTitle, Code, Container, Heading, Icon, modalManager } from '@immich/ui';
import {
Card,
CardBody,
CardHeader,
CardTitle,
Code,
CommandPaletteContext,
Container,
Heading,
Icon,
modalManager,
} from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -39,6 +50,8 @@
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[
{ title: $t('admin.external_library_management'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT },

View File

@ -26,7 +26,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getSystemConfigActions } from '$lib/services/system-config.service';
import { Alert, HStack } from '@immich/ui';
import { Alert, CommandPaletteContext, HStack } from '@immich/ui';
import {
mdiAccountOutline,
mdiBackupRestore,
@ -206,6 +206,8 @@
);
</script>
<CommandPaletteContext commands={[CopyToClipboard, Upload, Download]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>

View File

@ -6,7 +6,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, HStack, Icon } from '@immich/ui';
import { Button, CommandPaletteContext, HStack, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -43,6 +43,8 @@
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
{#snippet buttons()}
<HStack gap={1}>

View File

@ -22,6 +22,7 @@
CardHeader,
CardTitle,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
@ -105,6 +106,8 @@
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
>