Compare commits

..

26 Commits

Author SHA1 Message Date
danieldietzler
28b8a45448 chore: release v2.4.0 2025-12-11 00:14:45 +00:00
renovate[bot]
baad38f0e6 fix(deps): update typescript-projects (#24476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-11 00:13:06 +00:00
Min Idzelis
161147af51 feat: timeline-manager improvement to use AssetResponseDto efficiently (#24421) 2025-12-11 01:07:31 +01:00
renovate[bot]
cbdf5011f9 chore(deps): update docker.io/valkey/valkey:9 docker digest to fb8d272 (#24474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:39 +01:00
renovate[bot]
f0f1d279c4 chore(deps): update prom/prometheus docker digest to d936808 (#24475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:20 +01:00
renovate[bot]
5821f2fe61 chore(deps): update github-actions (#24477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:03 +01:00
Noel S
4cbce072be fix(docs): slow upload speed with example nginx reverse proxy config (#24490)
* increase buffer size

* increase further

* increase buffer further
2025-12-10 22:29:36 +00:00
idubnori
5e5bb7e87d fix(mobile): versionStatus.message text overflow (#24504) 2025-12-10 16:18:55 -06:00
shenlong
b052893a1e feat(mobile): immich-ui icon button (#24502)
* feat(mobile): immich-ui icon button

* fix lint

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-10 16:18:01 -06:00
Kurt McKee
15e58595fd fix(mobile): iOS local permission dialog extra whitespace (#24491)
Fix a iOS rendering issue caused by extra whitespace
2025-12-10 16:17:08 -06:00
Alex
6d499c782a chore: update ui lib (#24483) 2025-12-09 17:27:01 -06:00
idubnori
7af99b8606 feat(mobile): move top bar buttons into kebabu menu in AssetViewer (#24461)
* chore(mobile):  i18n: "open_asset_info" in viewer kebab menu

* feat(mobile): move some top buttons into kebabu menu

* refactor(mobile): viewer kebab menu to use context-based button generation

* feat(mobile): refactor action button and kebab menu to use ConsumerWidget for improved state management

* feat(mobile): pass original theme to ViewerKebabMenu for consistent styling

* chore: styling

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-09 18:26:28 +00:00
Arnau Mora
01e39277e0 feat(mobile): Localized backup upload details page (#21136)
* Localized backup details page

# Conflicts:
#	i18n/en.json

* Format

* format fix

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-12-09 11:23:01 -06:00
Yaros
06e79703da fix(mobile): timeline bottom padding on selection (#24480) 2025-12-09 09:19:41 -06:00
Yaros
c360781565 fix(mobile): fix overflow text in backup card (#24448)
* fix(mobile): fix overflow text in backup card

* refactor: use intrinsicheight

* chore: fix spelling of entitycounttile
2025-12-09 09:03:29 -06:00
idubnori
287f6d5c94 fix(mobile): buttons inside AddActionButton color is the same as background color (#24460)
* fix: icon & text color in AddActionButton

* fix: use Divider
2025-12-08 14:29:31 -06:00
Simon Kubiak
fe9125a3d1 fix(web): [album table view] long album title overflows table row (#24450)
fix(web): long album title overflows vertically on album page in table view
2025-12-08 15:35:58 +00:00
Yaros
8b31936bb6 fix(mobile): cannot create album while name field is focused (#24449)
fix(mobile): create album disabled when focused
2025-12-08 09:33:01 -06:00
Sergey Katsubo
19958dfd83 fix(server): building docker image for different platforms on the same host (#24459)
Fix building docker image for different platforms on the same host

Use per-platform mise cache to avoid 'sh: 1: extism-js: not found'
This happens due to re-using cached installed binary for another platform
2025-12-08 09:15:43 -06:00
Alex
1e1cf0d1fe fix: build iOS fastlane installation (#24408) 2025-12-06 14:55:53 -06:00
Min Idzelis
879e0ea131 fix: thumbnail doesnt send mouseLeave events properly (#24423) 2025-12-06 21:52:06 +01:00
Sergey Katsubo
42136f9091 fix(server): update exiftool-vendored to v34 for more robust metadata extraction (#24424) 2025-12-06 14:45:59 -06:00
Harrison
1109c32891 fix(docs): websockets in nginx example (#24411)
Co-authored-by: Harrison <frith.harry@gmail.com>
2025-12-06 16:28:12 +00:00
idubnori
3c80049192 chore(mobile): add kebabu menu in asset viewer (#24387)
* feat(mobile): implement viewer kebab menu with about option

* feat: revert exisitng buttons, adjust label name

* unify MenuAnchor usage

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-05 19:51:59 +00:00
Hai Sullivan
8f1669efbe chore(mobile): smoother UI experience for iOS devices (#24397)
allows the tab pages to use the standard Material page transition during push/pop navigation
2025-12-05 11:02:04 -06:00
Robert Schäfer
146bf65d02 refactor(dev): remove ulimits for rootless docker (#24393)
Description
-----------

When I follow the [developer setup](https://docs.immich.app/developer/setup) I run into a permission error using rootless docker. A while ago I asked on Discord in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327/1442974448776122592) about these ulimits.

I suggest to remove the `ulimits` altogether. It seems that @ItalyPaleAle has left the setting just hoping that it could help somebody in the future. See the [PR description](https://github.com/immich-app/immich/pull/4556).

How Has This Been Tested?
-------------------------

Using rootless docker:

```
$ docker context ls
NAME         DESCRIPTION                               DOCKER ENDPOINT                     ERROR
default                                                unix:///var/run/docker.sock
rootless *                                             unix:///run/user/1000/docker.sock
```

Running `make` will fail because of permission errors:
```
$  docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
...
Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error setting rlimits for ready process: error setting rlimit type 7: operation not permitted
```

On my machine I have the following hard limit for "Maximum number of open file descriptors":
```
$ ulimit -nH
524288
```

I can confirm that the permission error is caused by the security restrictions of the operating system mentioned above:

Changing `docker/docker-compose.dev.yml` like ..

```
    ulimits:
      nofile:
        soft: 524289
        hard: 524289
```

.. will lead to a permission error whereas this ..

```
    ulimits:
      nofile:
        soft: 524288
        hard: 524288
```

.. starts fine.

Apparently the defaults for these limits are coming from [systemd](26b2085d54/man/systemd.exec.xml (L1122)) which is used on nearly every linux distribution. So my assumption is that almost any linux user who uses rootless docker will run into a permission error when starting the development setup.

Checklist:
----------

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation if applicable
- [x] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services/` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services/`)
2025-12-05 09:26:20 -05:00
116 changed files with 2003 additions and 5061 deletions

View File

@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
- uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with:
distribution: 'zulu'
java-version: '17'
@@ -222,6 +222,7 @@ jobs:
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: ./mobile/ios
- name: Install CocoaPods dependencies
@@ -229,13 +230,6 @@ jobs:
run: |
pod install
- name: Install Fastlane
working-directory: ./mobile/ios
run: |
gem install bundler
bundle config set --local path 'vendor/bundle'
bundle install
- name: Create API Key
env:
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}

View File

@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

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

View File

@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

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

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,7 +68,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

230
CHANGELOG.md Normal file
View File

@@ -0,0 +1,230 @@
# v2.4.0
# v2.4.0
## Highlights
Welcome to the release `v2.4.0` of Immich. This release focuses on bug fixes, QoL improvements, and polished UI components across mobile and the web. Let's dive right in.
* Show the owner's name in the shared album
* Command palette
* Change search type directly in the search bar
* Job details
* Simplify the top control bar in the mobile app
* Notable fix: fix an issue where metadata extraction could fail on high concurrency
### Show the owner's name in the shared album.
On the web, in shared albums, you can now toggle an option to display the asset's owner name at the bottom right corner of the thumbnail.
![](/api/attachments.redirect?id=3e4d661e-4015-4b04-b8f3-a055e9d4db01 " =1071x758")
### Command palette
The web app now has an integrated command palette, which can be opened `ctrl + k` on Windows/Linux or `cmd + k` on macOS. This first iteration of command pallets lets you quickly navigate between administration pages by typing the name of the page you want to go to.
![](/api/attachments.redirect?id=e431bca6-78fb-4873-879c-2a61af4a6df0 " =1354x1032")
### Change search type directly in the search bar
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in <https://github.com/immich-app/immich/pull/24142>
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in <https://github.com/immich-app/immich/pull/24032>
* feat: command palette by @danieldietzler in <https://github.com/immich-app/immich/pull/23693>
* feat(web): Shared album owner labels by @xCJPECKOVERx in <https://github.com/immich-app/immich/pull/21171>
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in <https://github.com/immich-app/immich/pull/22133>
* feat: queue detail page by @jrasm91 in <https://github.com/immich-app/immich/pull/24352>
* chore(mobile): add kebabu menu in asset viewer by @idubnori in <https://github.com/immich-app/immich/pull/24387>
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in <https://github.com/immich-app/immich/pull/24080>
* feat: separate camera and lens info in detail panel by @fabianbees in <https://github.com/immich-app/immich/pull/23670>
* feat(web): shared link card tweaks by @jrasm91 in <https://github.com/immich-app/immich/pull/24192>
* feat(server): exclude syncthing folders from external libraries by @SaphuA in <https://github.com/immich-app/immich/pull/24240>
* feat(web): search type selection dropdown by @YarosMallorca in <https://github.com/immich-app/immich/pull/24091>
* feat: header context menu by @jrasm91 in <https://github.com/immich-app/immich/pull/24374>
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in <https://github.com/immich-app/immich/pull/24461>
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in <https://github.com/immich-app/immich/pull/24014>
* fix: do not clear hash on updated_at change by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24039>
* fix: disable animation "add to" action menu by @bwees in <https://github.com/immich-app/immich/pull/24040>
* fix: Use correct app store link by @Mraedis in <https://github.com/immich-app/immich/pull/24062>
* fix: show archived assets in favorite page by @bwees in <https://github.com/immich-app/immich/pull/24052>
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in <https://github.com/immich-app/immich/pull/23906>
* feat(web): show detected faces in spherical photos by @meesfrensel in <https://github.com/immich-app/immich/pull/23974>
* fix: add users to album by @danieldietzler in <https://github.com/immich-app/immich/pull/24133>
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in <https://github.com/immich-app/immich/pull/23333>
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24137>
* fix: albums page reactivity loops by @danieldietzler in <https://github.com/immich-app/immich/pull/24046>
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24131>
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in <https://github.com/immich-app/immich/pull/24018>
* fix: don't get OCR data in shared link by @alextran1502 in <https://github.com/immich-app/immich/pull/24152>
* fix: duration extraction by @jrasm91 in <https://github.com/immich-app/immich/pull/24178>
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in <https://github.com/immich-app/immich/pull/24045>
* fix: update timeline-manager after archive actions by @midzelis in <https://github.com/immich-app/immich/pull/24010>
* fix: theme switcher by @jrasm91 in <https://github.com/immich-app/immich/pull/24209>
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in <https://github.com/immich-app/immich/pull/24232>
* fix(mobile): enable backup text overflows by @YarosMallorca in <https://github.com/immich-app/immich/pull/24227>
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in <https://github.com/immich-app/immich/pull/24189>
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in <https://github.com/immich-app/immich/pull/24249>
* fix: only generate memory based on users assets by @alextran1502 in <https://github.com/immich-app/immich/pull/24151>
* fix(mobile): docs link by @mmomjian in <https://github.com/immich-app/immich/pull/24277>
* fix(server): use bigrams for cjk by @mertalev in <https://github.com/immich-app/immich/pull/24285>
* fix(ml): do not upscale preview by @mertalev in <https://github.com/immich-app/immich/pull/24322>
* fix(web): open onboarding documentation link in new tab by @carbonemys in <https://github.com/immich-app/immich/pull/24289>
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in <https://github.com/immich-app/immich/pull/24310>
* fix(web): folder view sort oder by @etnoy in <https://github.com/immich-app/immich/pull/24337>
* fix(server): do not delete offline assets by @mertalev in <https://github.com/immich-app/immich/pull/24355>
* fix: exposure info and better readability by @alextran1502 in <https://github.com/immich-app/immich/pull/24344>
* fix: Adjust the zoom level by @jforseth210 in <https://github.com/immich-app/immich/pull/24353>
* fix: local full sync on Android on resume by @alextran1502 in <https://github.com/immich-app/immich/pull/24348>
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in <https://github.com/immich-app/immich/pull/24372>
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in <https://github.com/immich-app/immich/pull/24047>
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in <https://github.com/immich-app/immich/pull/24424>
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in <https://github.com/immich-app/immich/pull/24449>
* fix(web): \[album table view\] long album title overflows table row by @simonkub in <https://github.com/immich-app/immich/pull/24450>
* fix(mobile): fix overflow text in backup card by @YarosMallorca in <https://github.com/immich-app/immich/pull/24448>
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in <https://github.com/immich-app/immich/pull/24480>
* feat(mobile): Localized backup upload details page by @ArnyminerZ in <https://github.com/immich-app/immich/pull/21136>
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in <https://github.com/immich-app/immich/pull/24215>
* fix(docs): build `cli` for e2e tests by @roschaefer in <https://github.com/immich-app/immich/pull/24184>
* docs(faq): add more info on archiving by @etnoy in <https://github.com/immich-app/immich/pull/24326>
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in <https://github.com/immich-app/immich/pull/24335>
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in <https://github.com/immich-app/immich/pull/24351>
* fix(docs): obsolete docs about rootless docker by @roschaefer in <https://github.com/immich-app/immich/pull/24376>
* fix(docs): websockets in nginx example by @fourthwall in <https://github.com/immich-app/immich/pull/24411>
### 🌐 Translations
* chore: add new language requests by @danieldietzler in <https://github.com/immich-app/immich/pull/23991>
## New Contributors
* @ujjwal123123 made their first contribution in <https://github.com/immich-app/immich/pull/24101>
* @lutostag made their first contribution in <https://github.com/immich-app/immich/pull/23333>
* @LukaPrebil made their first contribution in <https://github.com/immich-app/immich/pull/24045>
* @kimsey0 made their first contribution in <https://github.com/immich-app/immich/pull/24232>
* @SaphuA made their first contribution in <https://github.com/immich-app/immich/pull/24240>
* @dionysius made their first contribution in <https://github.com/immich-app/immich/pull/24215>
* @NiklasvonM made their first contribution in <https://github.com/immich-app/immich/pull/24249>
* @kao-byte made their first contribution in <https://github.com/immich-app/immich/pull/24098>
* @carbonemys made their first contribution in <https://github.com/immich-app/immich/pull/24289>
* @kiloomar made their first contribution in <https://github.com/immich-app/immich/pull/24372>
* @fourthwall made their first contribution in <https://github.com/immich-app/immich/pull/24411>
* @simonkub made their first contribution in <https://github.com/immich-app/immich/pull/24450>
* @ArnyminerZ made their first contribution in <https://github.com/immich-app/immich/pull/21136>
**Full Changelog**: <https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0>
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🫥 Deprecated Changes
* feat: queues by @jrasm91 in https://github.com/immich-app/immich/pull/24142
### 🚀 Features
* feat: improve performance: don't sort timeline buckets from server by @midzelis in https://github.com/immich-app/immich/pull/24032
* feat: command palette by @danieldietzler in https://github.com/immich-app/immich/pull/23693
* feat(web): Shared album owner labels by @xCJPECKOVERx in https://github.com/immich-app/immich/pull/21171
* feat(mobile): persist album sorting & layout in settings by @YarosMallorca in https://github.com/immich-app/immich/pull/22133
* feat: queue detail page by @jrasm91 in https://github.com/immich-app/immich/pull/24352
* chore(mobile): add kebabu menu in asset viewer by @idubnori in https://github.com/immich-app/immich/pull/24387
### 🌟 Enhancements
* feat(web): allow navigating the map with arrow keys by @lukashass in https://github.com/immich-app/immich/pull/24080
* feat: separate camera and lens info in detail panel by @fabianbees in https://github.com/immich-app/immich/pull/23670
* feat(web): shared link card tweaks by @jrasm91 in https://github.com/immich-app/immich/pull/24192
* feat(server): exclude syncthing folders from external libraries by @SaphuA in https://github.com/immich-app/immich/pull/24240
* feat(web): search type selection dropdown by @YarosMallorca in https://github.com/immich-app/immich/pull/24091
* feat: header context menu by @jrasm91 in https://github.com/immich-app/immich/pull/24374
* feat(mobile): move top bar buttons into kebabu menu in AssetViewer by @idubnori in https://github.com/immich-app/immich/pull/24461
### 🐛 Bug fixes
* fix: effect loop by @jrasm91 in https://github.com/immich-app/immich/pull/24014
* fix: do not clear hash on updated_at change by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24039
* fix: disable animation "add to" action menu by @bwees in https://github.com/immich-app/immich/pull/24040
* fix: Use correct app store link by @Mraedis in https://github.com/immich-app/immich/pull/24062
* fix: show archived assets in favorite page by @bwees in https://github.com/immich-app/immich/pull/24052
* fix(mobile): first video memory on page doesn't play by @YarosMallorca in https://github.com/immich-app/immich/pull/23906
* feat(web): show detected faces in spherical photos by @meesfrensel in https://github.com/immich-app/immich/pull/23974
* fix: add users to album by @danieldietzler in https://github.com/immich-app/immich/pull/24133
* fix(server): sanitize DB_URL for pg_dumpall to remove unknown query params by @lutostag in https://github.com/immich-app/immich/pull/23333
* fix: use proper updatedAt value in local assets by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24137
* fix: albums page reactivity loops by @danieldietzler in https://github.com/immich-app/immich/pull/24046
* fix: getAspectRatio fallback to db width and height by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24131
* fix(web): fix support & feedback modal wrapping by @Snowknight26 in https://github.com/immich-app/immich/pull/24018
* fix: don't get OCR data in shared link by @alextran1502 in https://github.com/immich-app/immich/pull/24152
* fix: duration extraction by @jrasm91 in https://github.com/immich-app/immich/pull/24178
* fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures by @LukaPrebil in https://github.com/immich-app/immich/pull/24045
* fix: update timeline-manager after archive actions by @midzelis in https://github.com/immich-app/immich/pull/24010
* fix: theme switcher by @jrasm91 in https://github.com/immich-app/immich/pull/24209
* fix: label 'for' attributes in user-api-key-grid by @kimsey0 in https://github.com/immich-app/immich/pull/24232
* fix(mobile): enable backup text overflows by @YarosMallorca in https://github.com/immich-app/immich/pull/24227
* fix(web): integrate zoom toggle button into panorama photo viewer by @meesfrensel in https://github.com/immich-app/immich/pull/24189
* fix(web): use full tag path when creating nested subtags by @NiklasvonM in https://github.com/immich-app/immich/pull/24249
* fix: only generate memory based on users assets by @alextran1502 in https://github.com/immich-app/immich/pull/24151
* fix(mobile): docs link by @mmomjian in https://github.com/immich-app/immich/pull/24277
* fix(server): use bigrams for cjk by @mertalev in https://github.com/immich-app/immich/pull/24285
* fix(ml): do not upscale preview by @mertalev in https://github.com/immich-app/immich/pull/24322
* fix(web): open onboarding documentation link in new tab by @carbonemys in https://github.com/immich-app/immich/pull/24289
* fix(mobile): use correct timezone displayed in the info sheet by @kao-byte in https://github.com/immich-app/immich/pull/24310
* fix(web): folder view sort oder by @etnoy in https://github.com/immich-app/immich/pull/24337
* fix(server): do not delete offline assets by @mertalev in https://github.com/immich-app/immich/pull/24355
* fix: exposure info and better readability by @alextran1502 in https://github.com/immich-app/immich/pull/24344
* fix: Adjust the zoom level by @jforseth210 in https://github.com/immich-app/immich/pull/24353
* fix: local full sync on Android on resume by @alextran1502 in https://github.com/immich-app/immich/pull/24348
* fix(web): Add minimum content size to logo for consistent visual on small screens by @kiloomar in https://github.com/immich-app/immich/pull/24372
* fix: use adjustment time in iOS for hash reset by @shenlong-tanwen in https://github.com/immich-app/immich/pull/24047
* fix(server): update exiftool-vendored to v34 for more robust metadata extraction by @skatsubo in https://github.com/immich-app/immich/pull/24424
* fix(mobile): cannot create album while name field is focused by @YarosMallorca in https://github.com/immich-app/immich/pull/24449
* fix(web): [album table view] long album title overflows table row by @simonkub in https://github.com/immich-app/immich/pull/24450
* fix(mobile): fix overflow text in backup card by @YarosMallorca in https://github.com/immich-app/immich/pull/24448
* fix(mobile): timeline bottom padding on selection by @YarosMallorca in https://github.com/immich-app/immich/pull/24480
* feat(mobile): Localized backup upload details page by @ArnyminerZ in https://github.com/immich-app/immich/pull/21136
* fix(mobile): iOS local permission dialog extra whitespace by @kurtmckee in https://github.com/immich-app/immich/pull/24491
* fix(mobile): versionStatus.message text overflow by @idubnori in https://github.com/immich-app/immich/pull/24504
### 📚 Documentation
* docs: DB_STORAGE_TYPE is only used by the database container by @dionysius in https://github.com/immich-app/immich/pull/24215
* fix(docs): build `cli` for e2e tests by @roschaefer in https://github.com/immich-app/immich/pull/24184
* docs(faq): add more info on archiving by @etnoy in https://github.com/immich-app/immich/pull/24326
* fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT by @dionysius in https://github.com/immich-app/immich/pull/24335
* fix: prevent OOM on nginx reverse proxy servers by @NicholasFlamy in https://github.com/immich-app/immich/pull/24351
* fix(docs): obsolete docs about rootless docker by @roschaefer in https://github.com/immich-app/immich/pull/24376
* fix(docs): websockets in nginx example by @fourthwall in https://github.com/immich-app/immich/pull/24411
* fix(docs): slow upload speed with example nginx reverse proxy config by @goalie2002 in https://github.com/immich-app/immich/pull/24490
### 🌐 Translations
* chore: add new language requests by @danieldietzler in https://github.com/immich-app/immich/pull/23991
## New Contributors
* @ujjwal123123 made their first contribution in https://github.com/immich-app/immich/pull/24101
* @lutostag made their first contribution in https://github.com/immich-app/immich/pull/23333
* @LukaPrebil made their first contribution in https://github.com/immich-app/immich/pull/24045
* @kimsey0 made their first contribution in https://github.com/immich-app/immich/pull/24232
* @SaphuA made their first contribution in https://github.com/immich-app/immich/pull/24240
* @dionysius made their first contribution in https://github.com/immich-app/immich/pull/24215
* @NiklasvonM made their first contribution in https://github.com/immich-app/immich/pull/24249
* @kao-byte made their first contribution in https://github.com/immich-app/immich/pull/24098
* @carbonemys made their first contribution in https://github.com/immich-app/immich/pull/24289
* @kiloomar made their first contribution in https://github.com/immich-app/immich/pull/24372
* @fourthwall made their first contribution in https://github.com/immich-app/immich/pull/24411
* @simonkub made their first contribution in https://github.com/immich-app/immich/pull/24450
* @ArnyminerZ made their first contribution in https://github.com/immich-app/immich/pull/21136
* @kurtmckee made their first contribution in https://github.com/immich-app/immich/pull/24491
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.3.1...v2.4.0
---

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.103",
"version": "2.2.104",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -58,10 +58,6 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
ulimits:
nofile:
soft: 1048576
hard: 1048576
ports:
- 9230:9230
- 9231:9231
@@ -100,10 +96,6 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
ulimits:
nofile:
soft: 1048576
hard: 1048576
restart: unless-stopped
depends_on:
immich-server:
@@ -135,7 +127,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -24,6 +24,9 @@ server {
# disable buffering uploads to prevent OOM on reverse proxy server and make uploads twice as fast (no pause)
proxy_request_buffering off;
# increase body buffer to avoid limiting upload speed
client_body_buffer_size 1024k;
# Set headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -32,8 +35,6 @@ server {
# enable websockets: http://nginx.org/en/docs/http/websocket.html
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
# set timeout
@@ -43,6 +44,8 @@ server {
location / {
proxy_pass http://<backend_url>:2283;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# useful when using Let's Encrypt http-01 challenge

View File

@@ -1,4 +1,8 @@
[
{
"label": "v2.4.0",
"url": "https://docs.v2.4.0.archive.immich.app"
},
{
"label": "v2.3.1",
"url": "https://docs.v2.3.1.archive.immich.app"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.3.1",
"version": "2.4.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -36,7 +36,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0",
"exiftool-vendored": "^34.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",

View File

@@ -5,7 +5,6 @@
"acknowledge": "Acknowledge",
"action": "Action",
"action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
@@ -16,13 +15,9 @@
"add_a_location": "Add a location",
"add_a_name": "Add a name",
"add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -41,7 +36,6 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -473,7 +467,6 @@
"album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary",
"album_updated": "Album updated",
@@ -495,7 +488,6 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
@@ -532,12 +524,10 @@
"archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -662,6 +652,7 @@
"backup_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings",
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
@@ -720,8 +711,6 @@
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging",
@@ -730,6 +719,7 @@
"check_corrupt_asset_backup_button": "Perform check",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
"check_logs": "Check Logs",
"checksum": "Checksum",
"choose_matching_people_to_merge": "Choose matching people to merge",
"city": "City",
"clear": "Clear",
@@ -797,7 +787,6 @@
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@@ -812,7 +801,6 @@
"create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created",
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
@@ -879,7 +867,6 @@
"deselect_all": "Deselect All",
"details": "Details",
"direction": "Direction",
"disable": "Disable",
"disabled": "Disabled",
"disallow_edits": "Disallow edits",
"discord": "Discord",
@@ -942,13 +929,11 @@
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_mode": "Editor mode",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -1029,7 +1014,6 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library",
@@ -1040,7 +1024,6 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash",
@@ -1091,7 +1074,6 @@
"unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
@@ -1144,10 +1126,8 @@
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
@@ -1163,7 +1143,6 @@
"general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
@@ -1189,13 +1168,13 @@
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"headers_settings_tile_title": "Custom proxy headers",
"height": "Height",
"hi_user": "Hi {name} ({email})",
"hide_all_people": "Hide all people",
"hide_gallery": "Hide gallery",
"hide_named_person": "Hide person {name}",
"hide_password": "Hide password",
"hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1268,8 +1247,6 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_all": "Keep All",
"keep_this_delete_others": "Keep this, delete others",
@@ -1314,6 +1291,7 @@
"local": "Local",
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
"local_assets": "Local Assets",
"local_id": "Local ID",
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
@@ -1438,13 +1416,11 @@
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash",
@@ -1454,7 +1430,6 @@
"my_albums": "My albums",
"name": "Name",
"name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1479,7 +1454,6 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1489,13 +1463,11 @@
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1591,7 +1563,6 @@
"people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1616,8 +1587,6 @@
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos",
"photos_and_videos": "Photos & Videos",
@@ -1867,22 +1836,17 @@
"second": "Second",
"see_all_people": "See all people",
"select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all",
"select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_face": "Select face",
"select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer",
"select_keep_all": "Select keep all",
"select_library_owner": "Select library owner",
"select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos",
"select_trash_all": "Select trash all",
@@ -2018,7 +1982,6 @@
"show_password": "Show password",
"show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options",
"show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition",
@@ -2146,13 +2109,6 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kick off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot",
"type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code",
@@ -2183,9 +2139,7 @@
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2231,7 +2185,6 @@
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
@@ -2263,28 +2216,15 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters",
"workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.3.1"
version = "2.4.0"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3028,
"android.injected.version.name" => "2.3.1",
"android.injected.version.code" => 3029,
"android.injected.version.name" => "2.4.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -137,8 +137,7 @@
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>

View File

@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
),
),
Text(
'Tap for more details',
"backup_upload_details_page_more_details".t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
@@ -239,14 +239,20 @@ class FileDetailDialog extends ConsumerWidget {
const SizedBox(height: 24),
if (asset != null) ...[
_buildInfoSection(context, [
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
_buildInfoRow(context, "Local ID", asset.id),
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
_buildInfoRow(context, "Created At", asset.createdAt.toString()),
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
_buildInfoRow(context, "local_id".t(context: context), asset.id),
_buildInfoRow(
context,
"file_size".t(context: context),
formatHumanReadableBytes(uploadStatus.fileSize, 2),
),
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
if (asset.height != null)
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
if (asset.checksum != null)
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
]),
],
],

View File

@@ -1,173 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:drift/drift.dart' hide Column;
import 'package:easy_localization/easy_localization.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/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
final _features = [
_Feature(
name: 'Main Timeline',
icon: Icons.timeline_rounded,
onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()),
),
_Feature(
name: 'Selection Mode Timeline',
icon: Icons.developer_mode_rounded,
onTap: (ctx, ref) async {
final user = ref.watch(currentUserProvider);
if (user == null) {
return Future.value();
}
final assets = await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
DriftAssetSelectionTimelineRoute(lockedSelectionAssets: assets.toSet()),
);
Logger("FeaturesInDevelopment").fine("Selected ${selectedAssets?.length ?? 0} assets");
return Future.value();
},
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Sync Local',
icon: Icons.photo_album_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(),
),
_Feature(
name: 'Sync Local Full (1)',
icon: Icons.photo_library_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncLocal(full: true),
),
_Feature(
name: 'Hash Local Assets (2)',
icon: Icons.numbers_outlined,
onTap: (_, ref) => ref.read(backgroundSyncProvider).hashAssets(),
),
_Feature(
name: 'Sync Remote (3)',
icon: Icons.refresh_rounded,
onTap: (_, ref) => ref.read(backgroundSyncProvider).syncRemote(),
),
_Feature(
name: 'WAL Checkpoint',
icon: Icons.save_rounded,
onTap: (_, ref) => ref.read(driftProvider).customStatement("pragma wal_checkpoint(truncate)"),
),
_Feature(name: '', icon: Icons.vertical_align_center_sharp, onTap: (_, __) => Future.value()),
_Feature(
name: 'Clear Delta Checkpoint',
icon: Icons.delete_rounded,
onTap: (_, ref) => ref.read(nativeSyncApiProvider).clearSyncCheckpoint(),
),
_Feature(
name: 'Clear Local Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_forever_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.localAssetEntity.deleteAll();
await db.localAlbumEntity.deleteAll();
await db.localAlbumAssetEntity.deleteAll();
},
),
_Feature(
name: 'Clear Remote Data',
style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
icon: Icons.delete_sweep_rounded,
onTap: (_, ref) async {
final db = ref.read(driftProvider);
await db.remoteAssetEntity.deleteAll();
await db.remoteExifEntity.deleteAll();
await db.remoteAlbumEntity.deleteAll();
await db.remoteAlbumUserEntity.deleteAll();
await db.remoteAlbumAssetEntity.deleteAll();
await db.memoryEntity.deleteAll();
await db.memoryAssetEntity.deleteAll();
await db.stackEntity.deleteAll();
await db.personEntity.deleteAll();
await db.assetFaceEntity.deleteAll();
},
),
_Feature(
name: 'Local Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.table_chart_rounded,
onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()),
),
_Feature(
name: 'Remote Media Summary',
style: const TextStyle(color: Colors.indigo, fontWeight: FontWeight.bold),
icon: Icons.summarize_rounded,
onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()),
),
_Feature(
name: 'Reset Sqlite',
icon: Icons.table_view_rounded,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
onTap: (_, ref) async {
final drift = ref.read(driftProvider);
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = drift.createMigrator();
for (final entity in drift.allSchemaEntities) {
await migrator.drop(entity);
await migrator.create(entity);
}
},
),
];
@RoutePage()
class FeatInDevPage extends StatelessWidget {
const FeatInDevPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('features_in_development'.tr()), centerTitle: true),
body: Column(
children: [
Flexible(
flex: 1,
child: ListView.builder(
itemBuilder: (_, index) {
final feat = _features[index];
return Consumer(
builder: (ctx, ref, _) => ListTile(
title: Text(feat.name, style: feat.style),
trailing: Icon(feat.icon),
visualDensity: VisualDensity.compact,
onTap: () => unawaited(feat.onTap(ctx, ref)),
),
);
},
itemCount: _features.length,
),
),
const Divider(height: 0),
],
),
);
}
}
class _Feature {
const _Feature({required this.name, required this.icon, required this.onTap, this.style});
final String name;
final IconData icon;
final TextStyle? style;
final Future<void> Function(BuildContext, WidgetRef _) onTap;
}

View File

@@ -0,0 +1,51 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_ui/immich_ui.dart';
List<Widget> _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) {
final children = <Widget>[];
final items = [
(variant: ImmichVariant.filled, title: "Filled Variant"),
(variant: ImmichVariant.ghost, title: "Ghost Variant"),
];
for (final (:variant, :title) in items) {
children.add(Text(title));
children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)]));
}
return children;
}
@RoutePage()
class ImmichUIShowcasePage extends StatelessWidget {
const ImmichUIShowcasePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Immich UI Showcase')),
body: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
spacing: 10,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("IconButton", style: context.textTheme.titleLarge),
..._showcaseBuilder(
(variant, color) =>
ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}),
),
Text("CloseButton", style: context.textTheme.titleLarge),
..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})),
],
),
),
),
);
}
}

View File

@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
child: Scaffold(
appBar: AppBar(
title: Text(album.name),
actions: [const LikeActivityActionButton(menuItem: true)],
actions: [const LikeActivityActionButton(iconOnly: true)],
actionsPadding: const EdgeInsets.only(right: 8),
),
body: activities.widgetWhen(

View File

@@ -27,8 +27,19 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
bool isAlbumTitleTextFieldFocus = false;
Set<BaseAsset> selectedAssets = {};
@override
void initState() {
super.initState();
albumTitleController.addListener(_onTitleChanged);
}
void _onTitleChanged() {
setState(() {});
}
@override
void dispose() {
albumTitleController.removeListener(_onTitleChanged);
albumTitleController.dispose();
albumDescriptionController.dispose();
albumTitleTextFieldFocusNode.dispose();

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
@@ -30,11 +31,13 @@ class DriftCropImagePage extends HookWidget {
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
leading: const ImmichCloseButton(),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onTap: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
@@ -72,17 +75,17 @@ class DriftCropImagePage extends HookWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateLeft(),
),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onTap: () => cropController.rotateRight(),
),
],
),

View File

@@ -21,12 +21,36 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
class AddActionButton extends ConsumerWidget {
const AddActionButton({super.key});
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
final ThemeData? originalTheme;
@override
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
}
class _AddActionButtonState extends ConsumerState<AddActionButton> {
void _handleMenuSelection(AddToMenuItem selected) {
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
List<Widget> _buildMenuChildren() {
final asset = ref.read(currentAssetNotifier);
if (asset == null) return;
if (asset == null) return [];
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
@@ -35,93 +59,57 @@ class AddActionButton extends ConsumerWidget {
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
final menuItemHeight = 30.0;
final List<PopupMenuEntry<AddToMenuItem>> items = [
PopupMenuItem(
enabled: false,
textStyle: context.textTheme.labelMedium,
height: 40,
child: Text("add_to_bottom_bar".tr()),
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.album,
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
BaseActionButton(
iconData: Icons.photo_album_outlined,
label: "album".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
),
const PopupMenuDivider(),
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
if (isOwner) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.archive,
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.unarchive,
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
PopupMenuItem(
height: menuItemHeight,
value: AddToMenuItem.lockedFolder,
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
),
],
];
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
context: context,
color: context.themeData.scaffoldBackgroundColor,
position: _menuPosition(context),
items: items,
popUpAnimationStyle: AnimationStyle.noAnimation,
);
if (selected == null) {
return;
}
switch (selected) {
case AddToMenuItem.album:
_openAlbumSelector(context, ref);
break;
case AddToMenuItem.archive:
await performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
RelativeRect _menuPosition(BuildContext context) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox) {
return RelativeRect.fill;
}
final size = renderObject.size;
final position = renderObject.localToGlobal(Offset.zero);
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
}
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
void _openAlbumSelector() {
final currentAsset = ref.read(currentAssetNotifier);
if (currentAsset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final List<Widget> slivers = [
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
];
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
showModalBottomSheet(
context: context,
@@ -141,7 +129,7 @@ class AddActionButton extends ConsumerWidget {
);
}
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
final latest = ref.read(currentAssetNotifier);
if (latest == null) {
@@ -174,17 +162,38 @@ class AddActionButton extends ConsumerWidget {
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
return Builder(
builder: (buttonContext) {
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
),
]
: _buildMenuChildren(),
builder: (context, controller, child) {
return BaseActionButton(
iconData: Icons.add,
label: "add_to_bottom_bar".tr(),
onPressed: () => _showAddOptions(buttonContext, ref),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class BaseActionButton extends StatelessWidget {
class BaseActionButton extends ConsumerWidget {
const BaseActionButton({
super.key,
required this.label,
@@ -11,6 +12,7 @@ class BaseActionButton extends StatelessWidget {
this.onLongPressed,
this.maxWidth = 90.0,
this.minWidth,
this.iconOnly = false,
this.menuItem = false,
});
@@ -19,25 +21,42 @@ class BaseActionButton extends StatelessWidget {
final Color? iconColor;
final double maxWidth;
final double? minWidth;
/// When true, renders only an IconButton without text label
final bool iconOnly;
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
final bool menuItem;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
final textColor = context.themeData.textTheme.labelLarge?.color;
if (menuItem) {
if (iconOnly) {
return IconButton(
onPressed: onPressed,
icon: Icon(iconData, size: iconSize, color: iconColor),
);
}
if (menuItem) {
final theme = context.themeData;
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
return MenuItemButton(
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: MaterialButton(

View File

@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.menuItem = true});
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class DownloadActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
if (!context.mounted) {
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
);

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class FavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
return BaseActionButton(
iconData: Icons.favorite_border_rounded,
label: "favorite".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);

View File

@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/user.provider.dart';
class LikeActivityActionButton extends ConsumerWidget {
const LikeActivityActionButton({super.key, this.menuItem = false});
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
label: "like".t(context: context),
onPressed: () => onTap(liked),
iconOnly: iconOnly,
menuItem: menuItem,
);
},
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
loading: () => BaseActionButton(
iconData: Icons.favorite_border,
label: "like".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
),
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),

View File

@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoActionButton extends ConsumerWidget {
const MotionPhotoActionButton({super.key, this.menuItem = true});
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UnFavoriteActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
iconData: Icons.favorite_rounded,
label: "unfavorite".t(context: context),
onPressed: () => _onTap(context, ref),
iconOnly: iconOnly,
menuItem: menuItem,
);
}

View File

@@ -38,11 +38,13 @@ class ViewerBottomBar extends ConsumerWidget {
opacity = 0;
}
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) const AddActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
asset.isLocalOnly

View File

@@ -4,25 +4,19 @@ 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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.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/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_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/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -41,15 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final showViewInTimelineButton =
timelineOrigin != TimelineOrigin.main &&
timelineOrigin != TimelineOrigin.deepLink &&
timelineOrigin != TimelineOrigin.trash &&
timelineOrigin != TimelineOrigin.archive &&
timelineOrigin != TimelineOrigin.localAlbum &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -62,11 +47,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
opacity = 0;
}
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final originalTheme = context.themeData;
final actions = <Widget>[
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
icon: const Icon(Icons.chat_outlined),
@@ -74,28 +58,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
},
),
if (showViewInTimelineButton)
IconButton(
onPressed: () async {
await context.maybePop();
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
},
icon: const Icon(Icons.image_search),
tooltip: 'view_in_timeline'.t(context: context),
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
const _KebabMenu(),
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
];
final lockedViewActions = <Widget>[
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
const _KebabMenu(),
];
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
return IgnorePointer(
ignoring: opacity < 255,
@@ -122,20 +94,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(60.0);
}
class _KebabMenu extends ConsumerWidget {
const _KebabMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
onPressed: () {
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
},
icon: const Icon(Icons.more_vert_rounded),
);
}
}
class _AppBarBackButton extends ConsumerWidget {
const _AppBarBackButton();

View File

@@ -0,0 +1,67 @@
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/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
final ThemeData? originalTheme;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetNotifier);
if (asset == null) {
return const SizedBox.shrink();
}
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final kebabContext = ViewerKebabMenuButtonContext(
asset: asset,
isOwner: isOwner,
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
);
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -324,7 +324,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
const scrubberBottomPadding = 100.0;
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
const bottomSheetOpenModifier = 120.0;
final bottomPadding =
context.padding.bottom +
(widget.appBar == null ? 0 : scrubberBottomPadding) +
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final grid = CustomScrollView(
primary: true,
@@ -347,7 +351,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false,
),
),
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
],
);

View File

@@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ChangePasswordRoute.page),
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
CustomRoute(
AutoRoute(
page: TabControllerRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
AutoRoute(
page: TabShellRoute.page,
guards: [_authGuard, _duplicateGuard],
children: [
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: GalleryViewerRoute.page,
@@ -288,7 +286,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LockedRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: PinAuthRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: FeatInDevRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: LocalMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupRoute.page, guards: [_authGuard, _duplicateGuard]),
@@ -340,6 +337,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -1648,22 +1648,6 @@ class FavoritesRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [FeatInDevPage]
class FeatInDevRoute extends PageRouteInfo<void> {
const FeatInDevRoute({List<PageRouteInfo>? children})
: super(FeatInDevRoute.name, initialChildren: children);
static const String name = 'FeatInDevRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const FeatInDevPage();
},
);
}
/// generated route for
/// [FilterImagePage]
class FilterImageRoute extends PageRouteInfo<FilterImageRouteArgs> {
@@ -1831,6 +1815,22 @@ class HeaderSettingsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [ImmichUIShowcasePage]
class ImmichUIShowcaseRoute extends PageRouteInfo<void> {
const ImmichUIShowcaseRoute({List<PageRouteInfo>? children})
: super(ImmichUIShowcaseRoute.name, initialChildren: children);
static const String name = 'ImmichUIShowcaseRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const ImmichUIShowcasePage();
},
);
}
/// generated route for
/// [LibraryPage]
class LibraryRoute extends PageRouteInfo<void> {

View File

@@ -1,9 +1,18 @@
import 'package:flutter/widgets.dart';
import 'package:auto_route/auto_route.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/album/album.model.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/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_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';
@@ -19,6 +28,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
class ActionButtonContext {
final BaseAsset asset;
@@ -164,3 +174,98 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
}
class ViewerKebabMenuButtonContext {
final BaseAsset asset;
final bool isOwner;
final bool isCasting;
final TimelineOrigin timelineOrigin;
final ThemeData? originalTheme;
const ViewerKebabMenuButtonContext({
required this.asset,
required this.isOwner,
required this.isCasting,
required this.timelineOrigin,
this.originalTheme,
});
}
enum ViewerKebabMenuButtonType {
openInfo,
viewInTimeline,
cast,
download;
/// Defines which group each button belongs to.
/// Buttons in the same group will be displayed together,
/// with dividers separating different groups.
int get group => switch (this) {
ViewerKebabMenuButtonType.openInfo => 0,
ViewerKebabMenuButtonType.viewInTimeline => 1,
ViewerKebabMenuButtonType.cast => 1,
ViewerKebabMenuButtonType.download => 1,
};
bool shouldShow(ViewerKebabMenuButtonContext context) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => true,
ViewerKebabMenuButtonType.viewInTimeline =>
context.timelineOrigin != TimelineOrigin.main &&
context.timelineOrigin != TimelineOrigin.deepLink &&
context.timelineOrigin != TimelineOrigin.trash &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.isOwner,
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
};
}
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
return switch (this) {
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
iconData: Icons.info_outline,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
),
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
label: 'view_in_timeline'.t(context: buildContext),
iconData: Icons.image_search,
iconColor: context.originalTheme?.iconTheme.color,
menuItem: true,
onPressed: () async {
await buildContext.maybePop();
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
};
}
}
class ViewerKebabMenuButtonBuilder {
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
if (visibleButtons.isEmpty) return [];
final List<Widget> result = [];
int? lastGroup;
for (final type in visibleButtons) {
if (lastGroup != null && type.group != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
lastGroup = type.group;
}
return result;
}
}

View File

@@ -53,16 +53,18 @@ class ServerUpdateNotification extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
Expanded(
child: Text(
serverInfoState.versionStatus.message,
textAlign: TextAlign.start,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
),
),
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate ||
serverInfoState.versionStatus == VersionStatus.clientOutOfDate) ...[
const Spacer(),
const SizedBox(width: 8),
TextButton(
onPressed: openUpdateLink,
style: TextButton.styleFrom(

View File

@@ -155,8 +155,8 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (kDebugMode || kProfileMode)
IconButton(
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
),
if (isCasting)
Padding(

View File

@@ -74,8 +74,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.science_rounded),
onPressed: () => context.pushRoute(const FeatInDevRoute()),
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),

View File

@@ -2,26 +2,27 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class EntitiyCountTile extends StatelessWidget {
class EntityCountTile extends StatelessWidget {
final int count;
final String label;
final IconData icon;
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
String zeroPadding(int number, int targetWidth) {
final numStr = number.toString();
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
}
int calculateMaxDigits(double availableWidth) {
const double charWidth = 11.0;
return (availableWidth / charWidth).floor().clamp(1, 8);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final availableWidth = (screenWidth - 32 - 8) / 2;
const double charWidth = 11.0;
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
return Container(
height: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
@@ -29,7 +30,6 @@ class EntitiyCountTile extends StatelessWidget {
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon and Label
@@ -38,33 +38,30 @@ class EntitiyCountTile extends StatelessWidget {
children: [
Icon(icon, color: context.primaryColor),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
Flexible(
child: Text(
label,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
),
],
),
const SizedBox(height: 12),
// Number
LayoutBuilder(
builder: (context, constraints) {
final maxDigits = calculateMaxDigits(constraints.maxWidth);
return RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
const Spacer(),
RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
children: [
TextSpan(
text: zeroPadding(count, maxDigits),
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
),
);
},
TextSpan(
text: count.toString(),
style: TextStyle(color: context.primaryColor),
),
],
),
),
],
),

View File

@@ -282,76 +282,87 @@ class _SyncStatsCounts extends ConsumerWidget {
_SectionHeaderText(text: "assets".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
// 1. Wrap in IntrinsicHeight
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// 2. Stretch children vertically to fill the IntrinsicHeight
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAssetCount,
icon: Icons.smartphone,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAssetCount,
icon: Icons.cloud,
),
),
),
],
],
),
),
),
_SectionHeaderText(text: "albums".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: localAlbumCount,
icon: Icons.smartphone,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
Expanded(
child: EntityCountTile(
label: "remote".t(context: context),
count: remoteAlbumCount,
icon: Icons.cloud,
),
),
),
],
],
),
),
),
_SectionHeaderText(text: "other".t(context: context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "memories".t(context: context),
count: memoryCount,
icon: Icons.calendar_today,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: localHashedCount,
icon: Icons.tag,
),
),
),
],
],
),
),
),
// To be removed once the experimental feature is stable
@@ -364,26 +375,29 @@ class _SyncStatsCounts extends ConsumerWidget {
return counts.when(
data: (c) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 8.0,
children: [
Expanded(
child: EntitiyCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
child: IntrinsicHeight(
child: Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
spacing: 8.0,
children: [
Expanded(
child: EntityCountTile(
label: "local".t(context: context),
count: c.total,
icon: Icons.delete_outline,
),
),
),
Expanded(
child: EntitiyCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
Expanded(
child: EntityCountTile(
label: "hashed_assets".t(context: context),
count: c.hashed,
icon: Icons.tag,
),
),
),
],
],
),
),
),
loading: () => const CircularProgressIndicator(),

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.3.1
- API version: 2.4.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -199,7 +199,6 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
@@ -466,10 +465,9 @@ Class | Method | HTTP request | Description
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginContext](doc//PluginContext.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)

View File

@@ -217,10 +217,9 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_context.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';

View File

@@ -73,57 +73,6 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.

View File

@@ -482,14 +482,12 @@ class ApiClient {
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginContext':
return PluginContextTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':

View File

@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
if (value is PluginContext) {
return PluginContextTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();

View File

@@ -32,7 +32,7 @@ class PluginActionResponseDto {
Object? schema;
List<PluginContextType> supportedContexts;
List<PluginContext> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}

View File

@@ -11,9 +11,9 @@
part of openapi.api;
class PluginContextType {
class PluginContext {
/// Instantiate a new enum with the provided [value].
const PluginContextType._(this.value);
const PluginContext._(this.value);
/// The underlying value of this enum member.
final String value;
@@ -23,24 +23,24 @@ class PluginContextType {
String toJson() => value;
static const asset = PluginContextType._(r'asset');
static const album = PluginContextType._(r'album');
static const person = PluginContextType._(r'person');
static const asset = PluginContext._(r'asset');
static const album = PluginContext._(r'album');
static const person = PluginContext._(r'person');
/// List of all possible values in this [enum][PluginContextType].
static const values = <PluginContextType>[
/// List of all possible values in this [enum][PluginContext].
static const values = <PluginContext>[
asset,
album,
person,
];
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[];
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContext>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginContextType.fromJson(row);
final value = PluginContext.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -50,16 +50,16 @@ class PluginContextType {
}
}
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
/// and [decode] dynamic data back to [PluginContextType].
class PluginContextTypeTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
/// Transformation class that can [encode] an instance of [PluginContext] to String,
/// and [decode] dynamic data back to [PluginContext].
class PluginContextTypeTransformer {
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
const PluginContextTypeTypeTransformer._();
const PluginContextTypeTransformer._();
String encode(PluginContextType data) => data.value;
String encode(PluginContext data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContextType.
/// Decodes a [dynamic value][data] to a PluginContext.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -67,12 +67,12 @@ class PluginContextTypeTypeTransformer {
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
PluginContext? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asset': return PluginContextType.asset;
case r'album': return PluginContextType.album;
case r'person': return PluginContextType.person;
case r'asset': return PluginContext.asset;
case r'album': return PluginContext.album;
case r'person': return PluginContext.person;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -82,7 +82,7 @@ class PluginContextTypeTypeTransformer {
return null;
}
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
/// Singleton [PluginContextTypeTransformer] instance.
static PluginContextTypeTransformer? _instance;
}

View File

@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
Object? schema;
List<PluginContextType> supportedContexts;
List<PluginContext> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}

View File

@@ -1,107 +0,0 @@
//
// 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 PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.contextType,
required this.type,
});
PluginContextType contextType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'contextType',
'type',
};
}

View File

@@ -40,7 +40,7 @@ class WorkflowResponseDto {
String ownerId;
PluginTriggerType triggerType;
WorkflowResponseDtoTriggerTypeEnum triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
);
}
return null;
@@ -165,3 +165,77 @@ class WorkflowResponseDto {
};
}
class WorkflowResponseDtoTriggerTypeEnum {
/// Instantiate a new enum with the provided [value].
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
assetCreate,
personRecognized,
];
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
}

View File

@@ -18,7 +18,6 @@ class WorkflowUpdateDto {
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
});
List<WorkflowActionItemDto> actions;
@@ -49,22 +48,13 @@ class WorkflowUpdateDto {
///
String? name;
///
/// 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.
///
PluginTriggerType? triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name &&
other.triggerType == triggerType;
other.name == name;
@override
int get hashCode =>
@@ -73,11 +63,10 @@ class WorkflowUpdateDto {
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -98,11 +87,6 @@ class WorkflowUpdateDto {
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
} else {
// json[r'triggerType'] = null;
}
return json;
}
@@ -120,7 +104,6 @@ class WorkflowUpdateDto {
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
);
}
return null;

View File

@@ -0,0 +1,3 @@
export 'src/buttons/close_button.dart';
export 'src/buttons/icon_button.dart';
export 'src/types.dart';

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/buttons/icon_button.dart';
import 'package:immich_ui/src/types.dart';
class ImmichCloseButton extends StatelessWidget {
final VoidCallback? onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichCloseButton({
super.key,
this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.ghost,
});
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onTap: onTap ?? () => Navigator.of(context).pop(),
);
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
final ImmichVariant variant;
final ImmichColor color;
const ImmichIconButton({
super.key,
required this.icon,
required this.onTap,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
});
@override
Widget build(BuildContext context) {
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.onPrimary,
ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => Theme.of(context).colorScheme.primary,
ImmichColor.secondary => Theme.of(context).colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: onTap,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
);
}
}

View File

@@ -0,0 +1,9 @@
enum ImmichVariant {
filled,
ghost,
}
enum ImmichColor {
primary,
secondary,
}

View File

@@ -0,0 +1,55 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
sdks:
dart: ">=3.8.0-0 <4.0.0"

View File

@@ -0,0 +1,12 @@
name: immich_ui
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter:
uses-material-design: true

View File

@@ -1015,6 +1015,13 @@ packages:
relative: true
source: path
version: "0.0.0"
immich_ui:
dependency: "direct main"
description:
path: "packages/ui"
relative: true
source: path
version: "0.0.0"
integration_test:
dependency: "direct dev"
description: flutter

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.3.1+3028
version: 2.4.0+3029
environment:
sdk: '>=3.8.0 <4.0.0'
@@ -43,6 +43,8 @@ dependencies:
hooks_riverpod: ^2.6.1
http: ^1.5.0
image_picker: ^1.2.0
immich_ui:
path: './packages/ui'
intl: ^0.20.2
isar:
git:

View File

@@ -8020,55 +8020,6 @@
"x-immich-state": "Alpha"
}
},
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getPluginTriggers",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTriggerResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all plugin triggers",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -14317,7 +14268,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.3.1",
"version": "2.4.0",
"contact": {}
},
"tags": [
@@ -18331,7 +18282,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContextType"
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
@@ -18350,7 +18301,7 @@
],
"type": "object"
},
"PluginContextType": {
"PluginContext": {
"enum": [
"asset",
"album",
@@ -18378,7 +18329,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContextType"
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
@@ -18450,29 +18401,6 @@
],
"type": "object"
},
"PluginTriggerResponseDto": {
"properties": {
"contextType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContextType"
}
]
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"contextType",
"type"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
@@ -23388,11 +23316,11 @@
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
}
},
"required": [
@@ -23430,13 +23358,6 @@
},
"name": {
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"type": "object"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.3.1",
"version": "2.4.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.3.1
* 2.4.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContextType[];
supportedContexts: PluginContext[];
title: string;
};
export type PluginFilterResponseDto = {
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContextType[];
supportedContexts: PluginContext[];
title: string;
};
export type PluginResponseDto = {
@@ -966,10 +966,6 @@ export type PluginResponseDto = {
updatedAt: string;
version: string;
};
export type PluginTriggerResponseDto = {
contextType: PluginContextType;
"type": PluginTriggerType;
};
export type QueueResponseDto = {
isPaused: boolean;
name: QueueName;
@@ -1754,7 +1750,7 @@ export type WorkflowResponseDto = {
id: string;
name: string | null;
ownerId: string;
triggerType: PluginTriggerType;
triggerType: TriggerType;
};
export type WorkflowActionItemDto = {
actionConfig?: object;
@@ -1778,7 +1774,6 @@ export type WorkflowUpdateDto = {
enabled?: boolean;
filters?: WorkflowFilterItemDto[];
name?: string;
triggerType?: PluginTriggerType;
};
/**
* List all activities
@@ -3661,17 +3656,6 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* List all plugin triggers
*/
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
}>("/plugins/triggers", {
...opts
}));
}
/**
* Retrieve a plugin
*/
@@ -5434,15 +5418,11 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContextType {
export enum PluginContext {
Asset = "asset",
Album = "album",
Person = "person"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
Active = "active",
Failed = "failed",
@@ -5659,3 +5639,11 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}

View File

@@ -1,36 +1,30 @@
{
"name": "immich-core",
"version": "2.0.1",
"version": "2.0.0",
"title": "Immich Core",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasm": {
"path": "dist/plugin.wasm"
},
"filters": [
{
"methodName": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"supportedContexts": [
"asset"
],
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "Filename pattern",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": [
"contains",
"regex",
"exact"
],
"enum": ["contains", "regex", "exact"],
"default": "contains",
"description": "Type of pattern matching to perform"
},
@@ -40,57 +34,43 @@
"description": "Whether matching should be case-sensitive"
}
},
"required": [
"pattern"
]
"required": ["pattern"]
}
},
{
"methodName": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"supportedContexts": [
"asset"
],
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"title": "File types",
"items": {
"type": "string",
"enum": [
"image",
"video"
]
"enum": ["IMAGE", "VIDEO"]
},
"description": "Allowed file types"
}
},
"required": [
"fileTypes"
]
"required": ["fileTypes"]
}
},
{
"methodName": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"supportedContexts": [
"person"
],
"supportedContexts": ["person"],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"title": "Person IDs",
"items": {
"type": "string"
},
"description": "List of person to match",
"subType": "people-picker"
"description": "List of person to match"
},
"matchAny": {
"type": "boolean",
@@ -98,29 +78,24 @@
"description": "Match any name (true) or require all names (false)"
}
},
"required": [
"personIds"
]
"required": ["personIds"]
}
}
],
"actions": [
{
"methodName": "actionArchive",
"title": "Archive",
"description": "Move the asset to archive",
"supportedContexts": [
"asset"
],
"supportedContexts": ["asset"],
"schema": {}
},
{
"methodName": "actionFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"supportedContexts": [
"asset"
],
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
@@ -136,23 +111,16 @@
"methodName": "actionAddToAlbum",
"title": "Add to Album",
"description": "Add the item to a specified album",
"supportedContexts": [
"asset",
"person"
],
"supportedContexts": ["asset", "person"],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"subType": "album-picker"
"description": "Target album ID"
}
},
"required": [
"albumId"
]
"required": ["albumId"]
}
}
]

View File

@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"cpu": [
"x64"
],
@@ -467,9 +467,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.0",
"@esbuild/android-arm": "0.27.0",
"@esbuild/android-arm64": "0.27.0",
"@esbuild/android-x64": "0.27.0",
"@esbuild/darwin-arm64": "0.27.0",
"@esbuild/darwin-x64": "0.27.0",
"@esbuild/freebsd-arm64": "0.27.0",
"@esbuild/freebsd-x64": "0.27.0",
"@esbuild/linux-arm": "0.27.0",
"@esbuild/linux-arm64": "0.27.0",
"@esbuild/linux-ia32": "0.27.0",
"@esbuild/linux-loong64": "0.27.0",
"@esbuild/linux-mips64el": "0.27.0",
"@esbuild/linux-ppc64": "0.27.0",
"@esbuild/linux-riscv64": "0.27.0",
"@esbuild/linux-s390x": "0.27.0",
"@esbuild/linux-x64": "0.27.0",
"@esbuild/netbsd-arm64": "0.27.0",
"@esbuild/netbsd-x64": "0.27.0",
"@esbuild/openbsd-arm64": "0.27.0",
"@esbuild/openbsd-x64": "0.27.0",
"@esbuild/openharmony-arm64": "0.27.0",
"@esbuild/sunos-x64": "0.27.0",
"@esbuild/win32-arm64": "0.27.0",
"@esbuild/win32-ia32": "0.27.0",
"@esbuild/win32-x64": "0.27.0"
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
}
},
"node_modules/typescript": {

1783
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,13 +50,15 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --cd plugins
COPY ./plugins ./plugins/
@@ -66,7 +68,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.3.1",
"version": "2.4.0",
"description": "",
"author": "",
"private": true,
@@ -70,7 +70,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.3.3",
"exiftool-vendored": "^33.0.0",
"exiftool-vendored": "^34.0.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { PluginResponseDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
@@ -12,17 +12,6 @@ import { UUIDParamDto } from 'src/validation';
export class PluginController {
constructor(private service: PluginService) {}
@Get('triggers')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugin triggers',
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPluginTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({

View File

@@ -1,16 +1,9 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
export class PluginTriggerResponseDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
type!: PluginTriggerType;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
contextType!: PluginContextType;
}
export class PluginResponseDto {
id!: string;
name!: string;
@@ -31,8 +24,8 @@ export class PluginFilterResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}
@@ -43,8 +36,8 @@ export class PluginActionResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}

View File

@@ -48,9 +48,6 @@ export class WorkflowCreateDto {
}
export class WorkflowUpdateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true })
triggerType?: PluginTriggerType;
@IsString()
@IsNotEmpty()
@Optional()
@@ -77,7 +74,6 @@ export class WorkflowUpdateDto {
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;

View File

@@ -1,17 +1,37 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
contextType: PluginContext;
description: string;
context: PluginContext;
schema: JSONSchema | null;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: PluginTriggerType.AssetCreate,
contextType: PluginContext.Asset,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
schema: {
type: 'object',
properties: {
assetType: {
type: 'string',
description: 'Type of the asset',
default: 'ALL',
enum: ['Image', 'Video', 'All'],
},
},
},
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
contextType: PluginContext.Person,
description: 'Triggered when a person is detected in an asset',
context: PluginContext.Person,
schema: null,
},
];

View File

@@ -7,8 +7,6 @@ from
"workflow"
where
"id" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByOwner
select
@@ -18,7 +16,7 @@ from
where
"ownerId" = $1
order by
"createdAt" desc
"name"
-- WorkflowRepository.getWorkflowsByTrigger
select

View File

@@ -12,22 +12,12 @@ export class WorkflowRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('id', '=', id)
.orderBy('createdAt', 'desc')
.executeTakeFirst();
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.orderBy('createdAt', 'desc')
.execute();
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
}
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })

View File

@@ -6,9 +6,8 @@ import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
@@ -51,10 +50,6 @@ export class PluginService extends BaseService {
await this.loadPlugins();
}
getTriggers(): PluginTriggerResponseDto[] {
return pluginTriggers;
}
//
// CRUD operations for plugins
//

View File

@@ -16,10 +16,10 @@ import { BaseService } from 'src/services/base.service';
@Injectable()
export class WorkflowService extends BaseService {
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const context = this.getContextForTrigger(dto.triggerType);
const trigger = this.getTriggerOrFail(dto.triggerType);
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
const actionInserts = await this.validateAndMapActions(dto.actions, context);
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
const workflow = await this.workflowRepository.createWorkflow(
{
@@ -56,11 +56,11 @@ export class WorkflowService extends BaseService {
}
const workflow = await this.findOrFail(id);
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
const trigger = this.getTriggerOrFail(workflow.triggerType);
const { filters, actions, ...workflowUpdate } = dto;
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
id,
@@ -124,12 +124,12 @@ export class WorkflowService extends BaseService {
}));
}
private getContextForTrigger(type: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === type);
private getTriggerOrFail(triggerType: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === triggerType);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${type}`);
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
}
return trigger.contextType;
return trigger;
}
private async findOrFail(id: string) {

View File

@@ -611,100 +611,6 @@ describe(WorkflowService.name, () => {
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
).rejects.toThrow();
});
it('should update trigger type', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.PersonRecognized,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
triggerType: PluginTriggerType.AssetCreate,
});
const fetched = await sut.get(auth, created.id);
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
});
it('should add filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
filters: [
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(2);
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
});
it('should replace existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(1);
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
});
it('should remove existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(0);
});
});
describe('delete', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.3.1",
"version": "2.4.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.50.0",
"@immich/ui": "^0.50.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -58,7 +58,6 @@
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1",
"svelte-jsoneditor": "^3.10.0",
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
@@ -99,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.45.2",
"svelte": "5.45.5",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

View File

@@ -1,118 +0,0 @@
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (element: HTMLElement) => {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'true');
}
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
// Add back only the active ones
if (dragging) {
node.classList.add('opacity-50');
}
if (dragOver) {
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
node.classList.remove('border-transparent');
} else {
node.classList.add('border-transparent');
}
};
updateClasses(isDragging || false, isDragOver || false);
return {
update(newOptions: DragAndDropOptions) {
index = newOptions.index;
onDragStart = newOptions.onDragStart;
onDragEnter = newOptions.onDragEnter;
onDrop = newOptions.onDrop;
onDragEnd = newOptions.onDragEnd;
const newIsDragging = newOptions.isDragging || false;
const newIsDragOver = newOptions.isDragOver || false;
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
isDragging = newIsDragging;
isDragOver = newIsDragOver;
updateClasses(isDragging, isDragOver);
}
},
destroy() {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
},
};
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,105 +0,0 @@
import type { Attachment } from 'svelte/attachments';
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(options: DragAndDropOptions): Attachment {
return (node: Element) => {
const element = node as HTMLElement;
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (el: HTMLElement) => {
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'true');
}
};
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
// Add back only the active ones
if (dragging) {
element.classList.add('opacity-50');
}
if (dragOver) {
element.classList.add('border-light-500', 'border-solid');
element.classList.remove('border-transparent');
} else {
element.classList.add('border-transparent');
}
};
element.setAttribute('draggable', 'true');
element.setAttribute('role', 'button');
element.setAttribute('tabindex', '0');
element.addEventListener('dragstart', handleDragStart);
element.addEventListener('dragenter', handleDragEnter);
element.addEventListener('dragover', handleDragOver);
element.addEventListener('drop', handleDrop);
element.addEventListener('dragend', handleDragEnd);
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
updateClasses(isDragging || false, isDragOver || false);
return () => {
element.removeEventListener('dragstart', handleDragStart);
element.removeEventListener('dragenter', handleDragEnter);
element.removeEventListener('dragover', handleDragOver);
element.removeEventListener('drop', handleDrop);
element.removeEventListener('dragend', handleDragEnd);
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
};
}

View File

@@ -32,7 +32,7 @@
</script>
<tr
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
{oncontextmenu}
>

View File

@@ -126,6 +126,7 @@
const onMouseLeave = () => {
mouseOver = false;
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
};
let timer: ReturnType<typeof setTimeout> | null = null;

View File

@@ -188,7 +188,7 @@
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
const monthGroup = await timelineManager.findMonthGroupForAsset({ id: assetId });
if (!monthGroup) {
return false;
}

View File

@@ -9,7 +9,6 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -17,7 +16,6 @@
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
];
</script>

View File

@@ -1,171 +0,0 @@
<script lang="ts">
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
type Props = {
schema: object | null;
config: Record<string, unknown>;
configKey?: string;
};
let { schema = null, config = $bindable({}), configKey }: Props = $props();
const components = $derived(getComponentFromSchema(schema));
// Get the actual config object to work with
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
// Update function that handles nested config
const updateConfig = (key: string, value: unknown) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
};
const updateConfigBatch = (updates: Record<string, unknown>) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
// Derive which keys need initialization (missing from actualConfig)
const uninitializedKeys = $derived.by(() => {
if (!components) {
return [];
}
return Object.entries(components)
.filter(([key]) => actualConfig[key] === undefined)
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
});
// Derive the batch updates needed
const pendingUpdates = $derived.by(() => {
const updates: Record<string, unknown> = {};
for (const { key, defaultValue } of uninitializedKeys) {
updates[key] = defaultValue;
}
return updates;
});
// Initialize config namespace if needed
$effect(() => {
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
});
// Apply pending config updates
$effect(() => {
if (Object.keys(pendingUpdates).length > 0) {
updateConfigBatch(pendingUpdates);
}
});
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
<!-- Select component -->
{#if component.type === 'select'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValue = actualConfig[key]}
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValues = (actualConfig[key] as string[]) ?? []}
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<MultiSelect
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
values={selectedItems}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
{@const checked = Boolean(actualConfig[key])}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Input
id={key}
value={actualConfig[key] as string}
oninput={(e) => updateConfig(key, e.currentTarget.value)}
required={component.required}
/>
</Field>
{/if}
</div>
{/each}
</div>
{:else}
<Text size="small" color="muted">No configuration required</Text>
{/if}

View File

@@ -1,42 +0,0 @@
<script lang="ts">
type Props = {
animated?: boolean;
};
let { animated = true }: Props = $props();
</script>
<div class="flex justify-center py-2">
<div class="relative h-12 w-0.5">
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
{#if animated}
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
{/if}
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
</div>
</div>
<style>
@keyframes flow {
0% {
transform: translateY(-25%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(25%);
opacity: 0;
}
}
.flow-pulse {
animation: flow 2s ease-in-out infinite;
}
</style>

View File

@@ -1,69 +0,0 @@
<script lang="ts">
import { themeManager } from '$lib/managers/theme-manager.svelte';
import type { WorkflowPayload } from '$lib/services/workflow.service';
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
import { mdiCodeJson } from '@mdi/js';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
type Props = {
jsonContent: WorkflowPayload;
onApply: () => void;
onContentChange: (content: WorkflowPayload) => void;
};
let { jsonContent, onApply, onContentChange }: Props = $props();
let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) {
return;
}
canApply = true;
if ('text' in updated && updated.text !== undefined) {
try {
const parsed = JSON.parse(updated.text);
onContentChange(parsed);
} catch (error_) {
console.error('Invalid JSON in text mode:', error_);
}
}
};
const handleApply = () => {
onApply();
canApply = false;
};
</script>
<VStack gap={4}>
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>Workflow JSON</CardTitle>
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
</div>
</div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div>
</CardHeader>
<CardBody>
<VStack gap={2}>
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>
</CardBody>
</Card>
</VStack>
<style>
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
</style>

View File

@@ -1,104 +0,0 @@
<script lang="ts">
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
import type { ComponentConfig } from '$lib/utils/workflow';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, modalManager } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
component: ComponentConfig;
configKey: string;
value: string | string[] | undefined;
onchange: (value: string | string[]) => void;
};
let { component, configKey, value = $bindable(), onchange }: Props = $props();
const label = $derived(component.title || component.label || configKey);
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
const isAlbum = $derived(subType === 'album-picker');
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
let pickerMetadata = $state<PickerMetadata | undefined>();
$effect(() => {
if (!value) {
pickerMetadata = undefined;
return;
}
if (!pickerMetadata) {
void loadMetadata();
}
});
const loadMetadata = async () => {
pickerMetadata = await fetchPickerMetadata(value, subType);
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
pickerMetadata = multiple ? albums : albums[0];
}
} else {
const currentIds = (Array.isArray(value) ? value : []) as string[];
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
onchange(newValue);
pickerMetadata = multiple ? people : people[0];
}
}
};
const removeSelection = () => {
onchange(multiple ? [] : '');
pickerMetadata = undefined;
};
const removeItemFromSelection = (itemId: string) => {
if (!Array.isArray(value)) {
return;
}
const newValue = value.filter((id) => id !== itemId);
onchange(newValue);
if (Array.isArray(pickerMetadata)) {
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
}
};
const getButtonText = () => {
if (isAlbum) {
return multiple ? $t('select_albums') : $t('select_album');
}
return multiple ? $t('select_people') : $t('select_person');
};
</script>
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>

View File

@@ -1,57 +0,0 @@
<script lang="ts">
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
item: AlbumResponseDto | PersonResponseDto;
isAlbum: boolean;
onRemove: () => void;
};
let { item, isAlbum, onRemove }: Props = $props();
</script>
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
{:else}
<div class="h-12 w-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</Text>
{#if isAlbum && 'assetCount' in item}
<Text size="small" color="muted">
{$t('items_count', { values: { count: item.assetCount } })}
</Text>
{/if}
</div>
<IconButton
type="button"
onclick={onRemove}
class="shrink-0"
shape="round"
aria-label={$t('remove')}
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
/>
</CardBody>
</Card>

View File

@@ -1,184 +0,0 @@
<script lang="ts">
import {
PluginTriggerType,
type PluginActionResponseDto,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
};
let { trigger, filters, actions }: Props = $props();
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
}
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<div
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
</div>
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{filter.title}</p>
</div>
{/each}
</div>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
{/if}
<!-- Actions -->
{#if actions.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{action.title}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{:else}
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />
</button>
{/if}

View File

@@ -1,80 +0,0 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
selected: boolean;
onclick: () => void;
};
let { trigger, selected, onclick }: Props = $props();
const getTriggerIcon = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return mdiFileUploadOutline;
}
case PluginTriggerType.PersonRecognized: {
return mdiFaceRecognition;
}
default: {
return mdiLightningBolt;
}
}
};
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
const getTriggerDescription = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded_description');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
default: {
return '';
}
}
};
</script>
<button
type="button"
{onclick}
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 {selected
? 'bg-primary text-light'
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
>
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
{#if getTriggerDescription(trigger.type)}
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
{/if}
</div>
</div>
</button>

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