diff --git a/.github/.nvmrc b/.github/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b8ce6387af306..44645c1e1b920 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -51,14 +51,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -79,12 +79,12 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false @@ -96,14 +96,14 @@ jobs: working-directory: ./mobile run: printf "%s" $KEY_JKS | base64 -d > android/key.jks - - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0 + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: 'zulu' java-version: '17' - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | ~/.gradle/caches @@ -160,7 +160,7 @@ jobs: - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 if: github.ref == 'refs/heads/main' with: path: | @@ -185,7 +185,7 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index 55f91e7989c04..3de467662248a 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -19,13 +19,13 @@ jobs: actions: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check out code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml new file mode 100644 index 0000000000000..20902698f62ff --- /dev/null +++ b/.github/workflows/check-openapi.yml @@ -0,0 +1,37 @@ +name: Check OpenAPI +on: + workflow_dispatch: + pull_request: + paths: + - 'open-api/**' + - '.github/workflows/check-openapi.yml' + push: + branches: [main] + paths: + - 'open-api/**' + - '.github/workflows/check-openapi.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + check-openapi: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Check for breaking API changes + # sha is pinning to a commit instead of a tag since the action does not tag versions + uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4 + with: + base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json + revision: open-api/immich-openapi-specs.json + fail-on: ERR diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 3591539b680bb..a2c763a0f606e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -31,12 +31,12 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -45,7 +45,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' @@ -71,13 +71,13 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -89,7 +89,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io @@ -115,7 +115,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 09e9dbb338529..1b18c0c5e16cb 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -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:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6 + image: ghcr.io/immich-app/mdq:main@sha256:4f9860d04c88f7f87861f8ee84bfeedaec15ed7ca5ca87bc7db44b036f81645f outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 71b59689606f6..67e0b4b972f3d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,20 +44,20 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 # â„šī¸ 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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41daebd3a7104..1636076491c36 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,14 +23,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -60,7 +60,7 @@ jobs: suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -90,7 +90,7 @@ jobs: suffix: [''] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -132,7 +132,7 @@ jobs: suffixes: '-rocm' platforms: linux/amd64 runner-mapping: '{"linux/amd64": "pokedex-giant"}' - uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 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@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0 + uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1 permissions: contents: read actions: read diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 91916e4ed272b..28828f22c6fcf 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -21,14 +21,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -54,13 +54,13 @@ jobs: steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -70,7 +70,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 8c0bf76f303f7..babda72c337bb 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,7 +20,7 @@ jobs: artifact: ${{ steps.get-artifact.outputs.result }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} @@ -119,19 +119,19 @@ jobs: if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 - name: Load parameters id: parameters @@ -192,16 +192,13 @@ jobs: ' >> $GITHUB_OUTPUT - name: Publish to Cloudflare Pages - # TODO: Action is deprecated - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: ${{ steps.docs-output.outputs.projectName }} - workingDirectory: 'docs' - directory: 'build' - branch: ${{ steps.parameters.outputs.name }} - wranglerVersion: '3' + working-directory: docs + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PROJECT_NAME: ${{ steps.docs-output.outputs.projectName }} + BRANCH_NAME: ${{ steps.parameters.outputs.name }} + run: mise run //docs:deploy - name: Deploy Docs Release Domain if: ${{ steps.parameters.outputs.event == 'release' }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index a7d068cb434f2..05842889cc577 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -17,19 +17,19 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0 + uses: immich-app/devtools/actions/use-mise@dab18118da6476e8237ac94080fd937983fecd42 # use-mise-action-v1.1.2 - name: Destroy Docs Subdomain env: diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 11a9ef06e49c2..1daa279cd2483 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -22,7 +22,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: 'Checkout' - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ steps.generate-token.outputs.token }} @@ -32,14 +32,14 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - name: Fix formatting - run: pnpm --recursive install && pnpm run --recursive --parallel fix:format + run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 0544de3dad2be..e04b32d74fc98 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 263426e548888..24f3f8faf1e3a 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 373fbaf6c1156..a1d31a61eafce 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -56,20 +56,20 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true ref: main - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -130,7 +130,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml index 8760b67fc0c36..dc6f0eff0af25 100644 --- a/.github/workflows/preview-label.yaml +++ b/.github/workflows/preview-label.yaml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} @@ -32,7 +32,7 @@ jobs: pull-requests: write steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 3ee96c45b703c..93e18a4fccbb5 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -23,20 +23,20 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true ref: main - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -159,7 +159,7 @@ jobs: - name: Create PR id: create-pr - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ steps.generate-token.outputs.token }} commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30783f5e9b7ca..30e9c1c7ca59b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false @@ -88,6 +88,7 @@ jobs: draft: true files: | docker/docker-compose.yml + docker/docker-compose.rootless.yml docker/example.env docker/hwaccel.ml.yml docker/hwaccel.transcoding.yml diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index bd2c292ad50ae..1bcdec47473c9 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -19,12 +19,12 @@ jobs: working-directory: ./open-api/typescript-sdk steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -33,7 +33,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index c0d53388c677d..e355803f17ab5 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -20,14 +20,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -49,13 +49,13 @@ jobs: working-directory: ./mobile steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93efccf2e1190..681baea06630c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,14 +17,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -63,13 +63,13 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -77,7 +77,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -108,20 +108,20 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -155,20 +155,20 @@ jobs: working-directory: ./cli steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -197,20 +197,20 @@ jobs: working-directory: ./web steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -241,20 +241,20 @@ jobs: working-directory: ./web steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -279,20 +279,20 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -327,20 +327,20 @@ jobs: working-directory: ./e2e steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -373,13 +373,13 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -387,7 +387,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -412,13 +412,13 @@ jobs: runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -426,7 +426,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -446,12 +446,29 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} - - name: Docker build - run: docker compose build + - name: Start Docker Compose + run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} - name: Run e2e tests (api & cli) + env: + VITEST_DISABLE_DOCKER_SETUP: true run: pnpm test if: ${{ !cancelled() }} + - name: Run e2e tests (maintenance) + env: + VITEST_DISABLE_DOCKER_SETUP: true + run: pnpm test:maintenance + if: ${{ !cancelled() }} + - name: Capture Docker logs + if: always() + run: docker compose logs --no-color > docker-compose-logs.txt + working-directory: ./e2e + - name: Archive Docker logs + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + if: always() + with: + name: e2e-server-docker-logs-${{ matrix.runner }} + path: e2e/docker-compose-logs.txt e2e-tests-web: name: End-to-End Tests (Web) needs: pre-job @@ -467,13 +484,13 @@ jobs: runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false submodules: 'recursive' @@ -481,7 +498,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -501,9 +518,8 @@ jobs: if: ${{ !cancelled() }} - name: Run e2e tests (web) env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=web + run: pnpm test:web if: ${{ !cancelled() }} - name: Archive e2e test (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -513,9 +529,8 @@ jobs: path: e2e/playwright-report/ - name: Run ui tests (web) env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=ui + run: pnpm test:web:ui if: ${{ !cancelled() }} - name: Archive ui test (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -525,9 +540,8 @@ jobs: path: e2e/playwright-report/ - name: Run maintenance tests env: - CI: true PLAYWRIGHT_DISABLE_WEBSERVER: true - run: npx playwright test --project=maintenance + run: pnpm test:web:maintenance if: ${{ !cancelled() }} - name: Archive maintenance tests (web) results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 @@ -543,7 +557,7 @@ jobs: uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: always() with: - name: docker-compose-logs-${{ matrix.runner }} + name: e2e-web-docker-logs-${{ matrix.runner }} path: e2e/docker-compose-logs.txt success-check-e2e: name: End-to-End Tests Success @@ -564,12 +578,12 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -596,17 +610,17 @@ jobs: working-directory: ./machine-learning steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 with: python-version: 3.11 - name: Install dependencies @@ -636,20 +650,20 @@ jobs: working-directory: ./.github steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -666,12 +680,12 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -687,20 +701,20 @@ jobs: contents: read steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -749,20 +763,20 @@ jobs: working-directory: ./server steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Setup Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index cb11a11be482e..6e997ad76a46d 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -24,14 +24,14 @@ jobs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Check what should run id: check - uses: immich-app/devtools/actions/pre-job@08bac802a312fc89808e0dd589271ca0974087b5 # pre-job-action-v2.0.0 + uses: immich-app/devtools/actions/pre-job@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 with: github-token: ${{ steps.token.outputs.token }} filters: | @@ -47,7 +47,7 @@ jobs: if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }} steps: - id: token - uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0 + uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} diff --git a/cli/.nvmrc b/cli/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/package.json b/cli/package.json index d80efdd74a908..8e2aec0282069 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index c4f673de6e867..4bd60262ad8d7 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -44,7 +44,7 @@ While this guide focuses on VS Code, you have many options for Dev Container dev **Self-Hostable Options:** - [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed -- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise) +- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise). Check [quick-start guide](#quick-start-guide-for-devpod-with-docker) ::: ## Dev Container Services @@ -410,6 +410,26 @@ If you encounter issues: 3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/) 4. Ask in [Discord](https://discord.immich.app) `#contributing` channel +### Quick-start guide for DevPod with docker + +You will need DevPod CLI (check [DevPod CLI installation guide](https://devpod.sh/docs/getting-started/install)) and Docker Desktop. + +```sh +# Step 1: Clone the Repository +git clone https://github.com/immich-app/immich.git +cd immich + +# Step 2: Prepare DevPod (if you haven't already) +devpod provider add docker +devpod provider use docker + +# Step 3: Build 'immich-server-dev' docker image first manually +docker build -f server/Dockerfile.dev -t immich-server-dev . + +# Step 4: Now you can start devcontainer +devpod up . +``` + ## Mobile Development While the Dev Container focuses on server and web development, you can connect mobile apps for testing: diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index a6aaae149b61b..bf815521ef73e 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -8,7 +8,8 @@ A config file can be provided as an alternative to the UI configuration. ### Step 1 - Create a new config file -In JSON format, create a new config file (e.g. `immich.json`) and put it in a location that can be accessed by Immich. +In JSON format, create a new config file (e.g. `immich.json`) and put it in a location mounted in the container that can be accessed by Immich. +YAML-formatted config files are also supported. The default configuration looks like this:
@@ -251,6 +252,15 @@ So you can just grab it from there, paste it into a file and you're pretty much In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config. For more information, refer to the [Environment Variables](/install/environment-variables.md) section. -:::tip -YAML-formatted config files are also supported. -::: +:::info Docker Compose +In your `.env` file, the variables `UPLOAD_LOCATION` and `DB_DATA_LOCATION` concern the location on the host. +However, the variable `IMMICH_CONFIG_FILE` concerns the location inside the container, and informs the `immich-server` container that a configuration file is present. + +It is recommended to reuse this variable in your `docker-compose.yml`: + +```yaml +volumes: + - ./configuration.yml:${IMMICH_CONFIG_FILE} +``` + +:: diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index 3e5b780db2f6c..b86561dbbfaf6 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -8,8 +8,6 @@ sidebar_position: 85 This is a community contribution and not officially supported by the Immich team, but included here for convenience. Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). - -**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** ::: Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager. diff --git a/docs/mise.toml b/docs/mise.toml index 4ffb7d5cce03d..32fcac557840a 100644 --- a/docs/mise.toml +++ b/docs/mise.toml @@ -23,3 +23,9 @@ run = "prettier --check ." [tasks."format-fix"] env._.path = "./node_modules/.bin" run = "prettier --write ." + +[tasks.deploy] +run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" + +[tools] +wrangler = "4.66.0" diff --git a/docs/package.json b/docs/package.json index 87b0b3fccdfa6..c22826b3cbb42 100644 --- a/docs/package.json +++ b/docs/package.json @@ -58,6 +58,6 @@ "node": ">=20" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 2ef57475b7380..8ae5762a1bf2f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 diff --git a/e2e/package.json b/e2e/package.json index abe46a39ca76b..cebd9fafc2cd4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,8 +7,13 @@ "scripts": { "test": "vitest --run", "test:watch": "vitest", - "test:web": "npx playwright test", - "start:web": "npx playwright test --ui", + "test:maintenance": "vitest --run --config vitest.maintenance.config.ts", + "test:web": "npx playwright test --project=web", + "test:web:maintenance": "npx playwright test --project=maintenance", + "test:web:ui": "npx playwright test --project=ui", + "start:web": "npx playwright test --ui --project=web", + "start:web:maintenance": "npx playwright test --ui --project=maintenance", + "start:web:ui": "npx playwright test --ui --project=ui", "format": "prettier --check .", "format:fix": "prettier --write .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", @@ -27,7 +32,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", @@ -52,6 +57,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 6dd8c10d256df..040546b7bb126 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -48,7 +48,7 @@ const config: PlaywrightTestConfig = { { name: 'maintenance', use: { ...devices['Desktop Chrome'] }, - testDir: './src/specs/maintenance', + testDir: './src/specs/maintenance/web', workers: 1, }, ], diff --git a/e2e/src/specs/server/api/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts similarity index 100% rename from e2e/src/specs/server/api/database-backups.e2e-spec.ts rename to e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts diff --git a/e2e/src/specs/server/api/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts similarity index 100% rename from e2e/src/specs/server/api/maintenance.e2e-spec.ts rename to e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts diff --git a/e2e/src/specs/maintenance/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts similarity index 100% rename from e2e/src/specs/maintenance/database-backups.e2e-spec.ts rename to e2e/src/specs/maintenance/web/database-backups.e2e-spec.ts diff --git a/e2e/src/specs/maintenance/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/web/maintenance.e2e-spec.ts similarity index 100% rename from e2e/src/specs/maintenance/maintenance.e2e-spec.ts rename to e2e/src/specs/maintenance/web/maintenance.e2e-spec.ts diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 953273a930ec7..1312bf9b750c4 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -1,15 +1,20 @@ import { defineConfig } from 'vitest/config'; -// skip `docker compose up` if `make e2e` was already run +const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; + +// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set const globalSetup: string[] = []; -try { - await fetch('http://127.0.0.1:2285/api/server/ping'); -} catch { - globalSetup.push('src/docker-compose.ts'); +if (!skipDockerSetup) { + try { + await fetch('http://127.0.0.1:2285/api/server/ping'); + } catch { + globalSetup.push('src/docker-compose.ts'); + } } export default defineConfig({ test: { + retry: process.env.CI ? 4 : 0, include: ['src/specs/server/**/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, diff --git a/e2e/vitest.maintenance.config.ts b/e2e/vitest.maintenance.config.ts new file mode 100644 index 0000000000000..6bb6721a6d502 --- /dev/null +++ b/e2e/vitest.maintenance.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true'; + +// skip `docker compose up` if `make e2e` was already run or if VITEST_DISABLE_DOCKER_SETUP is set +const globalSetup: string[] = []; +if (!skipDockerSetup) { + try { + await fetch('http://127.0.0.1:2285/api/server/ping'); + } catch { + globalSetup.push('src/docker-compose.ts'); + } +} + +export default defineConfig({ + test: { + retry: process.env.CI ? 4 : 0, + include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'], + globalSetup, + testTimeout: 15_000, + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, +}); diff --git a/i18n/en.json b/i18n/en.json index 6e35085be8334..aa8b768311746 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -819,6 +819,7 @@ "contain": "Contain", "context": "Context", "continue": "Continue", + "control_bottom_app_bar_add_tags": "Add Tags", "control_bottom_app_bar_create_new_album": "Create new album", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_local": "Delete from device", @@ -1070,6 +1071,7 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_reset_pin_code": "Failed to reset PIN code", "failed_to_stack_assets": "Failed to stack assets", + "failed_to_tag_assets": "Failed to tag assets", "failed_to_unstack_assets": "Failed to un-stack assets", "failed_to_update_notification_status": "Failed to update notification status", "incorrect_email_or_password": "Incorrect email or password", @@ -1218,6 +1220,7 @@ "filter_description": "Conditions to filter the target assets", "filter_people": "Filter people", "filter_places": "Filter places", + "filter_tags": "Filter tags", "filters": "Filters", "find_them_fast": "Find them fast by name with search", "first": "First", @@ -1945,6 +1948,7 @@ "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/mise.toml b/mise.toml index 3ca0d353eaeb4..7cb3a024e30dc 100644 --- a/mise.toml +++ b/mise.toml @@ -14,15 +14,15 @@ config_roots = [ ] [tools] -node = "24.13.0" +node = "24.13.1" flutter = "3.35.7" -pnpm = "10.28.2" +pnpm = "10.29.3" terragrunt = "0.98.0" opentofu = "1.11.4" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] -version = "1.30.0" +version = "1.35.1" bin = "dcm" postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" diff --git a/mobile/dcm_global.yaml b/mobile/dcm_global.yaml index c33846e674fc9..ffe77eede8fc9 100644 --- a/mobile/dcm_global.yaml +++ b/mobile/dcm_global.yaml @@ -1 +1 @@ -version: '>=1.29.0 <=1.30.0' +version: '>=1.29.0 <=1.36.0' diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index fc9cebc80fda3..9bbe00852e231 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event { } // Asset Viewer Events -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +class ViewerShowDetailsEvent extends Event { + const ViewerShowDetailsEvent(); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart new file mode 100644 index 0000000000000..357367b13ea22 --- /dev/null +++ b/mobile/lib/domain/models/tag.model.dart @@ -0,0 +1,29 @@ +import 'package:openapi/api.dart'; + +class Tag { + final String id; + final String value; + + const Tag({required this.id, required this.value}); + + @override + String toString() { + return 'Tag(id: $id, value: $value)'; + } + + @override + bool operator ==(covariant Tag other) { + if (identical(this, other)) return true; + + return other.id == id && other.value == value; + } + + @override + int get hashCode { + return id.hashCode ^ value.hashCode; + } + + static Tag fromDto(TagResponseDto dto) { + return Tag(id: dto.id, value: dto.value); + } +} diff --git a/mobile/lib/domain/services/tag.service.dart b/mobile/lib/domain/services/tag.service.dart new file mode 100644 index 0000000000000..2bcdf7f2fdf22 --- /dev/null +++ b/mobile/lib/domain/services/tag.service.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; + +final tagServiceProvider = Provider((ref) => TagService(ref.watch(tagsApiRepositoryProvider))); + +class TagService { + final TagsApiRepository _repository; + + const TagService(this._repository); + + Future bulkTagAssets(List assetIds, List tagIds) async { + await _repository.bulkTagAssets(assetIds, tagIds); + } + + Future> getAllTags() async { + final dtos = await _repository.getAllTags(); + if (dtos == null) { + return {}; + } + return dtos.map((dto) => Tag.fromDto(dto)).toSet(); + } + + Future> upsertTags(List tags) async { + final dtos = await _repository.upsertTags(tags); + if (dtos == null) { + return []; + } + return dtos.map((dto) => Tag.fromDto(dto)).toList(); + } +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index bd36d0b5699b8..39aeb867a39bb 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -183,8 +183,8 @@ class TimelineService { return _buffer.slice(start, start + count); } - // Pre-cache assets around the given index for asset viewer - Future preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); + // Preload assets around the given index for asset viewer + Future preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 169032ff5d09e..5917e127bc3de 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { damping: 80, ); } + +class SnapScrollPhysics extends ScrollPhysics { + static const _minFlingVelocity = 700.0; + static const minSnapDistance = 30.0; + + static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + const SnapScrollPhysics({super.parent}); + + @override + SnapScrollPhysics applyTo(ScrollPhysics? ancestor) { + return SnapScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is SnapScrollPosition, + 'SnapScrollPhysics can only be used with Scrollables that use a ' + 'controller whose createScrollPosition returns a SnapScrollPosition', + ); + + final snapOffset = (position as SnapScrollPosition).snapOffset; + if (snapOffset <= 0) { + return super.createBallisticSimulation(position, velocity); + } + + if (position.pixels >= snapOffset) { + final simulation = super.createBallisticSimulation(position, velocity); + if (simulation == null || simulation.x(double.infinity) >= snapOffset) { + return simulation; + } + } + + return ScrollSpringSimulation( + _spring, + position.pixels, + target(position, velocity, snapOffset), + velocity, + tolerance: toleranceFor(position), + ); + } + + static double target(ScrollMetrics position, double velocity, double snapOffset) { + if (velocity > _minFlingVelocity) return snapOffset; + if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; + return position.pixels < minSnapDistance ? 0.0 : snapOffset; + } +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); +} + +class ProxyScrollController extends ScrollController { + final ScrollController scrollController; + + ProxyScrollController({required this.scrollController}); + + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + return ProxyScrollPosition( + scrollController: scrollController, + physics: physics, + context: context, + oldPosition: oldPosition, + ); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } +} + +class ProxyScrollPosition extends SnapScrollPosition { + final ScrollController scrollController; + + ProxyScrollPosition({ + required this.scrollController, + required super.physics, + required super.context, + super.oldPosition, + }); + + @override + double setPixels(double newPixels) { + final overscroll = super.setPixels(newPixels); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + return overscroll; + } + + @override + void forcePixels(double value) { + super.forcePixels(value); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + } + + @override + double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.maxScrollExtent + : super.maxScrollExtent; + + @override + double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.minScrollExtent + : super.minScrollExtent; + + @override + double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension + ? scrollController.position.viewportDimension + : super.viewportDimension; +} diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 043a42b1a43a9..bcfddfce6e48b 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 100, @@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 1000, diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart new file mode 100644 index 0000000000000..6b047cadec400 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -0,0 +1,25 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final tagsApiRepositoryProvider = Provider( + (ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi), +); + +class TagsApiRepository extends ApiRepository { + final TagsApi _api; + const TagsApiRepository(this._api); + + Future?> getAllTags() async { + return await _api.getAllTags(); + } + + Future bulkTagAssets(List assetIds, List tagIds) async { + await _api.bulkTagAssets(TagBulkAssetsDto(assetIds: assetIds, tagIds: tagIds)); + } + + Future?> upsertTags(List tags) async { + return _api.upsertTags(TagUpsertDto(tags: tags)); + } +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 2d45913fcb63c..1b730e0c681e2 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -214,6 +214,7 @@ class SearchFilter { String? ocr; String? language; String? assetId; + List? tagIds; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -231,6 +232,7 @@ class SearchFilter { this.ocr, this.language, this.assetId, + this.tagIds, required this.people, required this.location, required this.camera, @@ -246,6 +248,7 @@ class SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && + (tagIds ?? []).isEmpty && people.isEmpty && location.country == null && location.state == null && @@ -269,6 +272,7 @@ class SearchFilter { String? ocr, String? assetId, Set? people, + List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, @@ -290,12 +294,13 @@ class SearchFilter { display: display ?? this.display, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, + tagIds: tagIds ?? this.tagIds, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -309,6 +314,7 @@ class SearchFilter { other.ocr == ocr && other.assetId == assetId && other.people == people && + other.tagIds == tagIds && other.location == location && other.camera == camera && other.date == date && @@ -326,6 +332,7 @@ class SearchFilter { ocr.hashCode ^ assetId.hashCode ^ people.hashCode ^ + tagIds.hashCode ^ location.hashCode ^ camera.hashCode ^ date.hashCode ^ diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index e366cf70f1768..993b91d8f74f4 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -118,7 +118,7 @@ class MapPage extends HookConsumerWidget { } // finds the nearest asset marker from the tap point and store it as the selectedMarker - Future onMarkerClicked(Point point, LatLng coords) async { + Future onMarkerClicked(Point point, LatLng _) async { // Guard map not created if (mapController.value == null) { return; diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index a2c927c6bd398..3dace15ced3f1 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -28,7 +28,7 @@ class MapLocationPickerPage extends HookConsumerWidget { marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng); } - Future onMapClick(Point point, LatLng centre) async { + Future onMapClick(Point _, LatLng centre) async { selectedLatLng.value = centre; await controller.value?.animateCamera(CameraUpdate.newLatLng(centre)); if (marker.value != null) { diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ac0cd7f3093e4..fa5737443fdc0 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { final RemoteAlbum album; + final String? assetId; + final String? assetName; - const DriftActivitiesPage({super.key, required this.album}); + const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName}); @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier); + final activities = ref.watch(albumActivityProvider(album.id, assetId)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: Text(album.name), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(album.name), + if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall), + ], + ), actions: [const LikeActivityActionButton(iconOnly: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: CommentBubble(activity: activity), + child: CommentBubble(activity: activity, isAssetActivity: assetId != null), ), ); } diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 9042f2f1f5f87..147165f2a3e4b 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 62ec11f7edbba..c045dbd325432 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -24,6 +25,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; @@ -62,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget { mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, + tagIds: preFilter?.tagIds ?? [], ), ); @@ -72,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget { final dateRangeCurrentFilterWidget = useState(null); final cameraCurrentFilterWidget = useState(null); final locationCurrentFilterWidget = useState(null); + final tagCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); final isSearching = useState(false); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + final userPreferences = ref.watch(userMetadataPreferencesProvider); SnackBar searchInfoSnackBar(String message) { return SnackBar( @@ -177,6 +179,42 @@ class DriftSearchPage extends HookConsumerWidget { ); } + showTagPicker() { + handleOnSelect(Iterable tags) { + filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList()); + final label = tags.map((t) => t.value).join(', '); + if (label.isEmpty) { + tagCurrentFilterWidget.value = null; + } else { + tagCurrentFilterWidget.value = Text( + label.isEmpty ? 'tags'.t(context: context) : label, + style: context.textTheme.labelLarge, + ); + } + } + + handleClear() { + filter.value = filter.value.copyWith(tagIds: []); + tagCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_tags_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: TagPicker(onSelectExistingTag: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + ), + ), + ); + } + showLocationPicker() { handleOnSelect(Map value) { filter.value = filter.value.copyWith( @@ -658,6 +696,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), + if (userPreferences.valueOrNull?.tagsEnabled ?? false) + SearchFilterChip( + icon: Icons.sell_outlined, + onTap: showTagPicker, + label: 'tags'.t(context: context), + currentFilter: tagCurrentFilterWidget.value, + ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, @@ -677,14 +722,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (isRatingEnabled) ...[ + if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, ), - ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 23cd19f363c0a..4162f43a24478 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart new file mode 100644 index 0000000000000..e55339b22d771 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class BulkTagAssetsActionButton extends ConsumerWidget { + final ActionSource source; + + const BulkTagAssetsActionButton({super.key, required this.source}); + + _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).tagAssets(source, context); + if (result == null) { + return; + } + + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'tagged_assets'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'errors.failed_to_tag_assets'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + maxWidth: 95.0, + iconData: Icons.sell, + label: "control_bottom_app_bar_add_tags".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 4c7b6ffbdce3a..440985a0bba5c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 8c326974a7183..a44b0b581560e 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 8f3cee9215da3..15749fb9af5fa 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart deleted file mode 100644 index 3b46b69958317..0000000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; -import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; - -class ActivitiesBottomSheet extends HookConsumerWidget { - final DraggableScrollableController controller; - final double initialChildSize; - final bool scrollToBottomInitially; - - const ActivitiesBottomSheet({ - required this.controller, - this.initialChildSize = 0.35, - this.scrollToBottomInitially = true, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); - - Future onAddComment(String comment) async { - await activityNotifier.addComment(comment); - } - - Widget buildActivitiesSliver() { - return activities.widgetWhen( - onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), - onData: (data) { - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (index == data.length) { - return const SizedBox.shrink(); - } - final activity = data[data.length - 1 - index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: CommentBubble(activity: activity, isAssetActivity: true), - ); - }, childCount: data.length + 1), - ); - }, - ); - } - - return BaseBottomSheet( - actions: [], - slivers: [buildActivitiesSliver()], - footer: Padding( - // TODO: avoid fixed padding, use context.padding.bottom - padding: const EdgeInsets.only(bottom: 32), - child: Column( - children: [ - const Divider(indent: 16, endIndent: 16), - DriftActivityTextField( - isEnabled: album.isActivityEnabled, - isBottomSheet: true, - // likeId: likedId, - onSubmit: onAddComment, - ), - ], - ), - ), - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart new file mode 100644 index 0000000000000..949a6917e93f6 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; + +class AssetDetails extends ConsumerWidget { + final double minHeight; + + const AssetDetails({required this.minHeight, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + const DateTimeDetails(), + const PeopleDetails(), + const LocationDetails(), + const TechnicalDetails(), + const RatingDetails(), + const AppearsInDetails(), + SizedBox(height: context.padding.bottom + 48), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart new file mode 100644 index 0000000000000..a3d6bdb8abfbc --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppearsInDetails extends ConsumerWidget { + const AppearsInDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + + String? remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; + } + + if (remoteAssetId == null) return const SizedBox.shrink(); + + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) return const SizedBox.shrink(); + + albums.sortBy((a) => a.name); + + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + spacing: 12, + children: [ + SheetTile( + title: 'appears_in'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart new file mode 100644 index 0000000000000..4872bf9e75c28 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +const _kSeparator = ' â€ĸ '; + +class DateTimeDetails extends ConsumerWidget { + const DateTimeDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + + return Column( + children: [ + SheetTile( + title: _getDateTime(context, asset, exifInfo), + titleStyle: context.textTheme.labelLarge, + trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote && isOwner + ? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context) + : null, + ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), + ], + ); + } + + static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; + return '$date$_kSeparator$time $timezone'; + } +} + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + final bool isEditable; + + const _SheetAssetDescription({required this.exif, this.isEditable = true}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + final currentDescription = currentExifInfo?.description ?? ''; + final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( + context: context, + ); + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: IgnorePointer( + ignoring: !widget.isEditable, + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + focusNode: _descriptionFocus, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart new file mode 100644 index 0000000000000..8c24c5004c756 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class DragHandle extends StatelessWidget { + const DragHandle({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(2)), + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index ce561c4016209..0665f4d46c422 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class SheetLocationDetails extends ConsumerStatefulWidget { - const SheetLocationDetails({super.key}); +class LocationDetails extends ConsumerStatefulWidget { + const LocationDetails({super.key}); @override - ConsumerState createState() => _SheetLocationDetailsState(); + ConsumerState createState() => _LocationDetailsState(); } -class _SheetLocationDetailsState extends ConsumerState { +class _LocationDetailsState extends ConsumerState { MapLibreMapController? _mapController; String? _getLocationName(ExifInfo? exifInfo) { @@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState { void _onExifChanged(AsyncValue? previous, AsyncValue current) { final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 7eb9e578ffc08..5074c63c9c4f5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class SheetPeopleDetails extends ConsumerStatefulWidget { - const SheetPeopleDetails({super.key}); +class PeopleDetails extends ConsumerStatefulWidget { + const PeopleDetails({super.key}); @override - ConsumerState createState() => _SheetPeopleDetailsState(); + ConsumerState createState() => _PeopleDetailsState(); } -class _SheetPeopleDetailsState extends ConsumerState { +class _PeopleDetailsState extends ConsumerState { @override Widget build(BuildContext context) { final asset = ref.watch(currentAssetNotifier); @@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState { scrollDirection: Axis.horizontal, children: [ for (final person in people) - _PeopleAvatar( + _Avatar( person: person, assetFileCreatedAt: asset.createdAt, onTap: () { @@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState { } } -class _PeopleAvatar extends StatelessWidget { +class _Avatar extends StatelessWidget { final DriftPerson person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; final double imageSize = 96; - const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); + const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart new file mode 100644 index 0000000000000..982ea67583af9 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; + +class RatingDetails extends ConsumerWidget { + const RatingDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + + if (!isRatingEnabled) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart new file mode 100644 index 0000000000000..d79362b55903f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' â€ĸ '; + +class TechnicalDetails extends ConsumerWidget { + const TechnicalDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; + + return Column( + children: [ + SheetTile( + title: 'details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + _buildFileInfoTile(context, ref, asset, exifInfo), + if (cameraTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + if (lensTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ], + ); + } + + Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) { + final icon = Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ); + final subtitle = _getFileInfo(asset, exifInfo); + final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); + + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + return SheetTile( + title: snapshot.data ?? asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + }, + ); + } + + return SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + } + + static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height; + final width = asset.width; + final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; + final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', + }; + } + + static String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + static String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart new file mode 100644 index 0000000000000..a8f5f9d14a036 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/gestures.dart' show Drag, kTouchSlop; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +enum _DragIntent { none, scroll, dismiss } + +class AssetPage extends ConsumerStatefulWidget { + final int index; + final int heroOffset; + + const AssetPage({super.key, required this.index, required this.heroOffset}); + + @override + ConsumerState createState() => _AssetPageState(); +} + +class _AssetPageState extends ConsumerState { + PhotoViewControllerBase? _viewController; + StreamSubscription? _scaleBoundarySub; + StreamSubscription? _eventSubscription; + + AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier); + + late PhotoViewControllerValue _initialPhotoViewState; + + bool _blockGestures = false; + bool _showingDetails = false; + bool _isZoomed = false; + + final _scrollController = ScrollController(); + late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + + double _snapOffset = 0.0; + double _lastScrollOffset = 0.0; + + DragStartDetails? _dragStart; + _DragIntent _dragIntent = _DragIntent.none; + Drag? _drag; + bool _dragInProgress = false; + bool _shouldPopOnDrag = false; + + @override + void initState() { + super.initState(); + _proxyScrollController.addListener(_onScroll); + _eventSubscription = EventStream.shared.listen(_onEvent); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_proxyScrollController.hasClients) return; + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_showingDetails && _snapOffset > 0) { + _proxyScrollController.jumpTo(_snapOffset); + } + }); + } + + @override + void dispose() { + _proxyScrollController.dispose(); + _scaleBoundarySub?.cancel(); + _eventSubscription?.cancel(); + super.dispose(); + } + + void _onEvent(Event event) { + switch (event) { + case ViewerShowDetailsEvent(): + _showDetails(); + default: + } + } + + void _showDetails() { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; + _lastScrollOffset = _proxyScrollController.offset; + _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + } + + bool _willClose(double scrollVelocity) { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; + + final position = _proxyScrollController.position; + return _proxyScrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; + } + + void _onScroll() { + final offset = _proxyScrollController.offset; + if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) { + _viewer.setShowingDetails(true); + } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { + _viewer.setShowingDetails(false); + } + _lastScrollOffset = offset; + } + + void _beginDrag(DragStartDetails details) { + _dragStart = details; + _shouldPopOnDrag = false; + _lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0; + + if (_viewController != null) { + _initialPhotoViewState = _viewController!.value; + } + + if (_showingDetails) { + _dragIntent = _DragIntent.scroll; + _startProxyDrag(); + } + } + + void _startProxyDrag() { + if (_proxyScrollController.hasClients && _dragStart != null) { + _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + } + } + + void _updateDrag(DragUpdateDetails details) { + if (_blockGestures) return; + + _dragInProgress = true; + + if (_dragIntent == _DragIntent.none) { + _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { + < -kTouchSlop => _DragIntent.scroll, + > kTouchSlop => _DragIntent.dismiss, + _ => _DragIntent.none, + }; + } + + switch (_dragIntent) { + case _DragIntent.none: + case _DragIntent.scroll: + if (_drag == null) _startProxyDrag(); + _drag?.update(details); + case _DragIntent.dismiss: + _handleDragDown(context, details.localPosition - _dragStart!.localPosition); + } + } + + void _endDrag(DragEndDetails details) { + _dragInProgress = false; + + if (_blockGestures) { + _blockGestures = false; + return; + } + + final intent = _dragIntent; + _dragIntent = _DragIntent.none; + _dragStart = null; + + switch (intent) { + case _DragIntent.none: + case _DragIntent.scroll: + final scrollVelocity = -(details.primaryVelocity ?? 0.0); + if (_willClose(scrollVelocity)) { + _viewer.setShowingDetails(false); + } + _drag?.end(details); + _drag = null; + case _DragIntent.dismiss: + if (_shouldPopOnDrag) { + context.maybePop(); + return; + } + _viewController?.animateMultiple( + position: _initialPhotoViewState.position, + scale: _viewController?.initialScale ?? _initialPhotoViewState.scale, + rotation: _initialPhotoViewState.rotation, + ); + _viewer.setOpacity(1.0); + } + } + + void _onDragStart( + BuildContext context, + DragStartDetails details, + PhotoViewControllerBase controller, + PhotoViewScaleStateController scaleStateController, + ) { + _viewController = controller; + if (!_showingDetails && _isZoomed) { + _blockGestures = true; + return; + } + _beginDrag(details); + } + + void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) => + _updateDrag(details); + + void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details); + + void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0)); + + void _handleDragDown(BuildContext context, Offset delta) { + const dragRatio = 0.2; + const popThreshold = 75.0; + + _shouldPopOnDrag = delta.dy > popThreshold; + + final distance = delta.dy.abs(); + + final maxScaleDistance = context.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale; + final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null; + + final opacity = 1.0 - (scaleReduction / dragRatio); + + _viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale); + _viewer.setOpacity(opacity); + } + + void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { + if (!_showingDetails && !_dragInProgress) _viewer.toggleControls(); + } + + void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + + void _onScaleStateChanged(PhotoViewScaleState scaleState) { + _isZoomed = switch (scaleState) { + PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, + _ => false, + }; + _viewer.setZoomed(_isZoomed); + + if (scaleState != PhotoViewScaleState.initial) { + if (!_dragInProgress) _viewer.setControls(false); + + ref.read(videoPlayerControlsProvider.notifier).pause(); + return; + } + + if (!_showingDetails) _viewer.setControls(true); + } + + void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (controller == null || controller.scaleBoundaries != null) return; + _scaleBoundarySub = controller.outputStateStream.listen((_) { + if (controller.scaleBoundaries != null) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (mounted) setState(() {}); + } + }); + } + + double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) { + final sb = _viewController?.scaleBoundaries; + if (sb != null) return sb.childSize.height * sb.initialScale; + + if (asset == null || asset.width == null || asset.height == null) return maxHeight; + + final r = asset.width! / asset.height!; + return math.min(maxWidth / r, maxHeight); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + _viewController = controller; + _listenForScaleBoundaries(controller); + } + + Widget _buildPhotoView( + BaseAsset displayAsset, + BaseAsset asset, { + required bool isCurrentPage, + required bool showingDetails, + required bool isPlayingMotionVideo, + required BoxDecoration backgroundDecoration, + }) { + final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + + if (displayAsset.isImage && !isPlayingMotionVideo) { + final size = context.sizeData; + return PhotoView( + key: ValueKey(displayAsset.heroTag), + index: widget.index, + imageProvider: getFullImageProvider(displayAsset, size: size), + heroAttributes: heroAttributes, + loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), + backgroundDecoration: backgroundDecoration, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + disableScaleGestures: showingDetails, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + errorBuilder: (_, __, ___) => SizedBox( + width: size.width, + height: size.height, + child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + ), + ); + } + + return PhotoView.customChild( + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + maxScale: 1.0, + basePosition: Alignment.center, + disableScaleGestures: true, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + enablePanAlways: true, + backgroundDecoration: backgroundDecoration, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewer( + key: ValueKey(displayAsset.heroTag), + asset: displayAsset, + image: Image( + key: ValueKey(displayAsset), + image: getFullImageProvider(displayAsset, size: context.sizeData), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); + final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + + final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index); + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + BaseAsset displayAsset = asset; + final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + displayAsset = stackChildren.elementAt(stackIndex); + } + + final viewportWidth = MediaQuery.widthOf(context); + final viewportHeight = MediaQuery.heightOf(context); + final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); + + final margin = (viewportHeight - imageHeight) / 2; + final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2); + _snapOffset = (margin + imageHeight) - (viewportHeight / 4); + + if (_proxyScrollController.hasClients) { + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + } + + return ProviderScope( + overrides: [ + currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), + currentAssetExifProvider.overrideWith((ref) { + final a = ref.watch(currentAssetNotifier); + if (a == null) return Future.value(null); + return ref.watch(assetServiceProvider).getExif(a); + }), + ], + child: Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), + ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + displayAsset, + asset, + isCurrentPage: currentHeroTag == asset.heroTag, + showingDetails: _showingDetails, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), + ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: overflowBoxHeight), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart new file mode 100644 index 0000000000000..ca7498a37fbb6 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; + +class AssetPreloader { + static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); + + final TimelineService timelineService; + final bool Function() mounted; + + Timer? _timer; + ImageStream? _prevStream; + ImageStream? _nextStream; + + AssetPreloader({required this.timelineService, required this.mounted}); + + void preload(int index, Size size) { + unawaited(timelineService.preloadAssets(index)); + _timer?.cancel(); + _timer = Timer(Durations.medium4, () async { + if (!mounted()) return; + final (prev, next) = await ( + timelineService.getAssetAsync(index - 1), + timelineService.getAssetAsync(index + 1), + ).wait; + if (!mounted()) return; + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + _prevStream = prev != null ? _resolveImage(prev, size) : null; + _nextStream = next != null ? _resolveImage(next, size) : null; + }); + } + + ImageStream _resolveImage(BaseAsset asset, Size size) { + return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + } + + void dispose() { + _timer?.cancel(); + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 0978b3c9afc47..2835342b85694 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { const AssetStackRow({super.key}); @@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0; - - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: _StackList(stack: stackChildren), - ), - ); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + return _StackList(stack: stackChildren); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index ed2ab9d15d8d2..13311fc4b2d21 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; @RoutePage() class AssetViewerPage extends StatelessWidget { @@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget { _setAsset(ref, asset); } - void changeAsset(WidgetRef ref, BaseAsset asset) { - _setAsset(ref, asset); - } - static void _setAsset(WidgetRef ref, BaseAsset asset) { // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); @@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(videoPlayerControlsProvider.notifier).pause(); } // Hide controls by default for videos - if (asset.isVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } + if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } } -const double _kBottomSheetMinimumExtent = 0.4; -const double _kBottomSheetSnapExtent = 0.67; - class _AssetViewerState extends ConsumerState { - static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); late PageController pageController; - late DraggableScrollableController bottomSheetController; - PersistentBottomSheetController? sheetCloseController; - // PhotoViewGallery takes care of disposing it's controllers - PhotoViewControllerBase? viewController; - StreamSubscription? reloadSubscription; + + StreamSubscription? _reloadSubscription; late final int heroOffset; - late PhotoViewControllerValue initialPhotoViewState; - bool? hasDraggedDown; - bool isSnapping = false; - bool blockGestures = false; - bool dragInProgress = false; - bool shouldPopOnDrag = false; - bool assetReloadRequested = false; - double previousExtent = _kBottomSheetMinimumExtent; - Offset dragDownPosition = Offset.zero; - int totalAssets = 0; - int stackIndex = 0; - BuildContext? scaffoldContext; - Map videoPlayerKeys = {}; - - // Delayed operations that should be cancelled on disposal - final List _delayedOperations = []; - - ImageStream? _prevPreCacheStream; - ImageStream? _nextPreCacheStream; + bool _assetReloadRequested = false; + int _totalAssets = 0; + late final AssetPreloader _preloader; KeepAliveLink? _stackChildrenKeepAlive; @override @@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState { super.initState(); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); - totalAssets = ref.read(timelineServiceProvider).totalAssets; - bottomSheetController = DraggableScrollableController(); + final timelineService = ref.read(timelineServiceProvider); + _totalAssets = timelineService.totalAssets; + _preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - reloadSubscription = EventStream.shared.listen(_onEvent); + _reloadSubscription = EventStream.shared.listen(_onEvent); heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final asset = ref.read(currentAssetNotifier); - if (asset != null) { - _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); - } - if (ref.read(assetViewerProvider).showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); } @override void dispose() { pageController.dispose(); - bottomSheetController.dispose(); - _cancelTimers(); - reloadSubscription?.cancel(); - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _preloader.dispose(); + _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } - bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); - - Color get backgroundColor { - final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); - return Colors.black.withAlpha(opacity); - } - - void _cancelTimers() { - for (final timer in _delayedOperations) { - timer.cancel(); - } - _delayedOperations.clear(); - } - - double _getVerticalOffsetForBottomSheet(double extent) => - (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); - - ImageStream _precacheImage(BaseAsset asset) { - final provider = getFullImageProvider(asset, size: context.sizeData); - return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); - } - - void _precacheAssets(int index) { - final timelineService = ref.read(timelineServiceProvider); - unawaited(timelineService.preCacheAssets(index)); - _cancelTimers(); - // This will trigger the pre-caching of adjacent assets ensuring - // that they are ready when the user navigates to them. - final timer = Timer(Durations.medium4, () async { - // Check if widget is still mounted before proceeding - if (!mounted) return; - - final (prevAsset, nextAsset) = await ( - timelineService.getAssetAsync(index - 1), - timelineService.getAssetAsync(index + 1), - ).wait; - if (!mounted) return; - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - _prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null; - _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; - }); - _delayedOperations.add(timer); - } - - void _onAssetInit(Duration _) { - _precacheAssets(widget.initialIndex); + void _onAssetInit(Duration timeStamp) { + _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); } void _onAssetChanged(int index) async { final timelineService = ref.read(timelineServiceProvider); final asset = await timelineService.getAssetAsync(index); - if (asset == null) { - return; - } + if (asset == null) return; - widget.changeAsset(ref, asset); - _precacheAssets(index); + AssetViewer._setAsset(ref, asset); + _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -238,223 +145,40 @@ class _AssetViewerState extends ConsumerState { final asset = ref.read(currentAssetNotifier); if (asset == null) return; - // hide any casting snackbars if they exist - context.scaffoldMessenger.hideCurrentSnackBar(); - - // send image to casting if the server has it if (asset is RemoteAsset) { + context.scaffoldMessenger.hideCurrentSnackBar(); ref.read(castProvider.notifier).loadMedia(asset, false); - } else { - // casting cannot show local assets - context.scaffoldMessenger.clearSnackBars(); - - if (ref.read(castProvider).isCasting) { - ref.read(castProvider.notifier).stop(); - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "local_asset_cast_failed".tr(), - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ), - ), - ); - } - } - } - - void _onPageBuild(PhotoViewControllerBase controller) { - viewController ??= controller; - if (showingBottomSheet && bottomSheetController.isAttached) { - final verticalOffset = - (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); - controller.position = Offset(0, -verticalOffset); - // Apply the zoom effect when the bottom sheet is showing - controller.scale = (controller.scale ?? 1.0) + 0.01; - } - } - - void _onPageChanged(int index, PhotoViewControllerBase? controller) { - _onAssetChanged(index); - viewController = controller; - } - - void _onDragStart( - _, - DragStartDetails details, - PhotoViewControllerBase controller, - PhotoViewScaleStateController scaleStateController, - ) { - viewController = controller; - dragDownPosition = details.localPosition; - initialPhotoViewState = controller.value; - final isZoomed = - scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || - scaleStateController.scaleState == PhotoViewScaleState.covering; - if (!showingBottomSheet && isZoomed) { - blockGestures = true; - } - } - - void _onDragEnd(BuildContext ctx, _, __) { - dragInProgress = false; - - if (shouldPopOnDrag) { - // Dismiss immediately without state updates to avoid rebuilds - ctx.maybePop(); - return; - } - - // Do not reset the state if the bottom sheet is showing - if (showingBottomSheet) { - _snapBottomSheet(); - return; - } - - // If the gestures are blocked, do not reset the state - if (blockGestures) { - blockGestures = false; return; } - shouldPopOnDrag = false; - hasDraggedDown = null; - viewController?.animateMultiple( - position: initialPhotoViewState.position, - scale: viewController?.initialScale ?? initialPhotoViewState.scale, - rotation: initialPhotoViewState.rotation, + context.scaffoldMessenger.clearSnackBars(); + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), ); - ref.read(assetViewerProvider.notifier).setOpacity(255); - } - - void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { - if (blockGestures) { - return; - } - - dragInProgress = true; - final delta = details.localPosition - dragDownPosition; - hasDraggedDown ??= delta.dy > 0; - if (!hasDraggedDown! || showingBottomSheet) { - _handleDragUp(ctx, delta); - return; - } - - _handleDragDown(ctx, delta); - } - - void _handleDragUp(BuildContext ctx, Offset delta) { - const double openThreshold = 50; - - final position = initialPhotoViewState.position + Offset(0, delta.dy); - final distanceToOrigin = position.distance; - - viewController?.updateMultiple(position: position); - // Moves the bottom sheet when the asset is being dragged up - if (showingBottomSheet && bottomSheetController.isAttached) { - final centre = (ctx.height * _kBottomSheetMinimumExtent); - bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); - } - - if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { - _openBottomSheet(ctx); - } - } - - void _handleDragDown(BuildContext ctx, Offset delta) { - const double dragRatio = 0.2; - const double popThreshold = 75; - - final distance = delta.distance; - shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; - - final maxScaleDistance = ctx.height * 0.5; - final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); - double? updatedScale; - double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; - if (initialScale != null) { - updatedScale = initialScale * (1.0 - scaleReduction); - } - - final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); - - viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale); - ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); - } - - void _onTapDown(_, __, ___) { - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).toggleControls(); - } - } - - bool _onNotification(Notification delta) { - if (delta is DraggableScrollableNotification) { - _handleDraggableNotification(delta); - } - - // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after - // the isSnapping guard is to prevent the notification from recursively handling the - // notification, eventually resulting in a heap overflow - if (!isSnapping && delta is ScrollEndNotification) { - _snapBottomSheet(); - } - return false; - } - - void _handleDraggableNotification(DraggableScrollableNotification delta) { - final currentExtent = delta.extent; - final isDraggingDown = currentExtent < previousExtent; - previousExtent = currentExtent; - // Closes the bottom sheet if the user is dragging down - if (isDraggingDown && delta.extent < 0.67) { - if (dragInProgress) { - blockGestures = true; - } - // Jump to a lower position before starting close animation to prevent glitch - if (bottomSheetController.isAttached) { - bottomSheetController.jumpTo(0.67); - } - sheetCloseController?.close(); - } - - // If the asset is being dragged down, we do not want to update the asset position again - if (dragInProgress) { - return; - } - - final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent); - // Moves the asset when the bottom sheet is being dragged - if (verticalOffset > 0) { - viewController?.position = Offset(0, -verticalOffset); - } } void _onEvent(Event event) { - if (event is TimelineReloadEvent) { - _onTimelineReloadEvent(); - return; - } - - if (event is ViewerReloadAssetEvent) { - assetReloadRequested = true; - return; - } - - if (event is ViewerOpenBottomSheetEvent) { - final extent = _kBottomSheetMinimumExtent + 0.3; - _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode); - final offset = _getVerticalOffsetForBottomSheet(extent); - viewController?.position = Offset(0, -offset); - return; + switch (event) { + case TimelineReloadEvent(): + _onTimelineReloadEvent(); + case ViewerReloadAssetEvent(): + _assetReloadRequested = true; + default: } } void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - totalAssets = timelineService.totalAssets; + _totalAssets = timelineService.totalAssets; - if (totalAssets == 0) { + if (_totalAssets == 0) { context.maybePop(); return; } @@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState { } } - if (index >= totalAssets) { - index = totalAssets - 1; + if (index >= _totalAssets) { + index = _totalAssets - 1; pageController.jumpToPage(index); } - if (assetReloadRequested) { - assetReloadRequested = false; + if (_assetReloadRequested) { + _assetReloadRequested = false; _onAssetReloadEvent(index); } } void _onAssetReloadEvent(int index) async { final timelineService = ref.read(timelineServiceProvider); - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) { - return; - } + final newAsset = await timelineService.getAssetAsync(index); + if (newAsset == null) return; final currentAsset = ref.read(currentAssetNotifier); - // Do not reload / close the bottom sheet if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) { - return; - } - setState(() { - _onAssetChanged(pageController.page!.round()); - sheetCloseController?.close(); - }); - } + // Do not reload if the asset has not changed + if (newAsset.heroTag == currentAsset?.heroTag) return; - void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { - ref.read(assetViewerProvider.notifier).setBottomSheet(true); - previousExtent = _kBottomSheetMinimumExtent; - sheetCloseController = showBottomSheet( - context: ctx, - sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2), - constraints: const BoxConstraints(maxWidth: double.infinity), - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))), - backgroundColor: ctx.colorScheme.surfaceContainerLowest, - builder: (_) { - return NotificationListener( - onNotification: _onNotification, - child: activitiesMode - ? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent) - : AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), - ); - }, - ); - sheetCloseController?.closed.then((_) => _handleSheetClose()); - } - - void _handleSheetClose() { - viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: viewController?.initialScale); - ref.read(assetViewerProvider.notifier).setBottomSheet(false); - sheetCloseController = null; - shouldPopOnDrag = false; - hasDraggedDown = null; - } - - void _snapBottomSheet() { - if (!bottomSheetController.isAttached || - bottomSheetController.size > _kBottomSheetSnapExtent || - bottomSheetController.size < 0.4) { - return; - } - isSnapping = true; - bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut); - } - - Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { - return const Center(child: ImmichLoadingIndicator()); - } - - void _onScaleStateChanged(PhotoViewScaleState scaleState) { - if (scaleState != PhotoViewScaleState.initial) { - if (!dragInProgress) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - ref.read(videoPlayerControlsProvider.notifier).pause(); - return; - } - - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).setControls(true); - } - } - - void _onLongPress(_, __, ___) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = true; - } - - PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { - scaffoldContext ??= ctx; - final timelineService = ref.read(timelineServiceProvider); - final asset = timelineService.getAssetSafe(index); - - // If asset is not available in buffer, return a placeholder - if (asset == null) { - return PhotoViewGalleryPageOptions.customChild( - heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'), - child: Container( - width: ctx.width, - height: ctx.height, - color: backgroundColor, - child: const Center(child: CircularProgressIndicator()), - ), - ); - } - - BaseAsset displayAsset = asset; - final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex); - } - - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); - if (displayAsset.isImage && !isPlayingMotionVideo) { - return _imageBuilder(ctx, displayAsset); - } - - return _videoBuilder(ctx, displayAsset); - } - - PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = ctx.sizeData; - return PhotoViewGalleryPageOptions( - key: ValueKey(asset.heroTag), - imageProvider: getFullImageProvider(asset, size: size), - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - tightMode: true, - disableScaleGestures: showingBottomSheet, - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => Container( - width: size.width, - height: size.height, - color: backgroundColor, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), - ), - ); - } - - GlobalKey _getVideoPlayerKey(String id) { - videoPlayerKeys.putIfAbsent(id, () => GlobalKey()); - return videoPlayerKeys[id]!; - } - - PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - maxScale: 1.0, - basePosition: Alignment.center, - disableScaleGestures: true, - child: SizedBox( - width: ctx.width, - height: ctx.height, - child: NativeVideoViewer( - key: _getVideoPlayerKey(asset.heroTag), - asset: asset, - image: Image( - key: ValueKey(asset), - image: getFullImageProvider(asset, size: ctx.sizeData), - fit: BoxFit.contain, - height: ctx.height, - width: ctx.width, - alignment: Alignment.center, - ), - ), - ), - ); - } - - void _onPop(bool didPop, T? result) { - ref.read(currentAssetNotifier.notifier).dispose(); + _onAssetChanged(index); } @override Widget build(BuildContext context) { - // Rebuild the widget when the asset viewer state changes - // Using multiple selectors to avoid unnecessary rebuilds for other state changes - ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); - ref.watch(assetViewerProvider.select((s) => s.stackIndex)); - ref.watch(isPlayingMotionVideoProvider); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed)); + final backgroundColor = showingDetails + ? context.colorScheme.surface + : Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity))); // Listen for casting changes and send initial asset to the cast provider - ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { + ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) { if (!isCasting) return; - - final asset = ref.read(currentAssetNotifier); - if (asset == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) { _handleCasting(); }); }); - // Listen for control visibility changes and change system UI mode accordingly - ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { - if (showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { + final (controls, details) = state; + final mode = !controls || (CurrentPlatform.isIOS && details) + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge; + unawaited(SystemChrome.setEnabledSystemUIMode(mode)); }); - // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. - // Issue: https://github.com/flutter/flutter/issues/109037 - // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( - onPopInvokedWithResult: _onPop, + onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), child: Scaffold( backgroundColor: backgroundColor, appBar: const ViewerTopAppBar(), @@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState { child: const DownloadStatusFloatingButton(), ), ), + bottomNavigationBar: const ViewerBottomAppBar(), body: Stack( children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: pageController, + physics: isZoomed + ? const NeverScrollableScrollPhysics() + : CurrentPlatform.isIOS + ? const FastScrollPhysics() + : const FastClampingScrollPhysics(), + itemCount: _totalAssets, + onPageChanged: (index) => _onAssetChanged(index), + itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset), + ), ), - if (!showingBottomSheet) - const Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 36e5bf67d9159..dc510d601769e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { - final int backgroundOpacity; - final bool showingBottomSheet; + final double backgroundOpacity; + final bool showingDetails; final bool showingControls; + final bool isZoomed; final BaseAsset? currentAsset; final int stackIndex; const AssetViewerState({ - this.backgroundOpacity = 255, - this.showingBottomSheet = false, + this.backgroundOpacity = 1.0, + this.showingDetails = false, this.showingControls = true, + this.isZoomed = false, this.currentAsset, this.stackIndex = 0, }); AssetViewerState copyWith({ - int? backgroundOpacity, - bool? showingBottomSheet, + double? backgroundOpacity, + bool? showingDetails, bool? showingControls, + bool? isZoomed, BaseAsset? currentAsset, int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, + showingDetails: showingDetails ?? this.showingDetails, showingControls: showingControls ?? this.showingControls, + isZoomed: isZoomed ?? this.isZoomed, currentAsset: currentAsset ?? this.currentAsset, stackIndex: stackIndex ?? this.stackIndex, ); @@ -35,7 +39,7 @@ class AssetViewerState { @override String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; + return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)'; } @override @@ -44,8 +48,9 @@ class AssetViewerState { if (other.runtimeType != runtimeType) return false; return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && - other.showingBottomSheet == showingBottomSheet && + other.showingDetails == showingDetails && other.showingControls == showingControls && + other.isZoomed == isZoomed && other.currentAsset == currentAsset && other.stackIndex == stackIndex; } @@ -53,8 +58,9 @@ class AssetViewerState { @override int get hashCode => backgroundOpacity.hashCode ^ - showingBottomSheet.hashCode ^ + showingDetails.hashCode ^ showingControls.hashCode ^ + isZoomed.hashCode ^ currentAsset.hashCode ^ stackIndex.hashCode; } @@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(currentAsset: asset, stackIndex: 0); } - void setOpacity(int opacity) { + void setOpacity(double opacity) { if (opacity == state.backgroundOpacity) { return; } - state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); + state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls); } - void setBottomSheet(bool showing) { - if (showing == state.showingBottomSheet) { + void setShowingDetails(bool showing) { + if (showing == state.showingDetails) { return; } - state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); + state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } @@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(showingControls: !state.showingControls); } + void setZoomed(bool isZoomed) { + if (isZoomed == state.isZoomed) { + return; + } + state = state.copyWith(isZoomed: isZoomed); + } + void setStackIndex(int index) { if (index == state.stackIndex) { return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 537f2fc31d44d..93006ab978667 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; - final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - if (!showControls) { - opacity = 0; - } - final originalTheme = context.themeData; final actions = [ @@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget { ], ]; - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: AnimatedSwitcher( - duration: Durations.short4, - child: isSheetOpen - ? const SizedBox.shrink() - : Theme( - data: context.themeData.copyWith( - iconTheme: const IconThemeData(size: 22, color: Colors.white), - textTheme: context.themeData.textTheme.copyWith( - labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), - ), - ), - child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (asset.isVideo) const VideoControls(), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], - ), - ), + return AnimatedSwitcher( + duration: Durations.short4, + child: showingDetails + ? const SizedBox.shrink() + : Theme( + data: context.themeData.copyWith( + iconTheme: const IconThemeData(size: 22, color: Colors.white), + textTheme: context.themeData.textTheme.copyWith( + labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), + ), + ), + child: Container( + color: Colors.black.withAlpha(125), + padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (asset.isVideo) const VideoControls(), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], ), - ), - ), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart deleted file mode 100644 index 2e10e6856b429..0000000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:immich_mobile/utils/timezone.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -const _kSeparator = ' â€ĸ '; - -class AssetDetailBottomSheet extends ConsumerWidget { - final DraggableScrollableController? controller; - final double initialChildSize; - - const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - return BaseBottomSheet( - actions: [], - slivers: const [_AssetDetailBottomSheet()], - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} - -class _AssetDetailBottomSheet extends ConsumerWidget { - const _AssetDetailBottomSheet(); - - String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { - DateTime dateTime = asset.createdAt.toLocal(); - Duration timeZoneOffset = dateTime.timeZoneOffset; - - // Use EXIF timezone information if available (matching web app behavior) - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, timeZoneOffset) = applyTimezoneOffset( - dateTime: exifInfo!.dateTimeOriginal!, - timeZone: exifInfo.timeZone, - ); - } - - final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); - final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; - } - - String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { - final height = asset.height; - final width = asset.width; - final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; - final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; - - return switch ((fileSize, resolution)) { - (null, null) => '', - (String fileSize, null) => fileSize, - (null, String resolution) => resolution, - (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', - }; - } - - String? _getCameraInfoTitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - - return switch ((exifInfo.make, exifInfo.model)) { - (null, null) => null, - (String make, null) => make, - (null, String model) => model, - (String make, String model) => '$make $model', - }; - } - - String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; - return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - String? _getLensInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; - final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; - return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - Future _editDateTime(BuildContext context, WidgetRef ref) async { - await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); - } - - Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - if (!asset.hasRemote) { - return const SizedBox.shrink(); - } - - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } - - if (remoteAssetId == null) { - return const SizedBox.shrink(); - } - - final userId = ref.watch(currentUserProvider)?.id; - final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); - - return assetAlbums.when( - data: (albums) { - if (albums.isEmpty) { - return const SizedBox.shrink(); - } - - albums.sortBy((a) => a.name); - - return Column( - spacing: 12, - children: [ - if (albums.isNotEmpty) - SheetTile( - title: 'appears_in'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - Padding( - padding: const EdgeInsets.only(left: 24), - child: Column( - spacing: 12, - children: albums.map((album) { - final isOwner = album.ownerId == userId; - return AlbumTile( - album: album, - isOwner: isOwner, - onAlbumSelected: (album) async { - ref.invalidate(assetViewerProvider); - unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); - }, - ); - }).toList(), - ), - ), - ], - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - final cameraTitle = _getCameraInfoTitle(exifInfo); - final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; - final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); - - // Build file info tile based on asset type - Widget buildFileInfoTile() { - if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); - return FutureBuilder( - future: assetMediaRepository.getOriginalFilename(asset.id), - builder: (context, snapshot) { - final displayName = snapshot.data ?? asset.name; - return SheetTile( - title: displayName, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - }, - ); - } else { - // For remote assets, use the name directly - return SheetTile( - title: asset.name, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - } - } - - return SliverList.list( - children: [ - // Asset Date and Time - SheetTile( - title: _getDateTime(context, asset, exifInfo), - titleStyle: context.textTheme.labelLarge, - trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, - ), - if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), - const SheetPeopleDetails(), - const SheetLocationDetails(), - // Details header - SheetTile( - title: 'details'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - // File info - buildFileInfoTile(), - // Camera info - if (cameraTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: cameraTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getCameraInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Lens info - if (lensTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: lensTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getLensInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Rating bar - if (isRatingEnabled) ...[ - Padding( - padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - 'rating'.t(context: context), - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - RatingBar( - initialRating: exifInfo?.rating?.toDouble() ?? 0, - filledColor: context.themeData.colorScheme.primary, - unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), - itemSize: 40, - onRatingUpdate: (rating) async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); - }, - onClearRating: () async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); - }, - ), - ], - ), - ), - ], - // Appears in (Albums) - Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), - // padding at the bottom to avoid cut-off - const SizedBox(height: 60), - ], - ); - } -} - -class _SheetAssetDescription extends ConsumerStatefulWidget { - final ExifInfo exif; - final bool isEditable; - - const _SheetAssetDescription({required this.exif, this.isEditable = true}); - - @override - ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); -} - -class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { - late TextEditingController _controller; - final _descriptionFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.exif.description ?? ''); - } - - Future saveDescription(String? previousDescription) async { - final newDescription = _controller.text.trim(); - - if (newDescription == previousDescription) { - _descriptionFocus.unfocus(); - return; - } - - final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); - - if (!editAction.success) { - _controller.text = previousDescription ?? ''; - - ImmichToast.show( - context: context, - msg: 'exif_bottom_sheet_description_error'.t(context: context), - toastType: ToastType.error, - ); - } - - _descriptionFocus.unfocus(); - } - - @override - Widget build(BuildContext context) { - // Watch the current asset EXIF provider to get updates - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - // Update controller text when EXIF data changes - final currentDescription = currentExifInfo?.description ?? ''; - final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( - context: context, - ); - if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { - _controller.text = currentDescription; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: IgnorePointer( - ignoring: !widget.isEditable, - child: TextField( - controller: _controller, - keyboardType: TextInputType.multiline, - focusNode: _descriptionFocus, - maxLines: null, // makes it grow as text is added - decoration: InputDecoration( - hintText: hintText, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 538a9bde20109..643d3e87ef3c0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) { + if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index c1324b8ac0c53..a2c1372c83fd5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - if (showBottomSheet) { + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { showControls = false; } final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart new file mode 100644 index 0000000000000..aa3b8bb93fede --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; + +class ViewerBottomAppBar extends ConsumerWidget { + const ViewerBottomAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0.0; + } + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 10f3595d010f1..fb25e9e1cb6d2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart similarity index 80% rename from mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 193cf6022052e..4b748abc27350 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { @@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { } if (!showControls) { - opacity = 0; + opacity = 0.0; } final originalTheme = context.themeData; @@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); + context.router.push( + DriftActivitiesRoute( + album: album, + assetId: asset is RemoteAsset ? asset.id : null, + assetName: asset.name, + ), + ); }, ), @@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; return IgnorePointer( - ignoring: opacity < 255, + ignoring: opacity < 1.0, child: AnimatedOpacity( - opacity: opacity / 255, + opacity: opacity, duration: Durations.short2, child: AppBar( - backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), + backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), leading: const _AppBarBackButton(), iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet || isReadonlyModeEnabled + actions: showingDetails || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions @@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; + final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; return Padding( padding: const EdgeInsets.only(left: 12.0), @@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget { iconSize: 22, iconColor: foregroundColor, padding: EdgeInsets.zero, - elevation: isShowingSheet ? 4 : 0, + elevation: showingDetails ? 4 : 0, ), onPressed: context.maybePop, child: const Icon(Icons.arrow_back_rounded), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index fea3da88e5392..4493244dcc9b9 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; @@ -114,6 +115,7 @@ class _GeneralBottomSheetState extends ConsumerState { : const DeletePermanentActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), + const BulkTagAssetsActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290841..8187c7bac3111 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -332,6 +332,21 @@ class ActionNotifier extends Notifier { } } + Future tagAssets(ActionSource source, BuildContext context) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + final isTagged = await _service.tagAssets(ids, context); + if (!isTagged) { + return null; + } + + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to tag assets', error, stack); + return ActionResult(count: ids.length, success: false, error: error.toString()); + } + } + Future removeFromAlbum(ActionSource source, String albumId) async { final ids = _getRemoteIdsForSource(source); try { diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart similarity index 85% rename from mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart rename to mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 1956170c1e1c1..57183337595a0 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier { } } +class ScopedAssetNotifier extends CurrentAssetNotifier { + final BaseAsset _asset; + + ScopedAssetNotifier(this._asset); + + @override + BaseAsset? build() { + setAsset(_asset); + return _asset; + } +} + final currentAssetExifProvider = FutureProvider.autoDispose((ref) { final currentAsset = ref.watch(currentAssetNotifier); if (currentAsset == null) { diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart new file mode 100644 index 0000000000000..dbee7673456c7 --- /dev/null +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/domain/services/tag.service.dart'; + +class TagNotifier extends AsyncNotifier> { + late final TagService service; + + @override + Future> build() async { + final service = ref.watch(tagServiceProvider); + return await service.getAllTags(); + } + + Future bulkTagAssets(List assetIds, List tagIds) async { + await service.bulkTagAssets(assetIds, tagIds); + } + + Future> upsertTags(List tags) async { + final upsertedTags = await service.upsertTags(tags); + + state = AsyncValue.data({...?state.valueOrNull, ...upsertedTags}); + return upsertedTags; + } +} + +final tagProvider = AsyncNotifierProvider>(TagNotifier.new); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114392..5fd8d2be85293 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo { DriftActivitiesRoute({ Key? key, required RemoteAlbum album, + String? assetId, + String? assetName, List? children, }) : super( DriftActivitiesRoute.name, - args: DriftActivitiesRouteArgs(key: key, album: album), + args: DriftActivitiesRouteArgs( + key: key, + album: album, + assetId: assetId, + assetName: assetName, + ), initialChildren: children, ); @@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return DriftActivitiesPage(key: args.key, album: args.album); + return DriftActivitiesPage( + key: args.key, + album: args.album, + assetId: args.assetId, + assetName: args.assetName, + ); }, ); } class DriftActivitiesRouteArgs { - const DriftActivitiesRouteArgs({this.key, required this.album}); + const DriftActivitiesRouteArgs({ + this.key, + required this.album, + this.assetId, + this.assetName, + }); final Key? key; final RemoteAlbum album; + final String? assetId; + + final String? assetName; + @override String toString() { - return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}'; } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 3d3ef1494cba3..af070f33df870 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart'; @@ -22,6 +23,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/timezone.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.dart'; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -35,6 +37,7 @@ final actionServiceProvider = Provider( ref.watch(trashedLocalAssetRepository), ref.watch(assetMediaRepositoryProvider), ref.watch(downloadRepositoryProvider), + ref.watch(tagProvider.notifier), ), ); @@ -47,6 +50,7 @@ class ActionService { final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DownloadRepository _downloadRepository; + final TagNotifier _tagService; const ActionService( this._assetApiRepository, @@ -57,6 +61,7 @@ class ActionService { this._trashedLocalAssetRepository, this._assetMediaRepository, this._downloadRepository, + this._tagService, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -222,6 +227,26 @@ class ActionService { return true; } + Future tagAssets(List remoteIds, BuildContext context) async { + final tagResults = await showTagPickerModal(context: context); + if (tagResults == null) { + // user cancelled + return false; + } + + final selectedTagIds = tagResults.$1; + final selectedNewTagValues = tagResults.$2; + + if (selectedNewTagValues.isNotEmpty) { + final upsertedTags = await _tagService.upsertTags(selectedNewTagValues.toList()); + selectedTagIds.addAll(upsertedTags.map((t) => t.id)); + } + if (selectedTagIds.isNotEmpty) { + await _tagService.bulkTagAssets(remoteIds, selectedTagIds.toList()); + } + return true; + } + Future stack(String userId, List remoteIds) async { final stack = await _assetApiRepository.stack(remoteIds); await _remoteAssetRepository.stack(userId, stack); diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 1a714b6f402e0..bafe780647735 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -35,6 +35,7 @@ class ApiService implements Authentication { late ViewsApi viewApi; late MemoriesApi memoriesApi; late SessionsApi sessionsApi; + late TagsApi tagsApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -74,6 +75,7 @@ class ApiService implements Authentication { viewApi = ViewsApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); + tagsApi = TagsApi(_apiClient); } Future _setUserAgentHeader() async { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee79ab..dccb7657605ea 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -225,7 +225,7 @@ enum ActionButtonType { iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart new file mode 100644 index 0000000000000..f1bec85ea6e8b --- /dev/null +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +Future<(Set, Set)?> showTagPickerModal({required BuildContext context, Set? initialSelection}) { + return showDialog<(Set, Set)?>( + context: context, + builder: (context) => _TagPickerModal(initialSelection: initialSelection), + ); +} + +class _TagPickerModal extends HookConsumerWidget { + final Set? initialSelection; + + const _TagPickerModal({this.initialSelection}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedTagIds = useState>(initialSelection ?? {}); + final newTagValues = useState>({}); + + void onSelectExistingTag(Iterable tags) { + selectedTagIds.value = tags.map((tag) => tag.id).toSet(); + } + + void onSelectNewTag(Set tags) { + newTagValues.value = tags; + } + + return AlertDialog( + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 0), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text( + "cancel", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colorScheme.error, + ), + ).tr(), + ), + TextButton( + onPressed: () => context.pop((selectedTagIds.value, newTagValues.value)), + child: Text( + "action_common_update", + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor), + ).tr(), + ), + ], + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.6, + child: TagPicker( + onSelectExistingTag: onSelectExistingTag, + filter: selectedTagIds.value, + onSelectNewTag: onSelectNewTag, + ), + ), + ); + } +} + +class TagPicker extends HookConsumerWidget { + const TagPicker({super.key, required this.onSelectExistingTag, required this.filter, this.onSelectNewTag}); + + final Set filter; + + /// Callback when existing tags are selected/deselected. + final Function(Iterable) onSelectExistingTag; + + /// If not null, shows a tile to create a new tag with user's filter input. + final Function(Set)? onSelectNewTag; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formFocus = useFocusNode(); + final searchQuery = useState(''); + final tags = ref.watch(tagProvider); + final selectedTagIds = useState>(filter); + final borderRadius = const BorderRadius.all(Radius.circular(10)); + final selectedNewTagValues = useState>({}); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SearchField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + filled: true, + hintText: 'filter_tags'.tr(), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), + child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + ), + Expanded( + child: tags.widgetWhen( + onData: (tags) { + final trimmedQuery = _trimSlashes(searchQuery.value); + final queryResult = tags + .where((t) => t.value.toLowerCase().contains(trimmedQuery.toLowerCase())) + .toList(); + final showCreateTile = + (onSelectNewTag != null) && + trimmedQuery.isNotEmpty && + !tags.any((t) => t.value.toLowerCase() == trimmedQuery.toLowerCase()); + final isCreateSelected = selectedNewTagValues.value.contains(trimmedQuery); + return ListView.builder( + itemCount: queryResult.length + (showCreateTile ? 1 : 0), + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + if (showCreateTile && index == queryResult.length) { + // Create new tag tile + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isCreateSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: ListTile( + title: Text( + trimmedQuery, + style: context.textTheme.bodyLarge?.copyWith( + color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + trailing: Icon( + Icons.add, + color: isCreateSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + onTap: () { + final newSelectedNewTagValues = {...selectedNewTagValues.value}; + if (isCreateSelected) { + newSelectedNewTagValues.remove(trimmedQuery); + } else { + newSelectedNewTagValues.add(trimmedQuery); + } + selectedNewTagValues.value = newSelectedNewTagValues; + onSelectNewTag!.call(newSelectedNewTagValues); + }, + ), + ), + ); + } + final tag = queryResult[index]; + final isSelected = selectedTagIds.value.any((id) => id == tag.id); + + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: borderRadius, + ), + child: ListTile( + title: Text( + tag.value, + style: context.textTheme.bodyLarge?.copyWith( + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + onTap: () { + final newSelected = {...selectedTagIds.value}; + if (isSelected) { + newSelected.removeWhere((id) => id == tag.id); + } else { + newSelected.add(tag.id); + } + selectedTagIds.value = newSelected; + onSelectExistingTag(tags.where((t) => newSelected.contains(t.id))); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } + + String _trimSlashes(String s) { + return s.replaceAll(RegExp(r'^/+|/+$'), ''); + } +} diff --git a/mobile/lib/widgets/map/asset_marker_icon.dart b/mobile/lib/widgets/map/asset_marker_icon.dart new file mode 100644 index 0000000000000..ff6058161bbef --- /dev/null +++ b/mobile/lib/widgets/map/asset_marker_icon.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class AssetMarkerIcon extends StatelessWidget { + const AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); + + final String id; + final String thumbhash; + + @override + Widget build(BuildContext context) { + final imageUrl = getThumbnailUrlForRemoteId(id); + return LayoutBuilder( + builder: (context, constraints) { + final pinHeight = constraints.maxHeight * 0.14; + final pinWidth = constraints.maxWidth * 0.14; + return SizedOverflowBox( + size: Size(pinWidth, pinHeight), + child: Stack( + // alignment: AlignmentGeometry.center, + children: [ + Positioned( + bottom: 0, + left: constraints.maxWidth * 0.5, + child: CustomPaint( + painter: _PinPainter( + primaryColor: context.colorScheme.onSurface, + secondaryColor: context.colorScheme.surface, + primaryRadius: constraints.maxHeight * 0.06, + secondaryRadius: constraints.maxHeight * 0.038, + ), + child: SizedBox(height: pinHeight, width: pinWidth), + ), + ), + Positioned( + top: constraints.maxHeight * 0.07, + left: constraints.maxWidth * 0.17, + child: CircleAvatar( + radius: constraints.maxHeight * 0.40, + backgroundColor: context.colorScheme.onSurface, + child: CircleAvatar( + radius: constraints.maxHeight * 0.37, + backgroundImage: RemoteImageProvider(url: imageUrl), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _PinPainter extends CustomPainter { + final Color primaryColor; + final Color secondaryColor; + final double primaryRadius; + final double secondaryRadius; + + const _PinPainter({ + required this.primaryColor, + required this.secondaryColor, + required this.primaryRadius, + required this.secondaryRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + Paint primaryBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.fill; + + Paint secondaryBrush = Paint() + ..color = secondaryColor + ..style = PaintingStyle.fill; + + Paint lineBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); + canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); + canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); + // The line is to make the above triangluar path more prominent since it has a slight curve + canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); + } + + Path getTrianglePath(double x, double y) { + final firstEndPoint = Offset(x / 2, y); + final controlPoint = Offset(x / 2, y * 0.3); + final secondEndPoint = Offset(x, 0); + + return Path() + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) + ..lineTo(0, 0); + } + + @override + bool shouldRepaint(_PinPainter old) { + return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; + } +} diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 32d90a28d972c..7defb52264fff 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers @@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); final styleLoaded = useState(false); - final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; styleLoaded.value = false; - if (assetMarkerRemoteId != null) { - // The iOS impl returns wrong toScreenLocation without the delay - Future.delayed( - const Duration(milliseconds: 100), - () async => position.value = await mapController.toScreenLocation(centre), - ); - } onCreated?.call(mapController); } @@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), child: Stack( - alignment: Alignment.center, + alignment: AlignmentGeometry.topCenter, children: [ style.widgetWhen( onData: (style) => MapLibreMap( - initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), + initialCameraPosition: CameraPosition(target: centre, zoom: zoom), styleString: style, onMapCreated: onMapCreated, onStyleLoadedCallback: onStyleLoaded, @@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget { attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null, ), ), - ValueListenableBuilder( - valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null - ? PositionedAssetMarkerIcon( - size: height / 2, - point: value, - assetRemoteId: assetMarkerRemoteId!, - assetThumbhash: assetThumbhash!, - ) - : const SizedBox.shrink(), - ), + if (assetMarkerRemoteId != null && assetThumbhash != null) + Container( + width: width, + height: height / 2, + alignment: Alignment.bottomCenter, + child: SizedBox.square( + dimension: height / 2.5, + child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!), + ), + ), ], ), ), diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 95b127f5b769b..b6d7241cf4cb4 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,8 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; @@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), + child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); } } - -class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); - - final String id; - final String thumbhash; - - @override - Widget build(BuildContext context) { - final imageUrl = getThumbnailUrlForRemoteId(id); - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Positioned( - bottom: 0, - left: constraints.maxWidth * 0.5, - child: CustomPaint( - painter: _PinPainter( - primaryColor: context.colorScheme.onSurface, - secondaryColor: context.colorScheme.surface, - primaryRadius: constraints.maxHeight * 0.06, - secondaryRadius: constraints.maxHeight * 0.038, - ), - child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14), - ), - ), - Positioned( - top: constraints.maxHeight * 0.07, - left: constraints.maxWidth * 0.17, - child: CircleAvatar( - radius: constraints.maxHeight * 0.40, - backgroundColor: context.colorScheme.onSurface, - child: CircleAvatar( - radius: constraints.maxHeight * 0.37, - backgroundImage: RemoteImageProvider(url: imageUrl), - ), - ), - ), - ], - ); - }, - ); - } -} - -class _PinPainter extends CustomPainter { - final Color primaryColor; - final Color secondaryColor; - final double primaryRadius; - final double secondaryRadius; - - const _PinPainter({ - required this.primaryColor, - required this.secondaryColor, - required this.primaryRadius, - required this.secondaryRadius, - }); - - @override - void paint(Canvas canvas, Size size) { - Paint primaryBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.fill; - - Paint secondaryBrush = Paint() - ..color = secondaryColor - ..style = PaintingStyle.fill; - - Paint lineBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - - canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); - canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); - canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); - // The line is to make the above triangluar path more prominent since it has a slight curve - canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); - } - - Path getTrianglePath(double x, double y) { - final firstEndPoint = Offset(x / 2, y); - final controlPoint = Offset(x / 2, y * 0.3); - final secondEndPoint = Offset(x, 0); - - return Path() - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) - ..lineTo(0, 0); - } - - @override - bool shouldRepaint(_PinPainter old) { - return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; - } -} diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 69be96ed53591..f9d3c6676791f 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget { /// location. final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// A callback when a drag gesture is canceled by the system. + final VoidCallback? onDragCancel; + /// A pointer that will trigger a scale has stopped contacting the screen at a /// particular location. final PhotoViewImageScaleEndCallback? onScaleEnd; @@ -543,7 +548,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final computedOuterSize = widget.customSize ?? constraints.biggest; - final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black); + final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent); return widget._isCustomChild ? CustomChildWrapper( @@ -564,6 +569,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, @@ -596,6 +602,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index af5b9a7ce7229..aa33d18403779 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.onDragDown] final PhotoViewImageDragEndCallback? onDragEnd; - /// Mirror to [PhotoView.onDraUpdate] + /// Mirror to [PhotoView.onDragUpdate] final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// Mirror to [PhotoView.onDragCancel] + final VoidCallback? onDragCancel; + /// Mirror to [PhotoView.onTapDown] final PhotoViewImageTapDownCallback? onTapDown; diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index d21b49f020c3d..72c4766c45f69 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.gestureDetectorBehavior, @@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageLongPressStartCallback? onLongPressStart; @@ -386,6 +389,7 @@ class PhotoViewCoreState extends State onDragUpdate: widget.onDragUpdate != null ? (details) => widget.onDragUpdate!(context, details, widget.controller.value) : null, + onDragCancel: widget.onDragCancel, hitDetector: this, onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null, onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 0d2f6fa45745a..6cbcec8d82a9e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onLongPressStart, this.child, this.onTapUp, @@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget { final GestureDragEndCallback? onDragEnd; final GestureDragStartCallback? onDragStart; final GestureDragUpdateCallback? onDragUpdate; + final GestureDragCancelCallback? onDragCancel; final GestureTapUpCallback? onTapUp; final GestureTapDownCallback? onTapDown; @@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget { instance ..onStart = onDragStart ..onUpdate = onDragUpdate - ..onEnd = onDragEnd; + ..onEnd = onDragEnd + ..onCancel = onDragCancel; }, ); } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index cd70745703443..ee18668f52c8f 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.outerSize, @@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -203,6 +205,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: widget.outerSize, @@ -233,6 +236,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, gestureDetectorBehavior: widget.gestureDetectorBehavior, @@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, required this.outerSize, @@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget { onDragStart: onDragStart, onDragEnd: onDragEnd, onDragUpdate: onDragUpdate, + onDragCancel: onDragCancel, onScaleEnd: onScaleEnd, onLongPressStart: onLongPressStart, gestureDetectorBehavior: gestureDetectorBehavior, diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index da5ecab684234..ba21acf49c4ca 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -54,7 +54,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { saveEndpointList(); } - Widget proxyDecorator(Widget child, int index, Animation animation) { + Widget proxyDecorator(Widget child, int _, Animation animation) { return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { diff --git a/mobile/mise.toml b/mobile/mise.toml index 6767836aa36b2..88b890205363b 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -16,7 +16,15 @@ sources = [ "infrastructure/**/*.drift", ] outputs = { auto = true } -run = "dart run build_runner build --delete-conflicting-outputs" +run = [ + "dart run build_runner build --delete-conflicting-outputs", + "dart format lib/routing/router.gr.dart", +] + +[tasks."codegen:watch"] +alias = "watch" +description = "Watch and auto-generate dart code" +run = "dart run build_runner watch --delete-conflicting-outputs" [tasks."codegen:pigeon"] alias = "pigeon" @@ -32,7 +40,7 @@ depends = [ [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [{ task = "//i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] +run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }] [tasks."codegen:app-icon"] description = "Generate app icons" diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4ebe5c7c65547..afeeb694e1edb 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md) - [DatabaseBackupDto](doc//DatabaseBackupDto.md) - [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md) + - [DownloadArchiveDto](doc//DownloadArchiveDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f10490e093250..0d6a98c001398 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -155,6 +155,7 @@ part 'model/database_backup_config.dart'; part 'model/database_backup_delete_dto.dart'; part 'model/database_backup_dto.dart'; part 'model/database_backup_list_response_dto.dart'; +part 'model/download_archive_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index 524562275347a..4d0c5c8165d03 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -24,17 +24,17 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future downloadArchiveWithHttpInfo(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/download/archive'; // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = downloadArchiveDto; final queryParams = []; final headerParams = {}; @@ -67,13 +67,13 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchive(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await downloadArchiveWithHttpInfo(assetIdsDto, key: key, slug: slug, ); + Future downloadArchive(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { + final response = await downloadArchiveWithHttpInfo(downloadArchiveDto, key: key, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 470f3aec27fc0..5aabf5cd4b91c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -356,6 +356,8 @@ class ApiClient { return DatabaseBackupDto.fromJson(value); case 'DatabaseBackupListResponseDto': return DatabaseBackupListResponseDto.fromJson(value); + case 'DownloadArchiveDto': + return DownloadArchiveDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': diff --git a/mobile/openapi/lib/model/download_archive_dto.dart b/mobile/openapi/lib/model/download_archive_dto.dart new file mode 100644 index 0000000000000..20e8527f18b41 --- /dev/null +++ b/mobile/openapi/lib/model/download_archive_dto.dart @@ -0,0 +1,120 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class DownloadArchiveDto { + /// Returns a new [DownloadArchiveDto] instance. + DownloadArchiveDto({ + this.assetIds = const [], + this.edited, + }); + + /// Asset IDs + List assetIds; + + /// Download edited asset if available + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? edited; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveDto && + _deepEquality.equals(other.assetIds, assetIds) && + other.edited == edited; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (edited == null ? 0 : edited!.hashCode); + + @override + String toString() => 'DownloadArchiveDto[assetIds=$assetIds, edited=$edited]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + if (this.edited != null) { + json[r'edited'] = this.edited; + } else { + // json[r'edited'] = null; + } + return json; + } + + /// Returns a new [DownloadArchiveDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadArchiveDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveDto"); + if (value is Map) { + final json = value.cast(); + + return DownloadArchiveDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + edited: mapValueOfType(json, r'edited'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadArchiveDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadArchiveDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadArchiveDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadArchiveDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 4b54ec4055fea..8780a3388f69f 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; import 'package:mocktail/mocktail.dart'; class MockAlbumRepository extends Mock implements AlbumRepository {} @@ -46,3 +47,5 @@ class MockPartnerRepository extends Mock implements PartnerRepository {} class MockPartnerApiRepository extends Mock implements PartnerApiRepository {} class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {} + +class MockTagNotifier extends Mock implements TagNotifier {} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 87263c9ae7dd7..d50e08afb133c 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -27,6 +27,7 @@ void main() { late MockTrashedLocalAssetRepository trashedLocalAssetRepository; late MockAssetMediaRepository assetMediaRepository; late MockDownloadRepository downloadRepository; + late MockTagNotifier tagNotifier; late Drift db; @@ -53,6 +54,7 @@ void main() { trashedLocalAssetRepository = MockTrashedLocalAssetRepository(); assetMediaRepository = MockAssetMediaRepository(); downloadRepository = MockDownloadRepository(); + tagNotifier = MockTagNotifier(); sut = ActionService( assetApiRepository, @@ -63,6 +65,7 @@ void main() { trashedLocalAssetRepository, assetMediaRepository, downloadRepository, + tagNotifier, ); }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 13d6ba7e56e86..420c7e10151f0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5052,7 +5052,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/DownloadArchiveDto" } } }, @@ -17662,6 +17662,26 @@ }, "type": "object" }, + "DownloadArchiveDto": { + "properties": { + "assetIds": { + "description": "Asset IDs", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "edited": { + "description": "Download edited asset if available", + "type": "boolean" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6310316857596..8f057df6cc461 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 59a25d58b3c1c..acd8109cd3805 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1132,9 +1132,11 @@ export type ValidateAccessTokenResponseDto = { /** Authentication status */ authStatus: boolean; }; -export type AssetIdsDto = { +export type DownloadArchiveDto = { /** Asset IDs */ assetIds: string[]; + /** Download edited asset if available */ + edited?: boolean; }; export type DownloadInfoDto = { /** Album ID to download */ @@ -2309,6 +2311,10 @@ export type SharedLinkEditDto = { /** Custom URL slug */ slug?: string | null; }; +export type AssetIdsDto = { + /** Asset IDs */ + assetIds: string[]; +}; export type AssetIdsResponseDto = { /** Asset ID */ assetId: string; @@ -4433,10 +4439,10 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { /** * Download asset archive */ -export function downloadArchive({ key, slug, assetIdsDto }: { +export function downloadArchive({ key, slug, downloadArchiveDto }: { key?: string; slug?: string; - assetIdsDto: AssetIdsDto; + downloadArchiveDto: DownloadArchiveDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; @@ -4447,7 +4453,7 @@ export function downloadArchive({ key, slug, assetIdsDto }: { }))}`, oazapfts.json({ ...opts, method: "POST", - body: assetIdsDto + body: downloadArchiveDto }))); } /** diff --git a/package.json b/package.json index 0e4017f928451..c50c4e1eb8637 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "engines": { "pnpm": ">=10.0.0" } diff --git a/plugins/mise.toml b/plugins/mise.toml index c1001e574bd3b..66a107674d77b 100644 --- a/plugins/mise.toml +++ b/plugins/mise.toml @@ -1,7 +1,7 @@ [tools] "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" -"github:extism/js-pdk" = "1.5.1" +"github:extism/js-pdk" = "1.6.0" [tasks.install] run = "pnpm install --frozen-lockfile" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c139181d8b3c3..c4719d9752250 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,11 +63,11 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -106,19 +106,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -127,16 +127,16 @@ importers: dependencies: '@docusaurus/core': specifier: ~3.9.0 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/preset-classic': specifier: ~3.9.0 - version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/theme-common': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-mermaid': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@mdi/js': specifier: ^7.3.67 version: 7.4.47 @@ -145,13 +145,13 @@ importers: version: 1.6.1 '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@19.2.13)(react@18.3.1) + version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 version: 10.4.24(postcss@8.5.6) docusaurus-lunr-search: specifier: ^3.3.2 - version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lunr: specifier: ^2.3.9 version: 2.3.9 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/pg': specifier: ^8.15.1 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -317,10 +317,10 @@ importers: dependencies: '@oazapfts/runtime': specifier: ^1.0.2 - version: 1.1.0 + version: 1.2.0 devDependencies: '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 typescript: specifier: ^5.3.3 @@ -345,7 +345,7 @@ importers: version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3) + version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0) '@nestjs/common': specifier: ^11.0.4 version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -411,7 +411,7 @@ importers: version: 8.3.0(socket.io-adapter@2.5.6) ajv: specifier: ^8.17.1 - version: 8.17.1 + version: 8.18.0 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -426,7 +426,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.67.3 + version: 5.68.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -516,7 +516,7 @@ importers: version: 7.0.13 openid-client: specifier: ^6.3.3 - version: 6.8.1 + version: 6.8.2 pg: specifier: ^8.11.3 version: 8.18.0 @@ -639,7 +639,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/nodemailer': specifier: ^7.0.0 @@ -652,7 +652,7 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.13 + version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 version: 2.16.0 @@ -670,7 +670,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -718,16 +718,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 version: 1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) web: dependencies: @@ -742,10 +742,10 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.63.0 - version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) '@mapbox/mapbox-gl-rtl-text': - specifier: 0.2.3 - version: 0.2.3(mapbox-gl@1.13.3) + specifier: 0.3.0 + version: 0.3.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -775,7 +775,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.0) + version: 0.3.9(svelte@5.50.2) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -793,7 +793,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.5.0 + version: 20.6.1 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -808,7 +808,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.17.0 + version: 5.18.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.0) + version: 4.0.1(svelte@5.50.2) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.0) + version: 3.11.0(svelte@5.50.2) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.0) + version: 1.2.6(svelte@5.50.2) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.0) + version: 0.12.0(svelte@5.50.2) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -863,16 +863,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -881,7 +881,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -905,7 +905,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) dotenv: specifier: ^17.0.0 version: 17.2.4 @@ -920,7 +920,7 @@ importers: version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -941,19 +941,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.1)(svelte@5.50.0) + version: 3.4.1(prettier@3.8.1)(svelte@5.50.2) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.0 - version: 5.50.0 + specifier: 5.50.2 + version: 5.50.2 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.0) + version: 1.4.1(svelte@5.50.2) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -962,13 +962,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -3310,48 +3310,26 @@ packages: resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} hasBin: true - '@mapbox/geojson-types@1.0.2': - resolution: {integrity: sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==} - '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} - '@mapbox/mapbox-gl-rtl-text@0.2.3': - resolution: {integrity: sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' - - '@mapbox/mapbox-gl-supported@1.5.0': - resolution: {integrity: sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' + '@mapbox/mapbox-gl-rtl-text@0.3.0': + resolution: {integrity: sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==} '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@mapbox/point-geometry@0.1.0': - resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} - '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} - '@mapbox/tiny-sdf@1.2.5': - resolution: {integrity: sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==} - '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} - '@mapbox/unitbezier@0.0.0': - resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} - '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - '@mapbox/vector-tile@1.3.1': - resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} - '@mapbox/vector-tile@2.0.4': resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} @@ -3600,8 +3578,8 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true - '@oazapfts/runtime@1.1.0': - resolution: {integrity: sha512-PwCn69pexqg/uhc0bpEHSlRFdfTtSnq3icXHd0wf4BQwZSMKsCerTnydzegVScEegYkokzIxMcl9li7on86A2w==} + '@oazapfts/runtime@1.2.0': + resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} '@opentelemetry/api-logs@0.211.0': resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} @@ -5056,8 +5034,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -5140,63 +5118,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5408,6 +5386,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + algoliasearch-helper@3.26.1: resolution: {integrity: sha512-CAlCxm4fYBXtvc5MamDzP6Svu8rW4z9me4DCBY1rQ2UDJ0u0flWmusQ8M3nOExZsLLRcUwUPoRAPMrhzOG3erw==} peerDependencies: @@ -5744,8 +5725,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.67.3: - resolution: {integrity: sha512-eeQobOJn8M0Rj8tcZCVFLrimZgJQallJH1JpclOoyut2nDNkDwTEPMVcZzLeSR2fGeIVbfJTjU96F563Qkge5A==} + bullmq@5.68.0: + resolution: {integrity: sha512-PywC7eTcPrKVQN5iEfhs5ats90nSLr8dzsyIhgviO8qQRTHnTq/SnETq2E8Do1RLg7Qw1Q0p5htBPI/cUGAlHg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -6342,9 +6323,6 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csscolorparser@1.0.3: - resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} - cssdb@8.5.2: resolution: {integrity: sha512-Pmoj9RmD8RIoIzA2EQWO4D4RMeDts0tgAH0VXdlNdxjuBGI3a9wMOIcUwaPNmD4r2qtIa06gqkIf7sECl+cBCg==} @@ -6840,9 +6818,6 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -7034,11 +7009,11 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.14.0: - resolution: {integrity: sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==} + eslint-plugin-svelte@3.15.0: + resolution: {integrity: sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.1 || ^9.0.0 + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: @@ -7470,9 +7445,6 @@ packages: resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} hasBin: true - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -7601,9 +7573,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - grid-index@1.1.0: - resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} - gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -7619,8 +7588,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.5.0: - resolution: {integrity: sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==} + happy-dom@20.6.1: + resolution: {integrity: sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8329,9 +8298,6 @@ packages: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true - kdbush@3.0.0: - resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} - kdbush@4.0.2: resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} @@ -8663,12 +8629,8 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - mapbox-gl@1.13.3: - resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} - engines: {node: '>=6.4.0'} - - maplibre-gl@5.17.0: - resolution: {integrity: sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==} + maplibre-gl@5.18.0: + resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -8694,8 +8656,8 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.1: - resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} hasBin: true @@ -9316,8 +9278,8 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -9382,8 +9344,8 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - openid-client@6.8.1: - resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -10147,9 +10109,6 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - potpack@1.0.2: - resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} - potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} @@ -10301,9 +10260,6 @@ packages: resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} engines: {node: '>=18'} - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -10868,8 +10824,8 @@ packages: resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==} engines: {node: '>=0.12.18'} - simple-icons@16.4.0: - resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==} + simple-icons@16.9.0: + resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -11127,9 +11083,6 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} - supercluster@7.1.5: - resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} - supercluster@8.0.1: resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} @@ -11227,8 +11180,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.0: - resolution: {integrity: sha512-FR9kTLmX5i0oyeQ5j/+w8DuagIkQ7MWMuPpPVioW2zx9Dw77q+1ufLzF1IqNtcTXPRnIIio4PlasliVn43OnbQ==} + svelte@5.50.2: + resolution: {integrity: sha512-WCxzm3BBf+Ase6RwiDPR4G36cM4Kb0NuhmLK6x44I+D6reaxizDDg8kBkk4jT/19+Rgmc44eZkOvMO6daoSFIw==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11330,6 +11283,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} @@ -11429,9 +11383,6 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -11592,8 +11543,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -11848,8 +11799,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-tsconfig-paths@6.1.0: - resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' @@ -11955,9 +11906,6 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - vt-pbf@3.1.3: - resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -13636,26 +13584,26 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/core@4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@docsearch/css@4.3.2': {} - '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: '@ai-sdk/react': 2.0.115(react@18.3.1)(zod@4.2.1) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.46.0)(algoliasearch@5.46.0)(search-insights@2.17.3) - '@docsearch/core': 4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/core': 4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docsearch/css': 4.3.2 ai: 5.0.113(zod@4.2.1) algoliasearch: 5.46.0 marked: 16.4.2 zod: 4.2.1 optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 @@ -13729,7 +13677,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/bundler': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -13738,7 +13686,7 @@ snapshots: '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -13844,7 +13792,7 @@ snapshots: dependencies: '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 18.3.1 @@ -13858,13 +13806,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13899,13 +13847,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13939,9 +13887,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13969,9 +13917,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13996,9 +13944,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 @@ -14024,9 +13972,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14050,9 +13998,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/gtag.js': 0.0.12 @@ -14077,9 +14025,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14103,9 +14051,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14134,9 +14082,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14164,22 +14112,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': - dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14206,25 +14154,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-classic@3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) clsx: 2.1.1 infima: 0.2.0-alpha.45 lodash: 4.17.23 @@ -14256,15 +14204,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14280,11 +14228,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) mermaid: 11.12.2 @@ -14310,13 +14258,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14363,7 +14311,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.13 + '@types/react': 19.2.14 commander: 5.1.0 joi: 17.13.3 react: 18.3.1 @@ -14954,22 +14902,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.0)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': dependencies: front-matter: 4.0.2 - marked: 17.0.1 + marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.50.0 + svelte: 5.50.2 - '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)': + '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) luxon: 3.7.2 - simple-icons: 16.4.0 - svelte: 5.50.0 + simple-icons: 16.9.0 + svelte: 5.50.2 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -15246,8 +15194,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -15279,17 +15227,9 @@ snapshots: get-stream: 6.0.1 minimist: 1.2.8 - '@mapbox/geojson-types@1.0.2': {} - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - '@mapbox/mapbox-gl-rtl-text@0.2.3(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 - - '@mapbox/mapbox-gl-supported@1.5.0(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 + '@mapbox/mapbox-gl-rtl-text@0.3.0': {} '@mapbox/node-pre-gyp@1.0.11': dependencies: @@ -15322,22 +15262,12 @@ snapshots: - encoding - supports-color - '@mapbox/point-geometry@0.1.0': {} - '@mapbox/point-geometry@1.1.0': {} - '@mapbox/tiny-sdf@1.2.5': {} - '@mapbox/tiny-sdf@2.0.7': {} - '@mapbox/unitbezier@0.0.0': {} - '@mapbox/unitbezier@0.0.1': {} - '@mapbox/vector-tile@1.3.1': - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile@2.0.4': dependencies: '@mapbox/point-geometry': 1.1.0 @@ -15414,10 +15344,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 '@mermaid-js/parser@0.6.3': @@ -15452,12 +15382,12 @@ snapshots: '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)': dependencies: '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.67.3 + bullmq: 5.68.0 tslib: 2.8.1 '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': @@ -15634,7 +15564,7 @@ snapshots: dependencies: consola: 3.4.2 - '@oazapfts/runtime@1.1.0': {} + '@oazapfts/runtime@1.2.0': {} '@opentelemetry/api-logs@0.211.0': dependencies: @@ -16320,17 +16250,17 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.50.0 - svelte-parse-markup: 0.1.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-parse-markup: 0.1.5(svelte@5.50.2) vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -16338,11 +16268,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16354,28 +16284,28 @@ snapshots: sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.0 + svelte: 5.50.2 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: @@ -16623,18 +16553,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.50.2)': dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - '@testing-library/svelte@5.3.1(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.50.0) - svelte: 5.50.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.50.2) + svelte: 5.50.2 optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17117,21 +17047,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 - '@types/react@19.2.13': + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -17235,14 +17165,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -17251,41 +17181,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17293,14 +17223,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.4 @@ -17310,27 +17240,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17345,11 +17275,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17364,7 +17294,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17502,10 +17432,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.0)': + '@zoom-image/svelte@0.3.9(svelte@5.50.2)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.0 + svelte: 5.50.2 abab@2.0.6: optional: true @@ -17575,9 +17505,9 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.2.1 - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: @@ -17587,9 +17517,9 @@ snapshots: dependencies: ajv: 6.12.6 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 fast-deep-equal: 3.1.3 ajv@6.12.6: @@ -17606,6 +17536,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch-helper@3.26.1(algoliasearch@5.46.0): dependencies: '@algolia/events': 4.0.1 @@ -17866,15 +17803,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) - svelte: 5.50.0 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + svelte: 5.50.2 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -17987,7 +17924,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.67.3: + bullmq@5.68.0: dependencies: cron-parser: 4.9.0 ioredis: 5.9.2 @@ -18583,8 +18520,6 @@ snapshots: css.escape@1.5.1: {} - csscolorparser@1.0.3: {} - cssdb@8.5.2: {} cssesc@3.0.0: {} @@ -19035,9 +18970,9 @@ snapshots: transitivePeerDependencies: - supports-color - docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) autocomplete.js: 0.37.1 clsx: 2.1.1 gauge: 3.0.2 @@ -19127,8 +19062,6 @@ snapshots: duplexer@0.1.2: {} - earcut@2.2.4: {} - earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -19402,7 +19335,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19414,9 +19347,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.4 - svelte-eslint-parser: 1.4.1(svelte@5.50.0) + svelte-eslint-parser: 1.4.1(svelte@5.50.2) optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - ts-node @@ -19984,8 +19917,6 @@ snapshots: pbf: 3.3.0 shapefile: 0.6.6 - geojson-vt@3.2.1: {} - geojson@0.5.0: {} get-caller-file@2.0.5: {} @@ -20135,8 +20066,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - grid-index@1.1.0: {} - gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -20154,12 +20083,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.5.0: + happy-dom@20.6.1: dependencies: '@types/node': 24.10.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 - entities: 4.5.0 + entities: 6.0.1 whatwg-mimetype: 3.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -21036,8 +20965,6 @@ snapshots: dependencies: commander: 8.3.0 - kdbush@3.0.0: {} - kdbush@4.0.2: {} keygrip@1.1.0: @@ -21329,32 +21256,7 @@ snapshots: transitivePeerDependencies: - supports-color - mapbox-gl@1.13.3: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/geojson-types': 1.0.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/mapbox-gl-supported': 1.5.0(mapbox-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.4 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - - maplibre-gl@5.17.0: + maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -21393,7 +21295,7 @@ snapshots: marked@16.4.2: {} - marked@17.0.1: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -22316,7 +22218,7 @@ snapshots: pkg-types: 2.3.0 tinyexec: 0.3.2 - oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -22389,10 +22291,10 @@ snapshots: opener@1.5.2: {} - openid-client@6.8.1: + openid-client@6.8.2: dependencies: jose: 6.1.3 - oauth4webapi: 3.8.3 + oauth4webapi: 3.8.5 optionator@0.9.4: dependencies: @@ -23187,8 +23089,6 @@ snapshots: postgres@3.4.8: {} - potpack@1.0.2: {} - potpack@2.1.0: {} prelude-ls@1.2.1: {} @@ -23206,10 +23106,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.0): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.2): dependencies: prettier: 3.8.1 - svelte: 5.50.0 + svelte: 5.50.2 prettier@3.8.1: {} @@ -23348,8 +23248,6 @@ snapshots: quick-lru@7.3.0: {} - quickselect@2.0.0: {} - quickselect@3.0.0: {} railroad-diagrams@1.0.0: {} @@ -23836,14 +23734,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.0 + svelte: 5.50.2 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -23910,9 +23808,9 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) search-insights@2.17.3: {} @@ -24141,7 +24039,7 @@ snapshots: simple-icons@15.22.0: {} - simple-icons@16.4.0: {} + simple-icons@16.9.0: {} sirv@2.0.4: dependencies: @@ -24447,10 +24345,6 @@ snapshots: transitivePeerDependencies: - supports-color - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - supercluster@8.0.1: dependencies: kdbush: 4.0.2 @@ -24473,23 +24367,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.0): + svelte-awesome@3.3.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.50.0 + svelte: 5.50.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.0): + svelte-eslint-parser@1.4.1(svelte@5.50.2): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24498,7 +24392,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 svelte-floating-ui@1.5.8: dependencies: @@ -24511,7 +24405,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.0): + svelte-i18n@4.0.1(svelte@5.50.2): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24519,10 +24413,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.0 + svelte: 5.50.2 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.0): + svelte-jsoneditor@3.11.0(svelte@5.50.2): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24537,7 +24431,7 @@ snapshots: '@jsonquerylang/jsonquery': 5.1.1 '@lezer/highlight': 1.2.3 '@replit/codemirror-indentation-markers': 6.5.3(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8) - ajv: 8.17.1 + ajv: 8.18.0 codemirror-wrapped-line-indent: 1.0.9(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.8) diff-sequences: 29.6.3 immutable-json-patch: 6.0.2 @@ -24549,42 +24443,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.0 - svelte-awesome: 3.3.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-awesome: 3.3.5(svelte@5.50.2) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.0): + svelte-maplibre@1.2.6(svelte@5.50.2): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.17.0 + maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.0 + svelte: 5.50.2 - svelte-parse-markup@0.1.5(svelte@5.50.0): + svelte-parse-markup@0.1.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-persisted-store@0.12.0(svelte@5.50.0): + svelte-persisted-store@0.12.0(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.0) + runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) style-to-object: 1.0.14 - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.0: + svelte@5.50.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -24859,8 +24753,6 @@ snapshots: tinypool@1.1.1: {} - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -25004,12 +24896,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -25329,7 +25221,7 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 @@ -25379,11 +25271,11 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25411,7 +25303,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25427,7 +25319,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25455,7 +25347,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25471,7 +25363,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25499,7 +25391,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25532,12 +25424,6 @@ snapshots: vscode-uri@3.0.8: {} - vt-pbf@3.1.3: - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile': 1.3.1 - pbf: 3.3.0 - w3c-keyname@2.2.8: {} w3c-xmlserializer@4.0.0: diff --git a/server/.nvmrc b/server/.nvmrc index 3fe3b1570a518..32f8c50de0cdb 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/server/package.json b/server/package.json index 80427642e5d32..814934b1be88a 100644 --- a/server/package.json +++ b/server/package.json @@ -135,7 +135,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", @@ -167,7 +167,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" }, "overrides": { "sharp": "^0.34.5" diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 942d44f4c344a..e45eeb23f3f42 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,9 +1,8 @@ import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; @@ -36,7 +35,7 @@ export class DownloadController { 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { + downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index 2f877e3c0bb96..ef52a72bd0d43 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsPositive } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/validation'; +import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' }) @@ -32,3 +33,8 @@ export class DownloadArchiveInfo { @ApiProperty({ description: 'Asset IDs in this archive' }) assetIds!: string[]; } + +export class DownloadArchiveDto extends AssetIdsDto { + @ValidateBoolean({ optional: true, description: 'Download edited asset if available' }) + edited?: boolean; +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 0f3a458c35a89..e8cdd335e2b16 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -587,6 +587,7 @@ where -- AssetRepository.getForOriginal select + "asset"."id", "originalFileName", "asset_file"."path" as "editedPath", "originalPath" @@ -596,7 +597,21 @@ from and "asset_file"."isEdited" = $1 and "asset_file"."type" = $2 where - "asset"."id" = $3 + "asset"."id" in ($3) + +-- AssetRepository.getForOriginals +select + "asset"."id", + "originalFileName", + "asset_file"."path" as "editedPath", + "originalPath" +from + "asset" + left join "asset_file" on "asset"."id" = "asset_file"."assetId" + and "asset_file"."isEdited" = $1 + and "asset_file"."type" = $2 +where + "asset"."id" in ($3) -- AssetRepository.getForThumbnail select diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1a060c4715b7c..d99d8cbab25b9 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1008,12 +1008,12 @@ export class AssetRepository { return count; } - @GenerateSql({ params: [DummyValue.UUID, true] }) - async getForOriginal(id: string, isEdited: boolean) { + private buildGetForOriginal(ids: string[], isEdited: boolean) { return this.db .selectFrom('asset') + .select('asset.id') .select('originalFileName') - .where('asset.id', '=', id) + .where('asset.id', 'in', ids) .$if(isEdited, (qb) => qb .leftJoin('asset_file', (join) => @@ -1024,8 +1024,17 @@ export class AssetRepository { ) .select('asset_file.path as editedPath'), ) - .select('originalPath') - .executeTakeFirstOrThrow(); + .select('originalPath'); + } + + @GenerateSql({ params: [DummyValue.UUID, true] }) + getForOriginal(id: string, isEdited: boolean) { + return this.buildGetForOriginal([id], isEdited).executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [[DummyValue.UUID], true] }) + getForOriginals(ids: string[], isEdited: boolean) { + return this.buildGetForOriginal(ids, isEdited).execute(); } @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] }) diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 9ca37200b77a2..429e60aedef2c 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -554,7 +554,7 @@ describe(DatabaseBackupService.name, () => { "bin": "/usr/lib/postgresql/14/bin/psql", "databaseMajorVersion": 14, "databasePassword": "", - "databaseUsername": "", + "databaseUsername": "postgres", "databaseVersion": "14.10 (Debian 14.10-1.pgdg120+1)", } `); diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index de7090fa83203..3c964c950c767 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -139,7 +139,8 @@ export class DatabaseBackupService { // remove known bad parameters parsedUrl.searchParams.delete('uselibpqcompat'); - databaseUsername = parsedUrl.username; + databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user'); + url = parsedUrl.toString(); } diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index ae010623d8ed5..1ae1b0b4d8b77 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -39,7 +39,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset'])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({ @@ -62,7 +62,7 @@ describe(DownloadService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -86,7 +86,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -108,7 +108,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -130,7 +130,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset2, asset1]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -151,7 +151,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.createZipStream.mockReturnValue(archiveMock); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index a5f734e59cd93..8d939e9635ba8 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,9 +1,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { Permission } from 'src/enum'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; @@ -80,11 +79,11 @@ export class DownloadService extends BaseService { return { totalSize, archives }; } - async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { + async downloadArchive(auth: AuthDto, dto: DownloadArchiveDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); - const assets = await this.assetRepository.getByIds(dto.assetIds); + const assets = await this.assetRepository.getForOriginals(dto.assetIds, dto.edited ?? false); const assetMap = new Map(assets.map((asset) => [asset.id, asset])); const paths: Record = {}; @@ -94,7 +93,7 @@ export class DownloadService extends BaseService { continue; } - const { originalPath, originalFileName } = asset; + const { originalPath, editedPath, originalFileName } = asset; let filename = originalFileName; const count = paths[filename] || 0; @@ -104,9 +103,10 @@ export class DownloadService extends BaseService { filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } - let realpath = originalPath; + let realpath = dto.edited && editedPath ? editedPath : originalPath; + try { - realpath = await this.storageRepository.realpath(originalPath); + realpath = await this.storageRepository.realpath(realpath); } catch { this.logger.warn('Unable to resolve realpath', { originalPath }); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8530f6fed2aac..7a5d5037fe50d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -295,7 +295,7 @@ describe(MetadataService.name, () => { id: asset.id, duration: null, fileCreatedAt: asset.fileCreatedAt, - fileModifiedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, localDateTime: asset.fileCreatedAt, width: null, height: null, @@ -919,7 +919,7 @@ describe(MetadataService.name, () => { Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: 'UTC-11:30', + zone: 'UTC-11:30', TagsList: ['parent/child'], Rating: 3, }; @@ -955,7 +955,7 @@ describe(MetadataService.name, () => { orientation: tags.Orientation?.toString(), profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', - timeZone: tags.tz, + timeZone: tags.zone, rating: tags.Rating, country: null, state: null, @@ -987,7 +987,7 @@ describe(MetadataService.name, () => { const tags: ImmichTags = { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), - tz: undefined, + zone: undefined, }; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4113025914b64..dac79343e0514 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -36,6 +36,10 @@ import { mergeTimeZone } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; +import { Tasks } from 'src/utils/tasks'; + +const POSTGRES_INT_MAX = 2_147_483_647; +const POSTGRES_INT_MIN = -2_147_483_648; /** look for a date from these tags (in order) */ const EXIF_DATE_TAGS: Array = [ @@ -89,7 +93,10 @@ const validate = (value: T): NonNullable | null => { return null; } - if (typeof value === 'number' && (Number.isNaN(value) || !Number.isFinite(value))) { + if ( + typeof value === 'number' && + (Number.isNaN(value) || !Number.isFinite(value) || value < POSTGRES_INT_MIN || value > POSTGRES_INT_MAX) + ) { return null; } @@ -307,33 +314,38 @@ export class MetadataService extends BaseService { const assetWidth = isSidewards ? validate(height) : validate(width); const assetHeight = isSidewards ? validate(width) : validate(height); - const promises: Promise[] = [ - this.assetRepository.update({ - id: asset.id, - duration: this.getDuration(exifTags), - localDateTime: dates.localDateTime, - fileCreatedAt: dates.dateTimeOriginal ?? undefined, - fileModifiedAt: stats.mtime, - - // only update the dimensions if they don't already exist - // we don't want to overwrite width/height that are modified by edits - width: asset.width == null ? assetWidth : undefined, - height: asset.height == null ? assetHeight : undefined, - }), - ]; - - await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); - await this.applyTagList(asset); + const tasks = new Tasks(); + + tasks.push( + () => + this.assetRepository.update({ + id: asset.id, + duration: this.getDuration(exifTags), + localDateTime: dates.localDateTime, + fileCreatedAt: dates.dateTimeOriginal ?? undefined, + fileModifiedAt: stats.mtime, + + // only update the dimensions if they don't already exist + // we don't want to overwrite width/height that are modified by edits + width: asset.width == null ? assetWidth : undefined, + height: asset.height == null ? assetHeight : undefined, + }), + async () => { + await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); + await this.applyTagList(asset); + }, + ); if (this.isMotionPhoto(asset, exifTags)) { - promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); + tasks.push(() => this.applyMotionPhotos(asset, exifTags, dates, stats)); } if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { - promises.push(this.applyTaggedFaces(asset, exifTags)); + tasks.push(() => this.applyTaggedFaces(asset, exifTags)); } - await Promise.all(promises); + await tasks.all(); + if (exifData.livePhotoCID) { await this.linkLivePhotos(asset, exifData); } @@ -527,6 +539,15 @@ export class MetadataService extends BaseService { for (const tag of EXIF_DATE_TAGS) { delete mediaTags[tag]; } + + // exiftool-vendored derives tz information from the date. + // if the sidecar file has date information, we also assume the tz information come from there. + // + // this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields + // and as such the tags aren't overwritten when returning all tags. + for (const tag of ['zone', 'tz', 'tzSource'] as const) { + delete mediaTags[tag]; + } } } @@ -897,8 +918,8 @@ export class MetadataService extends BaseService { } // timezone - let timeZone = exifTags.tz ?? null; - if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + let timeZone = exifTags.zone ?? null; + if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // https://github.com/photostructure/exiftool-vendored.js/issues/203 timeZone = 'UTC+0'; @@ -906,7 +927,7 @@ export class MetadataService extends BaseService { if (timeZone) { this.logger.verbose( - `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + `Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`, ); } else { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); diff --git a/server/src/utils/tasks.ts b/server/src/utils/tasks.ts new file mode 100644 index 0000000000000..4a8276fc46a1c --- /dev/null +++ b/server/src/utils/tasks.ts @@ -0,0 +1,13 @@ +export type Task = () => Promise | unknown; + +export class Tasks { + private tasks: Task[] = []; + + push(...tasks: Task[]) { + this.tasks.push(...tasks); + } + + async all() { + await Promise.all(this.tasks.map((item) => item())); + } +} diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 29e7ea703962c..db1b944e1f9df 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -398,6 +398,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with time zone UTC+0', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('updateAll', () => { @@ -456,7 +473,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets with timezone', async () => { + it('should relatively update assets with timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -477,7 +494,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets and set a timezone', async () => { + it('should relatively update assets and set a timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -497,6 +514,26 @@ describe(AssetService.name, () => { ); }); + it('should set asset time zones to UTC', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:11:00+00:00', + timeZone: 'UTC', + }), + }), + ); + }); + it('should update dateTimeOriginal', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -530,6 +567,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with UTC time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('upsertBulkMetadata', () => { diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 55dcf6456fd2b..ea7162c77ae7a 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -51,6 +51,7 @@ export const newAssetRepositoryMock = (): Mocked = (node, option const { destroy } = shortcuts(node, [ { shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) }, { shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) }, - { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) }, + { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event), preventDefault: false }, { shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) }, { shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) }, ]); diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index f008df4cb8b7e..0b19306d6eb48 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -1,5 +1,5 @@