mirror of
https://github.com/immich-app/immich.git
synced 2025-02-18 01:24:26 -05:00
Merge branch 'main' into feat/mobile-folder-view
This commit is contained in:
commit
aeadf6f5ff
106 changed files with 3184 additions and 1277 deletions
1
.github/PULL_REQUEST_TEMPLATE/config.yml
vendored
1
.github/PULL_REQUEST_TEMPLATE/config.yml
vendored
|
@ -1,2 +1 @@
|
|||
blank_issues_enabled: false
|
||||
blank_pull_request_template_enabled: false
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
## Description
|
||||
<!--- Describe your changes in detail -->
|
||||
<!--- Why is this change required? What problem does it solve? -->
|
||||
<!--- If it fixes an open issue, please link to the issue here. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
|
||||
## Screenshots (if appropriate):
|
||||
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have made corresponding changes to the documentation if applicable
|
36
.github/pull_request_template.md
vendored
Normal file
36
.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
## Description
|
||||
|
||||
<!--- Describe your changes in detail -->
|
||||
<!--- Why is this change required? What problem does it solve? -->
|
||||
<!--- If it fixes an open issue, please link to the issue here. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
|
||||
<details><summary><h2>Screenshots (if appropriate)</h2></summary>
|
||||
|
||||
<!-- Images go below this line. -->
|
||||
|
||||
</details>
|
||||
|
||||
<!-- API endpoint changes (if relevant)
|
||||
## API Changes
|
||||
The `/api/something` endpoint is now `/api/something-else`
|
||||
-->
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have made corresponding changes to the documentation if applicable
|
||||
- [ ] 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`)
|
|
@ -2,37 +2,37 @@
|
|||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.50.0"
|
||||
constraints = "4.50.0"
|
||||
version = "4.52.0"
|
||||
constraints = "4.52.0"
|
||||
hashes = [
|
||||
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
|
||||
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
|
||||
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
|
||||
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
|
||||
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
|
||||
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
|
||||
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
|
||||
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
|
||||
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
|
||||
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
|
||||
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
|
||||
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
|
||||
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
|
||||
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
|
||||
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
|
||||
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
|
||||
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
|
||||
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
|
||||
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
|
||||
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
|
||||
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
|
||||
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
|
||||
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
|
||||
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
|
||||
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
|
||||
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
|
||||
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
|
||||
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
|
||||
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
|
||||
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
|
||||
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
|
||||
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
|
||||
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
|
||||
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
|
||||
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
|
||||
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
|
||||
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
|
||||
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
|
||||
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
|
||||
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
|
||||
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
|
||||
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
|
||||
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
|
||||
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
|
||||
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
|
||||
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
|
||||
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
|
||||
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
|
||||
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
|
||||
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
|
||||
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
|
||||
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
|
||||
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
|
||||
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
|
||||
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ terraform {
|
|||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.50.0"
|
||||
version = "4.52.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,37 +2,37 @@
|
|||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "4.50.0"
|
||||
constraints = "4.50.0"
|
||||
version = "4.52.0"
|
||||
constraints = "4.52.0"
|
||||
hashes = [
|
||||
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
|
||||
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
|
||||
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
|
||||
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
|
||||
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
|
||||
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
|
||||
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
|
||||
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
|
||||
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
|
||||
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
|
||||
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
|
||||
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
|
||||
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
|
||||
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
|
||||
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
|
||||
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
|
||||
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
|
||||
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
|
||||
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
|
||||
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
|
||||
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
|
||||
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
|
||||
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
|
||||
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
|
||||
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
|
||||
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
|
||||
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
|
||||
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
|
||||
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
|
||||
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
|
||||
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
|
||||
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
|
||||
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
|
||||
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
|
||||
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
|
||||
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
|
||||
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
|
||||
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
|
||||
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
|
||||
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
|
||||
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
|
||||
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
|
||||
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
|
||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
|
||||
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
|
||||
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
|
||||
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
|
||||
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
|
||||
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
|
||||
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
|
||||
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
|
||||
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
|
||||
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
|
||||
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
|
||||
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
|
||||
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ terraform {
|
|||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.50.0"
|
||||
version = "4.52.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,7 +103,7 @@ services:
|
|||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c
|
||||
image: grafana/grafana:11.5.0-ubuntu@sha256:3c9e2b202eb933a22da5f2b5a22c98a665493f603b452263d9d6f242a87f60d7
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
|
|
@ -76,7 +76,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
|||
|
||||
### Mobile app
|
||||
|
||||
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x to be installed on your system.
|
||||
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x and FVM to be installed on your system.
|
||||
|
||||
Please refer to the [Flutter's official documentation](https://flutter.dev/docs/get-started/install) for more information on setting up the toolchain on your machine.
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
|||
|
||||
- ARM NN (Mali)
|
||||
- CUDA (NVIDIA GPUs with [compute capability](https://developer.nvidia.com/cuda-gpus) 5.2 or higher)
|
||||
- OpenVINO (Intel discrete GPUs such as Iris Xe and Arc)
|
||||
- OpenVINO (Intel GPUs such as Iris Xe and Arc)
|
||||
|
||||
## Limitations
|
||||
|
||||
|
@ -43,8 +43,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
|||
|
||||
#### OpenVINO
|
||||
|
||||
- The server must have a discrete GPU, i.e. Iris Xe or Arc. Expect issues when attempting to use integrated graphics.
|
||||
- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM.
|
||||
- Ensure the server's kernel version is new enough to use the device for hardware accceleration.
|
||||
- Expect higher RAM usage when using OpenVINO compared to CPU processing.
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
|
@ -27,6 +27,10 @@ SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09
|
|||
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
|
||||
```
|
||||
|
||||
```sql title="Find by ID"
|
||||
SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9';
|
||||
```
|
||||
|
||||
:::note
|
||||
You can calculate the checksum for a particular file by using the command `sha1sum <filename>`.
|
||||
:::
|
||||
|
|
|
@ -41,7 +41,7 @@ className="border rounded-xl"
|
|||
:::info Permissions
|
||||
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
||||
|
||||
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
|
||||
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, Immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
|
||||
:::
|
||||
|
||||
## Installing the Immich Application
|
||||
|
@ -160,6 +160,10 @@ The image above has example values.
|
|||
|
||||
### Additional Storage [(External Libraries)](/docs/features/libraries)
|
||||
|
||||
:::danger Advanced Users Only
|
||||
This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup. Also, your mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`. The picture below shows a valid example.
|
||||
:::
|
||||
|
||||
<img
|
||||
src={require('./img/truenas10.webp').default}
|
||||
width="40%"
|
||||
|
@ -168,7 +172,7 @@ className="border rounded-xl"
|
|||
/>
|
||||
|
||||
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
||||
The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
|
||||
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
|
||||
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
|
||||
|
||||
<!-- A section for Labels would go here but I don't know what they do. -->
|
||||
|
|
|
@ -72,7 +72,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
|||
</ul>
|
||||
</details>
|
||||
|
||||
5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
|
||||
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
|
||||
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
|
||||
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
|
||||
|
||||
|
|
|
@ -110,9 +110,9 @@ const config = {
|
|||
label: 'API',
|
||||
},
|
||||
{
|
||||
to: '/blog',
|
||||
href: 'https://immich.store',
|
||||
position: 'right',
|
||||
label: 'Blog',
|
||||
label: 'Merch',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/immich-app/immich',
|
||||
|
|
|
@ -44,12 +44,12 @@ export default function VersionSwitcher(): JSX.Element {
|
|||
return (
|
||||
versions.length > 0 && (
|
||||
<DropdownNavbarItem
|
||||
className="navbar__item"
|
||||
className="version-switcher-34ab39"
|
||||
label={label}
|
||||
mobile={windowSize === 'mobile'}
|
||||
items={versions.map(({ label, url }) => ({
|
||||
label,
|
||||
to: url,
|
||||
to: url + location.pathname,
|
||||
target: '_self',
|
||||
}))}
|
||||
/>
|
||||
|
|
|
@ -75,6 +75,11 @@ div[class^='announcementBar_'] {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* workaround for version switcher PR 15894 */
|
||||
div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -50,6 +50,13 @@ function HomepageHeader() {
|
|||
>
|
||||
Demo
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
|
||||
to="https://demo.immich.app/"
|
||||
>
|
||||
Buy Merch
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="my-12 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">
|
||||
|
|
86
e2e/src/api/specs/jobs.e2e-spec.ts
Normal file
86
e2e/src/api/specs/jobs.e2e-spec.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { JobCommand, JobName, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/jobs', () => {
|
||||
let admin: LoginResponseDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
});
|
||||
|
||||
describe('PUT /jobs', () => {
|
||||
afterEach(async () => {
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put('/jobs/metadataExtraction');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should queue metadata extraction for missing assets', async () => {
|
||||
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
|
||||
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;
|
||||
|
||||
await utils.createAsset(admin.accessToken, {
|
||||
assetData: { bytes: await readFile(path1), filename: basename(path1) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Pause,
|
||||
force: false,
|
||||
});
|
||||
|
||||
const { id } = await utils.createAsset(admin.accessToken, {
|
||||
assetData: { bytes: await readFile(path2), filename: basename(path2) },
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
{
|
||||
const asset = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
expect(asset.exifInfo).toBeDefined();
|
||||
expect(asset.exifInfo?.make).toBeNull();
|
||||
}
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Empty,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Resume,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
|
||||
command: JobCommand.Start,
|
||||
force: false,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
|
||||
|
||||
{
|
||||
const asset = await utils.getAssetInfo(admin.accessToken, id);
|
||||
|
||||
expect(asset.exifInfo).toBeDefined();
|
||||
expect(asset.exifInfo?.make).toBe('NIKON CORPORATION');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import { LoginResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { uuidDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import { app, asBearerAuth, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
@ -195,6 +195,7 @@ describe('/people', () => {
|
|||
.send({
|
||||
name: 'New Person',
|
||||
birthDate: '1990-01-01',
|
||||
color: '#333',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
|
@ -203,6 +204,22 @@ describe('/people', () => {
|
|||
birthDate: '1990-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a favorite person', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post(`/people`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
name: 'New Favorite Person',
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: 'New Favorite Person',
|
||||
isFavorite: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /people/:id', () => {
|
||||
|
@ -216,6 +233,7 @@ describe('/people', () => {
|
|||
{ key: 'name', type: 'string' },
|
||||
{ key: 'featureFaceAssetId', type: 'string' },
|
||||
{ key: 'isHidden', type: 'boolean value' },
|
||||
{ key: 'isFavorite', type: 'boolean value' },
|
||||
]) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const { status, body } = await request(app)
|
||||
|
@ -255,6 +273,42 @@ describe('/people', () => {
|
|||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ birthDate: null });
|
||||
});
|
||||
|
||||
it('should set a color', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${visiblePerson.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ color: '#555' });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ color: '#555' });
|
||||
});
|
||||
|
||||
it('should clear a color', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${visiblePerson.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ color: null });
|
||||
expect(status).toBe(200);
|
||||
expect(body.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should mark a person as favorite', async () => {
|
||||
const person = await utils.createPerson(admin.accessToken, {
|
||||
name: 'visible_person',
|
||||
});
|
||||
|
||||
expect(person.isFavorite).toBe(false);
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put(`/people/${person.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ isFavorite: true });
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ isFavorite: true });
|
||||
|
||||
const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) });
|
||||
expect(person2).toMatchObject({ id: person.id, isFavorite: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /people/:id/merge', () => {
|
||||
|
|
|
@ -89,7 +89,7 @@ describe('/shared-links', () => {
|
|||
await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
|
||||
});
|
||||
|
||||
describe('GET /share/${key}', () => {
|
||||
describe('GET /share/:key', () => {
|
||||
it('should have correct asset count in meta tag for non-empty album', async () => {
|
||||
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
||||
expect(resp.status).toBe(200);
|
||||
|
@ -139,7 +139,10 @@ describe('/shared-links', () => {
|
|||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: linkWithAlbum.id }),
|
||||
expect.objectContaining({ id: linkWithAssets.id }),
|
||||
expect.objectContaining({
|
||||
id: linkWithAssets.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset1.id })]),
|
||||
}),
|
||||
expect.objectContaining({ id: linkWithPassword.id }),
|
||||
expect.objectContaining({ id: linkWithMetadata.id }),
|
||||
expect.objectContaining({ id: linkWithoutMetadata.id }),
|
||||
|
|
|
@ -6,6 +6,8 @@ import {
|
|||
CheckExistingAssetsDto,
|
||||
CreateAlbumDto,
|
||||
CreateLibraryDto,
|
||||
JobCommandDto,
|
||||
JobName,
|
||||
MetadataSearchDto,
|
||||
Permission,
|
||||
PersonCreateDto,
|
||||
|
@ -29,6 +31,7 @@ import {
|
|||
getConfigDefaults,
|
||||
login,
|
||||
searchAssets,
|
||||
sendJobCommand,
|
||||
setBaseUrl,
|
||||
signUpAdmin,
|
||||
tagAssets,
|
||||
|
@ -475,6 +478,9 @@ export const utils = {
|
|||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
|
||||
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
|
||||
await context.addCookies([
|
||||
{
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"search_by_description_example": "Hiking day in Sapa",
|
||||
"search_by_description": "Search by description",
|
||||
"about": "About",
|
||||
"account": "Account",
|
||||
"account_settings": "Account Settings",
|
||||
|
@ -766,8 +768,10 @@
|
|||
"go_to_search": "Go to search",
|
||||
"go_to_folder": "Go to folder",
|
||||
"group_albums_by": "Group albums by...",
|
||||
"group_country": "Group by country",
|
||||
"group_no": "No grouping",
|
||||
"group_owner": "Group by owner",
|
||||
"group_places_by": "Group places by...",
|
||||
"group_year": "Group by year",
|
||||
"has_quota": "Has quota",
|
||||
"hi_user": "Hi {name} ({email})",
|
||||
|
@ -985,6 +989,7 @@
|
|||
"pick_a_location": "Pick a location",
|
||||
"place": "Place",
|
||||
"places": "Places",
|
||||
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
|
||||
"play": "Play",
|
||||
"play_memories": "Play memories",
|
||||
"play_motion_photo": "Play Motion Photo",
|
||||
|
@ -1276,6 +1281,7 @@
|
|||
"unfavorite": "Unfavorite",
|
||||
"unhide_person": "Unhide person",
|
||||
"unknown": "Unknown",
|
||||
"unknown_country": "Unknown Country",
|
||||
"unknown_year": "Unknown Year",
|
||||
"unlimited": "Unlimited",
|
||||
"unlink_motion_video": "Unlink motion video",
|
||||
|
@ -1350,4 +1356,4 @@
|
|||
"yes": "Yes",
|
||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||
"zoom_image": "Zoom Image"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,10 @@ start_docker_compose() {
|
|||
show_friendly_message() {
|
||||
local ip_address
|
||||
ip_address=$(hostname -I | awk '{print $1}')
|
||||
# If length of ip_address is 0, then we are on a Mac
|
||||
if [ ${#ip_address} -eq 0 ]; then
|
||||
ip_address=$(ipconfig getifaddr en0)
|
||||
fi
|
||||
cat <<EOF
|
||||
Successfully deployed Immich!
|
||||
You can access the website or the mobile app at http://$ip_address:2283
|
||||
|
|
|
@ -106,6 +106,22 @@ COPY --from=builder /opt/venv /opt/venv
|
|||
COPY ann/ann.py /usr/src/ann/ann.py
|
||||
COPY start.sh log_conf.json gunicorn_conf.py ./
|
||||
COPY app .
|
||||
|
||||
ARG BUILD_ID
|
||||
ARG BUILD_IMAGE
|
||||
ARG BUILD_SOURCE_REF
|
||||
ARG BUILD_SOURCE_COMMIT
|
||||
|
||||
ENV IMMICH_BUILD=${BUILD_ID}
|
||||
ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID}
|
||||
ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE}
|
||||
ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-machine-learning
|
||||
ENV IMMICH_REPOSITORY=immich-app/immich
|
||||
ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich
|
||||
ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
|
||||
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
|
||||
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["./start.sh"]
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
echo "Initializing Immich ML $IMMICH_SOURCE_REF"
|
||||
|
||||
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
# mimalloc seems to increase memory usage dramatically with openvino, need to investigate
|
||||
if ! [ "$DEVICE" = "openvino" ]; then
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
{
|
||||
"search_filter_contextual": "Search by context",
|
||||
"search_filter_filename": "Search by file name",
|
||||
"search_filter_description": "Search by description",
|
||||
"search_no_result": "No results found, try a different search term or combination",
|
||||
"description_search": "Hiking day in Sapa",
|
||||
"search_no_more_result": "No more results",
|
||||
"action_common_back": "Back",
|
||||
"action_common_cancel": "Cancel",
|
||||
|
@ -289,9 +293,9 @@
|
|||
"header_settings_field_validator_msg": "Value cannot be empty",
|
||||
"header_settings_header_name_input": "Header name",
|
||||
"header_settings_header_value_input": "Header value",
|
||||
"header_settings_page_title": "Proxy Headers (EXPERIMENTAL)",
|
||||
"header_settings_page_title": "Proxy Headers",
|
||||
"headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request",
|
||||
"headers_settings_tile_title": "Custom proxy headers",
|
||||
"headers_settings_tile_title": "Custom proxy headers (EXPERIMENTAL)",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||
|
|
|
@ -2,3 +2,9 @@ enum SortOrder {
|
|||
asc,
|
||||
desc,
|
||||
}
|
||||
|
||||
enum TextSearchType {
|
||||
context,
|
||||
filename,
|
||||
description,
|
||||
}
|
||||
|
|
|
@ -545,19 +545,13 @@ enum AssetType {
|
|||
}
|
||||
|
||||
extension AssetTypeEnumHelper on AssetTypeEnum {
|
||||
AssetType toAssetType() {
|
||||
switch (this) {
|
||||
case AssetTypeEnum.IMAGE:
|
||||
return AssetType.image;
|
||||
case AssetTypeEnum.VIDEO:
|
||||
return AssetType.video;
|
||||
case AssetTypeEnum.AUDIO:
|
||||
return AssetType.audio;
|
||||
case AssetTypeEnum.OTHER:
|
||||
return AssetType.other;
|
||||
}
|
||||
throw Exception();
|
||||
}
|
||||
AssetType toAssetType() => switch (this) {
|
||||
AssetTypeEnum.IMAGE => AssetType.image,
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Describes where the information of this asset came from:
|
||||
|
|
|
@ -96,25 +96,16 @@ class StoreValue {
|
|||
int? intValue;
|
||||
String? strValue;
|
||||
|
||||
T? _extract<T>(StoreKey<T> key) {
|
||||
switch (key.type) {
|
||||
case const (int):
|
||||
return intValue as T?;
|
||||
case const (bool):
|
||||
return intValue == null ? null : (intValue! == 1) as T;
|
||||
case const (DateTime):
|
||||
return intValue == null
|
||||
T? _extract<T>(StoreKey<T> key) => switch (key.type) {
|
||||
const (int) => intValue as T?,
|
||||
const (bool) => intValue == null ? null : (intValue! == 1) as T,
|
||||
const (DateTime) => intValue == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T;
|
||||
case const (String):
|
||||
return strValue as T?;
|
||||
default:
|
||||
if (key.fromDb != null) {
|
||||
return key.fromDb!.call(Store._db, intValue!);
|
||||
}
|
||||
}
|
||||
throw TypeError();
|
||||
}
|
||||
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T,
|
||||
const (String) => strValue as T?,
|
||||
_ when key.fromDb != null => key.fromDb!.call(Store._db, intValue!),
|
||||
_ => throw TypeError(),
|
||||
};
|
||||
|
||||
static Future<StoreValue> _of<T>(T? value, StoreKey<T> key) async {
|
||||
int? i;
|
||||
|
|
|
@ -149,56 +149,33 @@ enum AvatarColorEnum {
|
|||
}
|
||||
|
||||
extension AvatarColorEnumHelper on UserAvatarColor {
|
||||
AvatarColorEnum toAvatarColor() {
|
||||
switch (this) {
|
||||
case UserAvatarColor.primary:
|
||||
return AvatarColorEnum.primary;
|
||||
case UserAvatarColor.pink:
|
||||
return AvatarColorEnum.pink;
|
||||
case UserAvatarColor.red:
|
||||
return AvatarColorEnum.red;
|
||||
case UserAvatarColor.yellow:
|
||||
return AvatarColorEnum.yellow;
|
||||
case UserAvatarColor.blue:
|
||||
return AvatarColorEnum.blue;
|
||||
case UserAvatarColor.green:
|
||||
return AvatarColorEnum.green;
|
||||
case UserAvatarColor.purple:
|
||||
return AvatarColorEnum.purple;
|
||||
case UserAvatarColor.orange:
|
||||
return AvatarColorEnum.orange;
|
||||
case UserAvatarColor.gray:
|
||||
return AvatarColorEnum.gray;
|
||||
case UserAvatarColor.amber:
|
||||
return AvatarColorEnum.amber;
|
||||
}
|
||||
return AvatarColorEnum.primary;
|
||||
}
|
||||
AvatarColorEnum toAvatarColor() => switch (this) {
|
||||
UserAvatarColor.primary => AvatarColorEnum.primary,
|
||||
UserAvatarColor.pink => AvatarColorEnum.pink,
|
||||
UserAvatarColor.red => AvatarColorEnum.red,
|
||||
UserAvatarColor.yellow => AvatarColorEnum.yellow,
|
||||
UserAvatarColor.blue => AvatarColorEnum.blue,
|
||||
UserAvatarColor.green => AvatarColorEnum.green,
|
||||
UserAvatarColor.purple => AvatarColorEnum.purple,
|
||||
UserAvatarColor.orange => AvatarColorEnum.orange,
|
||||
UserAvatarColor.gray => AvatarColorEnum.gray,
|
||||
UserAvatarColor.amber => AvatarColorEnum.amber,
|
||||
_ => AvatarColorEnum.primary,
|
||||
};
|
||||
}
|
||||
|
||||
extension AvatarColorToColorHelper on AvatarColorEnum {
|
||||
Color toColor([bool isDarkTheme = false]) {
|
||||
switch (this) {
|
||||
case AvatarColorEnum.primary:
|
||||
return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF);
|
||||
case AvatarColorEnum.pink:
|
||||
return const Color.fromARGB(255, 244, 114, 182);
|
||||
case AvatarColorEnum.red:
|
||||
return const Color.fromARGB(255, 239, 68, 68);
|
||||
case AvatarColorEnum.yellow:
|
||||
return const Color.fromARGB(255, 234, 179, 8);
|
||||
case AvatarColorEnum.blue:
|
||||
return const Color.fromARGB(255, 59, 130, 246);
|
||||
case AvatarColorEnum.green:
|
||||
return const Color.fromARGB(255, 22, 163, 74);
|
||||
case AvatarColorEnum.purple:
|
||||
return const Color.fromARGB(255, 147, 51, 234);
|
||||
case AvatarColorEnum.orange:
|
||||
return const Color.fromARGB(255, 234, 88, 12);
|
||||
case AvatarColorEnum.gray:
|
||||
return const Color.fromARGB(255, 75, 85, 99);
|
||||
case AvatarColorEnum.amber:
|
||||
return const Color.fromARGB(255, 217, 119, 6);
|
||||
}
|
||||
}
|
||||
Color toColor([bool isDarkTheme = false]) => switch (this) {
|
||||
AvatarColorEnum.primary =>
|
||||
isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF),
|
||||
AvatarColorEnum.pink => const Color.fromARGB(255, 244, 114, 182),
|
||||
AvatarColorEnum.red => const Color.fromARGB(255, 239, 68, 68),
|
||||
AvatarColorEnum.yellow => const Color.fromARGB(255, 234, 179, 8),
|
||||
AvatarColorEnum.blue => const Color.fromARGB(255, 59, 130, 246),
|
||||
AvatarColorEnum.green => const Color.fromARGB(255, 22, 163, 74),
|
||||
AvatarColorEnum.purple => const Color.fromARGB(255, 147, 51, 234),
|
||||
AvatarColorEnum.orange => const Color.fromARGB(255, 234, 88, 12),
|
||||
AvatarColorEnum.gray => const Color.fromARGB(255, 75, 85, 99),
|
||||
AvatarColorEnum.amber => const Color.fromARGB(255, 217, 119, 6),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -235,6 +235,7 @@ class SearchDisplayFilters {
|
|||
class SearchFilter {
|
||||
String? context;
|
||||
String? filename;
|
||||
String? description;
|
||||
Set<Person> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
|
@ -247,6 +248,7 @@ class SearchFilter {
|
|||
SearchFilter({
|
||||
this.context,
|
||||
this.filename,
|
||||
this.description,
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
|
@ -258,6 +260,7 @@ class SearchFilter {
|
|||
bool get isEmpty {
|
||||
return (context == null || (context != null && context!.isEmpty)) &&
|
||||
(filename == null || (filename!.isEmpty)) &&
|
||||
(description == null || (description!.isEmpty)) &&
|
||||
people.isEmpty &&
|
||||
location.country == null &&
|
||||
location.state == null &&
|
||||
|
@ -275,6 +278,7 @@ class SearchFilter {
|
|||
SearchFilter copyWith({
|
||||
String? context,
|
||||
String? filename,
|
||||
String? description,
|
||||
Set<Person>? people,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
|
@ -285,6 +289,7 @@ class SearchFilter {
|
|||
return SearchFilter(
|
||||
context: context ?? this.context,
|
||||
filename: filename ?? this.filename,
|
||||
description: description ?? this.description,
|
||||
people: people ?? this.people,
|
||||
location: location ?? this.location,
|
||||
camera: camera ?? this.camera,
|
||||
|
@ -296,7 +301,7 @@ class SearchFilter {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -305,6 +310,7 @@ class SearchFilter {
|
|||
|
||||
return other.context == context &&
|
||||
other.filename == filename &&
|
||||
other.description == description &&
|
||||
other.people == people &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
|
@ -317,6 +323,7 @@ class SearchFilter {
|
|||
int get hashCode {
|
||||
return context.hashCode ^
|
||||
filename.hashCode ^
|
||||
description.hashCode ^
|
||||
people.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
|
|
|
@ -36,32 +36,19 @@ class AppLogPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget buildLeadingIcon(LogLevel level) {
|
||||
switch (level) {
|
||||
case LogLevel.INFO:
|
||||
return colorStatusIndicator(context.primaryColor);
|
||||
case LogLevel.SEVERE:
|
||||
return colorStatusIndicator(Colors.redAccent);
|
||||
Widget buildLeadingIcon(LogLevel level) => switch (level) {
|
||||
LogLevel.INFO => colorStatusIndicator(context.primaryColor),
|
||||
LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent),
|
||||
LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent),
|
||||
_ => colorStatusIndicator(Colors.grey),
|
||||
};
|
||||
|
||||
case LogLevel.WARNING:
|
||||
return colorStatusIndicator(Colors.orangeAccent);
|
||||
default:
|
||||
return colorStatusIndicator(Colors.grey);
|
||||
}
|
||||
}
|
||||
|
||||
getTileColor(LogLevel level) {
|
||||
switch (level) {
|
||||
case LogLevel.INFO:
|
||||
return Colors.transparent;
|
||||
case LogLevel.SEVERE:
|
||||
return Colors.redAccent.withOpacity(0.25);
|
||||
case LogLevel.WARNING:
|
||||
return Colors.orangeAccent.withOpacity(0.25);
|
||||
default:
|
||||
return context.primaryColor.withOpacity(0.1);
|
||||
}
|
||||
}
|
||||
Color getTileColor(LogLevel level) => switch (level) {
|
||||
LogLevel.INFO => Colors.transparent,
|
||||
LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25),
|
||||
LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25),
|
||||
_ => context.primaryColor.withOpacity(0.1),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
|
@ -74,26 +74,16 @@ class DownloadTaskTile extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final progressPercent = (progress * 100).round();
|
||||
|
||||
getStatusText() {
|
||||
switch (status) {
|
||||
case TaskStatus.running:
|
||||
return 'downloading'.tr();
|
||||
case TaskStatus.complete:
|
||||
return 'download_complete'.tr();
|
||||
case TaskStatus.failed:
|
||||
return 'download_failed'.tr();
|
||||
case TaskStatus.canceled:
|
||||
return 'download_canceled'.tr();
|
||||
case TaskStatus.paused:
|
||||
return 'download_paused'.tr();
|
||||
case TaskStatus.enqueued:
|
||||
return 'download_enqueue'.tr();
|
||||
case TaskStatus.notFound:
|
||||
return 'download_notfound'.tr();
|
||||
case TaskStatus.waitingToRetry:
|
||||
return 'download_waiting_to_retry'.tr();
|
||||
}
|
||||
}
|
||||
String getStatusText() => switch (status) {
|
||||
TaskStatus.running => 'downloading'.tr(),
|
||||
TaskStatus.complete => 'download_complete'.tr(),
|
||||
TaskStatus.failed => 'download_failed'.tr(),
|
||||
TaskStatus.canceled => 'download_canceled'.tr(),
|
||||
TaskStatus.paused => 'download_paused'.tr(),
|
||||
TaskStatus.enqueued => 'download_enqueue'.tr(),
|
||||
TaskStatus.notFound => 'download_notfound'.tr(),
|
||||
TaskStatus.waitingToRetry => 'download_waiting_to_retry'.tr(),
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
key: const ValueKey('download_progress'),
|
||||
|
|
|
@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
|||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
final Widget image;
|
||||
|
||||
const NativeVideoViewerPage({
|
||||
|
@ -33,6 +34,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||
required this.asset,
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -317,12 +319,16 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
// Delay the video playback to avoid a stutter in the swipe animation
|
||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
||||
// the playbackDelayFactor can be used for this
|
||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
||||
// playback, but other resolutions failed thus far
|
||||
Timer(
|
||||
Platform.isIOS
|
||||
? const Duration(milliseconds: 300)
|
||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||
: imageToVideo
|
||||
? const Duration(milliseconds: 200)
|
||||
: const Duration(milliseconds: 400), () {
|
||||
? Duration(milliseconds: 200 * playbackDelayFactor)
|
||||
: Duration(milliseconds: 400 * playbackDelayFactor), () {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,8 @@ class TabControllerPage extends HookConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isRefreshingAssets = ref.watch(assetProvider);
|
||||
final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider);
|
||||
final isScreenLandscape =
|
||||
MediaQuery.orientationOf(context) == Orientation.landscape;
|
||||
|
||||
Widget buildIcon({required Widget icon, required bool isProcessing}) {
|
||||
if (!isProcessing) return icon;
|
||||
|
@ -45,7 +47,7 @@ class TabControllerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
onNavigationSelected(TabsRouter router, int index) {
|
||||
void onNavigationSelected(TabsRouter router, int index) {
|
||||
// On Photos page menu tapped
|
||||
if (router.activeIndex == 0 && index == 0) {
|
||||
scrollToTopNotifierProvider.scrollToTop();
|
||||
|
@ -61,62 +63,82 @@ class TabControllerPage extends HookConsumerWidget {
|
|||
ref.read(tabProvider.notifier).state = TabEnum.values[index];
|
||||
}
|
||||
|
||||
bottomNavigationBar(TabsRouter tabsRouter) {
|
||||
final navigationDestinations = [
|
||||
NavigationDestination(
|
||||
label: 'tab_controller_nav_photos'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingAssets,
|
||||
icon: Icon(
|
||||
Icons.photo_library,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'tab_controller_nav_search'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.search_rounded,
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.search,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'albums'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingRemoteAlbums,
|
||||
icon: Icon(
|
||||
Icons.photo_album_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'library'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.space_dashboard_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingAssets,
|
||||
icon: Icon(
|
||||
Icons.space_dashboard_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
Widget bottomNavigationBar(TabsRouter tabsRouter) {
|
||||
return NavigationBar(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: (index) =>
|
||||
onNavigationSelected(tabsRouter, index),
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
label: 'tab_controller_nav_photos'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingAssets,
|
||||
icon: Icon(
|
||||
Icons.photo_library,
|
||||
color: context.primaryColor,
|
||||
destinations: navigationDestinations,
|
||||
);
|
||||
}
|
||||
|
||||
Widget navigationRail(TabsRouter tabsRouter) {
|
||||
return NavigationRail(
|
||||
destinations: navigationDestinations
|
||||
.map(
|
||||
(e) => NavigationRailDestination(
|
||||
icon: e.icon,
|
||||
label: Text(e.label),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'tab_controller_nav_search'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.search_rounded,
|
||||
),
|
||||
selectedIcon: Icon(
|
||||
Icons.search,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'albums'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.photo_album_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingRemoteAlbums,
|
||||
icon: Icon(
|
||||
Icons.photo_album_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'library'.tr(),
|
||||
icon: const Icon(
|
||||
Icons.space_dashboard_outlined,
|
||||
),
|
||||
selectedIcon: buildIcon(
|
||||
isProcessing: isRefreshingAssets,
|
||||
icon: Icon(
|
||||
Icons.space_dashboard_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
onDestinationSelected: (index) =>
|
||||
onNavigationSelected(tabsRouter, index),
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
groupAlignment: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -135,17 +157,27 @@ class TabControllerPage extends HookConsumerWidget {
|
|||
),
|
||||
builder: (context, child) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
final heroedChild = HeroControllerScope(
|
||||
controller: HeroController(),
|
||||
child: child,
|
||||
);
|
||||
return PopScope(
|
||||
canPop: tabsRouter.activeIndex == 0,
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
!didPop ? tabsRouter.setActiveIndex(0) : null,
|
||||
child: Scaffold(
|
||||
body: HeroControllerScope(
|
||||
controller: HeroController(),
|
||||
child: child,
|
||||
),
|
||||
bottomNavigationBar:
|
||||
multiselectEnabled ? null : bottomNavigationBar(tabsRouter),
|
||||
body: isScreenLandscape
|
||||
? Row(
|
||||
children: [
|
||||
navigationRail(tabsRouter),
|
||||
const VerticalDivider(),
|
||||
Expanded(child: heroedChild),
|
||||
],
|
||||
)
|
||||
: heroedChild,
|
||||
bottomNavigationBar: multiselectEnabled || isScreenLandscape
|
||||
? null
|
||||
: bottomNavigationBar(tabsRouter),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -174,33 +174,19 @@ class _AspectRatioButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
IconData iconData;
|
||||
switch (label) {
|
||||
case 'Free':
|
||||
iconData = Icons.crop_free_rounded;
|
||||
break;
|
||||
case '1:1':
|
||||
iconData = Icons.crop_square_rounded;
|
||||
break;
|
||||
case '16:9':
|
||||
iconData = Icons.crop_16_9_rounded;
|
||||
break;
|
||||
case '3:2':
|
||||
iconData = Icons.crop_3_2_rounded;
|
||||
break;
|
||||
case '7:5':
|
||||
iconData = Icons.crop_7_5_rounded;
|
||||
break;
|
||||
default:
|
||||
iconData = Icons.crop_free_rounded;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
iconData,
|
||||
switch (label) {
|
||||
'Free' => Icons.crop_free_rounded,
|
||||
'1:1' => Icons.crop_square_rounded,
|
||||
'16:9' => Icons.crop_16_9_rounded,
|
||||
'3:2' => Icons.crop_3_2_rounded,
|
||||
'7:5' => Icons.crop_7_5_rounded,
|
||||
_ => Icons.crop_free_rounded,
|
||||
},
|
||||
color: aspectRatio.value == ratio
|
||||
? context.primaryColor
|
||||
: context.themeData.iconTheme.color,
|
||||
|
|
|
@ -136,23 +136,16 @@ class PermissionOnboardingPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
final Widget child;
|
||||
switch (permission) {
|
||||
case PermissionStatus.limited:
|
||||
child = buildPermissionLimited();
|
||||
break;
|
||||
case PermissionStatus.denied:
|
||||
child = buildRequestPermission();
|
||||
break;
|
||||
case PermissionStatus.granted:
|
||||
case PermissionStatus.provisional:
|
||||
child = buildPermissionGranted();
|
||||
break;
|
||||
case PermissionStatus.restricted:
|
||||
case PermissionStatus.permanentlyDenied:
|
||||
child = buildPermissionDenied();
|
||||
break;
|
||||
}
|
||||
final Widget child = switch (permission) {
|
||||
PermissionStatus.limited => buildPermissionLimited(),
|
||||
PermissionStatus.denied => buildRequestPermission(),
|
||||
PermissionStatus.granted ||
|
||||
PermissionStatus.provisional =>
|
||||
buildPermissionGranted(),
|
||||
PermissionStatus.restricted ||
|
||||
PermissionStatus.permanentlyDenied =>
|
||||
buildPermissionDenied()
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
|
|
|
@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
|
||||
|
@ -13,6 +15,8 @@ import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
|||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||
|
||||
@RoutePage()
|
||||
|
||||
/// Expects [currentAssetProvider] to be set before navigating to this page
|
||||
class MemoryPage extends HookConsumerWidget {
|
||||
final List<Memory> memories;
|
||||
final int memoryIndex;
|
||||
|
@ -32,6 +36,7 @@ class MemoryPage extends HookConsumerWidget {
|
|||
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
|
||||
);
|
||||
const bgColor = Colors.black;
|
||||
final currentAsset = useState<Asset?>(null);
|
||||
|
||||
/// The list of all of the asset page controllers
|
||||
final memoryAssetPageControllers =
|
||||
|
@ -135,6 +140,14 @@ class MemoryPage extends HookConsumerWidget {
|
|||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
currentAssetPage.value = otherIndex;
|
||||
updateProgressText();
|
||||
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = asset;
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// And then precache the next asset
|
||||
|
@ -274,6 +287,16 @@ class MemoryPage extends HookConsumerWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (currentAsset.value != null &&
|
||||
currentAsset.value!.isVideo)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
right: 32,
|
||||
child: Icon(
|
||||
Icons.videocam_outlined,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
@ -31,7 +32,8 @@ class SearchPage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isContextualSearch = useState(true);
|
||||
final textSearchType = useState<TextSearchType>(TextSearchType.context);
|
||||
final searchHintText = useState<String>('contextual_search'.tr());
|
||||
final textSearchController = useTextEditingController();
|
||||
final filter = useState<SearchFilter>(
|
||||
SearchFilter(
|
||||
|
@ -478,37 +480,148 @@ class SearchPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
handleTextSubmitted(String value) {
|
||||
if (isContextualSearch.value) {
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: '',
|
||||
context: value,
|
||||
);
|
||||
} else {
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: value,
|
||||
context: '',
|
||||
);
|
||||
switch (textSearchType.value) {
|
||||
case TextSearchType.context:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: '',
|
||||
context: value,
|
||||
description: '',
|
||||
);
|
||||
|
||||
break;
|
||||
case TextSearchType.filename:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: value,
|
||||
context: '',
|
||||
description: '',
|
||||
);
|
||||
|
||||
break;
|
||||
case TextSearchType.description:
|
||||
filter.value = filter.value.copyWith(
|
||||
filename: '',
|
||||
context: '',
|
||||
description: value,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
search();
|
||||
}
|
||||
|
||||
IconData getSearchPrefixIcon() {
|
||||
switch (textSearchType.value) {
|
||||
case TextSearchType.context:
|
||||
return Icons.image_search_rounded;
|
||||
case TextSearchType.filename:
|
||||
return Icons.abc_rounded;
|
||||
case TextSearchType.description:
|
||||
return Icons.text_snippet_outlined;
|
||||
default:
|
||||
return Icons.search_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: true,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 14.0),
|
||||
child: IconButton(
|
||||
key: const Key('contextual_search_button'),
|
||||
icon: isContextualSearch.value
|
||||
? const Icon(Icons.abc_rounded)
|
||||
: const Icon(Icons.image_search_rounded),
|
||||
onPressed: () {
|
||||
isContextualSearch.value = !isContextualSearch.value;
|
||||
textSearchController.clear();
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: MenuAnchor(
|
||||
style: MenuStyle(
|
||||
elevation: const WidgetStatePropertyAll(1),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.all(4),
|
||||
),
|
||||
),
|
||||
builder: (
|
||||
BuildContext context,
|
||||
MenuController controller,
|
||||
Widget? child,
|
||||
) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
tooltip: 'Show text search menu',
|
||||
);
|
||||
},
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image_search_rounded),
|
||||
title: Text(
|
||||
'search_filter_contextual'.tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = 'contextual_search'.tr();
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.abc_rounded),
|
||||
title: Text(
|
||||
'search_filter_filename'.tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.filename
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.filename,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.filename;
|
||||
searchHintText.value = 'filename_search'.tr();
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.text_snippet_outlined),
|
||||
title: Text(
|
||||
'search_filter_description'.tr(),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
textSearchType.value == TextSearchType.description
|
||||
? context.colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected:
|
||||
textSearchType.value == TextSearchType.description,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.description;
|
||||
searchHintText.value = 'description_search'.tr();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -539,12 +652,10 @@ class SearchPage extends HookConsumerWidget {
|
|||
prefixIcon: prefilter != null
|
||||
? null
|
||||
: Icon(
|
||||
Icons.search_rounded,
|
||||
getSearchPrefixIcon(),
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
hintText: isContextualSearch.value
|
||||
? 'contextual_search'.tr()
|
||||
: 'filename_search'.tr(),
|
||||
hintText: searchHintText.value,
|
||||
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.themeData.colorScheme.onSurfaceSecondary,
|
||||
),
|
||||
|
|
|
@ -18,15 +18,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
|
|||
@override
|
||||
Future<int> count({bool? local}) {
|
||||
final baseQuery = db.albums.where();
|
||||
final QueryBuilder<Album, Album, QAfterWhereClause> query;
|
||||
switch (local) {
|
||||
case null:
|
||||
query = baseQuery.noOp();
|
||||
case true:
|
||||
query = baseQuery.localIdIsNotNull();
|
||||
case false:
|
||||
query = baseQuery.remoteIdIsNotNull();
|
||||
}
|
||||
final QueryBuilder<Album, Album, QAfterWhereClause> query = switch (local) {
|
||||
null => baseQuery.noOp(),
|
||||
true => baseQuery.localIdIsNotNull(),
|
||||
false => baseQuery.remoteIdIsNotNull(),
|
||||
};
|
||||
return query.count();
|
||||
}
|
||||
|
||||
|
@ -91,15 +87,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
|
|||
if (ownerId != null) {
|
||||
filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId));
|
||||
}
|
||||
final QueryBuilder<Album, Album, QAfterSortBy> query;
|
||||
switch (sortBy) {
|
||||
case null:
|
||||
query = filterQuery.noOp();
|
||||
case AlbumSort.remoteId:
|
||||
query = filterQuery.sortByRemoteId();
|
||||
case AlbumSort.localId:
|
||||
query = filterQuery.sortByLocalId();
|
||||
}
|
||||
final QueryBuilder<Album, Album, QAfterSortBy> query = switch (sortBy) {
|
||||
null => filterQuery.noOp(),
|
||||
AlbumSort.remoteId => filterQuery.sortByRemoteId(),
|
||||
AlbumSort.localId => filterQuery.sortByLocalId(),
|
||||
};
|
||||
return query.findAll();
|
||||
}
|
||||
|
||||
|
@ -150,14 +142,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
|
|||
query = query.owner(
|
||||
(q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
|
||||
);
|
||||
break;
|
||||
case QuickFilterMode.myAlbums:
|
||||
query = query.owner(
|
||||
(q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
|
||||
);
|
||||
break;
|
||||
case QuickFilterMode.all:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
@ -38,27 +38,20 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||
query = query.ownerIdEqualTo(ownerId);
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case null:
|
||||
break;
|
||||
case AssetState.local:
|
||||
query = query.remoteIdIsNull();
|
||||
case AssetState.remote:
|
||||
query = query.localIdIsNull();
|
||||
case AssetState.merged:
|
||||
query = query.localIdIsNotNull().remoteIdIsNotNull();
|
||||
if (state != null) {
|
||||
query = switch (state) {
|
||||
AssetState.local => query.remoteIdIsNull(),
|
||||
AssetState.remote => query.localIdIsNull(),
|
||||
AssetState.merged => query.localIdIsNotNull().remoteIdIsNotNull(),
|
||||
};
|
||||
}
|
||||
|
||||
final QueryBuilder<Asset, Asset, QAfterSortBy> sortedQuery;
|
||||
|
||||
switch (sortBy) {
|
||||
case null:
|
||||
sortedQuery = query.noOp();
|
||||
case AssetSort.checksum:
|
||||
sortedQuery = query.sortByChecksum();
|
||||
case AssetSort.ownerIdChecksum:
|
||||
sortedQuery = query.sortByOwnerId().thenByChecksum();
|
||||
}
|
||||
final QueryBuilder<Asset, Asset, QAfterSortBy> sortedQuery =
|
||||
switch (sortBy) {
|
||||
null => query.noOp(),
|
||||
AssetSort.checksum => query.sortByChecksum(),
|
||||
AssetSort.ownerIdChecksum => query.sortByOwnerId().thenByChecksum(),
|
||||
};
|
||||
|
||||
return sortedQuery.findAll();
|
||||
}
|
||||
|
@ -84,16 +77,12 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||
AssetState? state,
|
||||
) {
|
||||
final query = db.assets.remote(ids).filter();
|
||||
switch (state) {
|
||||
case null:
|
||||
return query.noOp();
|
||||
case AssetState.local:
|
||||
return query.remoteIdIsNull();
|
||||
case AssetState.remote:
|
||||
return query.localIdIsNull();
|
||||
case AssetState.merged:
|
||||
return query.localIdIsNotEmpty().remoteIdIsNotNull();
|
||||
}
|
||||
return switch (state) {
|
||||
null => query.noOp(),
|
||||
AssetState.local => query.remoteIdIsNull(),
|
||||
AssetState.remote => query.localIdIsNull(),
|
||||
AssetState.merged => query.localIdIsNotEmpty().remoteIdIsNotNull(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -104,39 +93,32 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||
int? limit,
|
||||
}) {
|
||||
final baseQuery = db.assets.where();
|
||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery;
|
||||
switch (state) {
|
||||
case null:
|
||||
filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp();
|
||||
case AssetState.local:
|
||||
filteredQuery = baseQuery
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.localIdIsNotNull()
|
||||
.ownerIdEqualTo(ownerId);
|
||||
case AssetState.remote:
|
||||
filteredQuery = baseQuery
|
||||
.localIdIsNull()
|
||||
.filter()
|
||||
.remoteIdIsNotNull()
|
||||
.ownerIdEqualTo(ownerId);
|
||||
case AssetState.merged:
|
||||
filteredQuery = baseQuery
|
||||
.ownerIdEqualToAnyChecksum(ownerId)
|
||||
.filter()
|
||||
.remoteIdIsNotNull()
|
||||
.localIdIsNotNull();
|
||||
}
|
||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery =
|
||||
switch (state) {
|
||||
null => baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(),
|
||||
AssetState.local => baseQuery
|
||||
.remoteIdIsNull()
|
||||
.filter()
|
||||
.localIdIsNotNull()
|
||||
.ownerIdEqualTo(ownerId),
|
||||
AssetState.remote => baseQuery
|
||||
.localIdIsNull()
|
||||
.filter()
|
||||
.remoteIdIsNotNull()
|
||||
.ownerIdEqualTo(ownerId),
|
||||
AssetState.merged => baseQuery
|
||||
.ownerIdEqualToAnyChecksum(ownerId)
|
||||
.filter()
|
||||
.remoteIdIsNotNull()
|
||||
.localIdIsNotNull(),
|
||||
};
|
||||
|
||||
final QueryBuilder<Asset, Asset, QAfterSortBy> query;
|
||||
switch (sortBy) {
|
||||
case null:
|
||||
query = filteredQuery.noOp();
|
||||
case AssetSort.checksum:
|
||||
query = filteredQuery.sortByChecksum();
|
||||
case AssetSort.ownerIdChecksum:
|
||||
query = filteredQuery.sortByOwnerId().thenByChecksum();
|
||||
}
|
||||
final QueryBuilder<Asset, Asset, QAfterSortBy> query = switch (sortBy) {
|
||||
null => filteredQuery.noOp(),
|
||||
AssetSort.checksum => filteredQuery.sortByChecksum(),
|
||||
AssetSort.ownerIdChecksum =>
|
||||
filteredQuery.sortByOwnerId().thenByChecksum(),
|
||||
};
|
||||
|
||||
return limit == null ? query.findAll() : query.limit(limit).findAll();
|
||||
}
|
||||
|
@ -155,17 +137,16 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||
int limit = 100,
|
||||
}) {
|
||||
final baseQuery = db.assets.where();
|
||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
|
||||
switch (state) {
|
||||
case null:
|
||||
query = baseQuery.noOp();
|
||||
case AssetState.local:
|
||||
query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull();
|
||||
case AssetState.remote:
|
||||
query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull();
|
||||
case AssetState.merged:
|
||||
query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull();
|
||||
}
|
||||
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query =
|
||||
switch (state) {
|
||||
null => baseQuery.noOp(),
|
||||
AssetState.local =>
|
||||
baseQuery.remoteIdIsNull().filter().localIdIsNotNull(),
|
||||
AssetState.remote =>
|
||||
baseQuery.localIdIsNull().filter().remoteIdIsNotNull(),
|
||||
AssetState.merged =>
|
||||
baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(),
|
||||
};
|
||||
return _getMatchesImpl(query, ownerId, assets, limit);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,13 +14,11 @@ class BackupRepository extends DatabaseRepository implements IBackupRepository {
|
|||
@override
|
||||
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
|
||||
final baseQuery = db.backupAlbums.where();
|
||||
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query;
|
||||
switch (sort) {
|
||||
case null:
|
||||
query = baseQuery.noOp();
|
||||
case BackupAlbumSort.id:
|
||||
query = baseQuery.sortById();
|
||||
}
|
||||
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query =
|
||||
switch (sort) {
|
||||
null => baseQuery.noOp(),
|
||||
BackupAlbumSort.id => baseQuery.sortById(),
|
||||
};
|
||||
return query.findAll();
|
||||
}
|
||||
|
||||
|
|
|
@ -25,13 +25,10 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
|
|||
final int userId = Store.get(StoreKey.currentUser).isarId;
|
||||
final QueryBuilder<User, User, QAfterWhereClause> afterWhere =
|
||||
self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId);
|
||||
final QueryBuilder<User, User, QAfterSortBy> query;
|
||||
switch (sortBy) {
|
||||
case null:
|
||||
query = afterWhere.noOp();
|
||||
case UserSort.id:
|
||||
query = afterWhere.sortById();
|
||||
}
|
||||
final QueryBuilder<User, User, QAfterSortBy> query = switch (sortBy) {
|
||||
null => afterWhere.noOp(),
|
||||
UserSort.id => afterWhere.sortById(),
|
||||
};
|
||||
return query.findAll();
|
||||
}
|
||||
|
||||
|
|
|
@ -519,18 +519,12 @@ class BackupService {
|
|||
return responseBody.containsKey('id') ? responseBody['id'] : null;
|
||||
}
|
||||
|
||||
String _getAssetType(AssetType assetType) {
|
||||
switch (assetType) {
|
||||
case AssetType.audio:
|
||||
return "AUDIO";
|
||||
case AssetType.image:
|
||||
return "IMAGE";
|
||||
case AssetType.video:
|
||||
return "VIDEO";
|
||||
case AssetType.other:
|
||||
return "OTHER";
|
||||
}
|
||||
}
|
||||
String _getAssetType(AssetType assetType) => switch (assetType) {
|
||||
AssetType.audio => "AUDIO",
|
||||
AssetType.image => "IMAGE",
|
||||
AssetType.video => "VIDEO",
|
||||
AssetType.other => "OTHER",
|
||||
};
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
|
|
|
@ -84,6 +84,10 @@ class SearchService {
|
|||
? filter.filename
|
||||
: null,
|
||||
country: filter.location.country,
|
||||
description:
|
||||
filter.description != null && filter.description!.isNotEmpty
|
||||
? filter.description
|
||||
: null,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
|
|
|
@ -2,13 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
/// Returns the suitable [IconData] to represent an [Asset]s storage location
|
||||
IconData storageIcon(Asset asset) {
|
||||
switch (asset.storage) {
|
||||
case AssetState.local:
|
||||
return Icons.cloud_off_outlined;
|
||||
case AssetState.remote:
|
||||
return Icons.cloud_outlined;
|
||||
case AssetState.merged:
|
||||
return Icons.cloud_done_outlined;
|
||||
}
|
||||
}
|
||||
IconData storageIcon(Asset asset) => switch (asset.storage) {
|
||||
AssetState.local => Icons.cloud_off_outlined,
|
||||
AssetState.remote => Icons.cloud_outlined,
|
||||
AssetState.merged => Icons.cloud_done_outlined,
|
||||
};
|
||||
|
|
|
@ -204,6 +204,13 @@ class ThumbnailImage extends ConsumerWidget {
|
|||
storageIcon(asset),
|
||||
color: Colors.white.withOpacity(.8),
|
||||
size: 16,
|
||||
shadows: [
|
||||
Shadow(
|
||||
blurRadius: 5.0,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
offset: const Offset(0.0, 0.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (asset.isFavorite)
|
||||
|
|
|
@ -15,36 +15,26 @@ class ImmichToast {
|
|||
final fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
Color getColor(ToastType type, BuildContext context) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return context.primaryColor;
|
||||
case ToastType.success:
|
||||
return const Color.fromARGB(255, 78, 140, 124);
|
||||
case ToastType.error:
|
||||
return const Color.fromARGB(255, 220, 48, 85);
|
||||
}
|
||||
}
|
||||
Color getColor(ToastType type, BuildContext context) => switch (type) {
|
||||
ToastType.info => context.primaryColor,
|
||||
ToastType.success => const Color.fromARGB(255, 78, 140, 124),
|
||||
ToastType.error => const Color.fromARGB(255, 220, 48, 85),
|
||||
};
|
||||
|
||||
Icon getIcon(ToastType type) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: context.primaryColor,
|
||||
);
|
||||
case ToastType.success:
|
||||
return const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
);
|
||||
case ToastType.error:
|
||||
return const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Color.fromARGB(255, 240, 162, 156),
|
||||
);
|
||||
}
|
||||
}
|
||||
Icon getIcon(ToastType type) => switch (type) {
|
||||
ToastType.info => Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
ToastType.success => const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
),
|
||||
ToastType.error => const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Color.fromARGB(255, 240, 162, 156),
|
||||
),
|
||||
};
|
||||
|
||||
fToast.showToast(
|
||||
child: Container(
|
||||
|
|
|
@ -168,7 +168,7 @@ class LoginForm extends HookConsumerWidget {
|
|||
populateTestLoginInfo1() {
|
||||
emailController.text = 'testuser@email.com';
|
||||
passwordController.text = 'password';
|
||||
serverEndpointController.text = 'http://10.1.15.216:3000/api';
|
||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
}
|
||||
|
||||
login() async {
|
||||
|
|
|
@ -75,11 +75,12 @@ class MemoryCard extends StatelessWidget {
|
|||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
showControls: false,
|
||||
playbackDelayFactor: 2,
|
||||
image: ImmichImage(
|
||||
asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
fit: fit,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
|
@ -33,6 +35,13 @@ class MemoryLane extends HookConsumerWidget {
|
|||
),
|
||||
onTap: (memoryIndex) {
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||
final asset = memories[memoryIndex].assets[0];
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
context.pushRoute(
|
||||
MemoryRoute(
|
||||
memories: memories,
|
||||
|
|
|
@ -590,21 +590,15 @@ class _PhotoViewState extends State<PhotoView>
|
|||
}
|
||||
|
||||
/// The default [ScaleStateCycle]
|
||||
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
|
||||
switch (actual) {
|
||||
case PhotoViewScaleState.initial:
|
||||
return PhotoViewScaleState.covering;
|
||||
case PhotoViewScaleState.covering:
|
||||
return PhotoViewScaleState.originalSize;
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return PhotoViewScaleState.initial;
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return PhotoViewScaleState.initial;
|
||||
default:
|
||||
return PhotoViewScaleState.initial;
|
||||
}
|
||||
}
|
||||
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) =>
|
||||
switch (actual) {
|
||||
PhotoViewScaleState.initial => PhotoViewScaleState.covering,
|
||||
PhotoViewScaleState.covering => PhotoViewScaleState.originalSize,
|
||||
PhotoViewScaleState.originalSize => PhotoViewScaleState.initial,
|
||||
PhotoViewScaleState.zoomedIn ||
|
||||
PhotoViewScaleState.zoomedOut =>
|
||||
PhotoViewScaleState.initial,
|
||||
};
|
||||
|
||||
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
|
||||
/// It is used internally to walk in the "doubletap gesture cycle".
|
||||
|
|
|
@ -9,25 +9,20 @@ double getScaleForScaleState(
|
|||
PhotoViewScaleState scaleState,
|
||||
ScaleBoundaries scaleBoundaries,
|
||||
) {
|
||||
switch (scaleState) {
|
||||
case PhotoViewScaleState.initial:
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
|
||||
case PhotoViewScaleState.covering:
|
||||
return _clampSize(
|
||||
return switch (scaleState) {
|
||||
PhotoViewScaleState.initial ||
|
||||
PhotoViewScaleState.zoomedIn ||
|
||||
PhotoViewScaleState.zoomedOut =>
|
||||
_clampSize(scaleBoundaries.initialScale, scaleBoundaries),
|
||||
PhotoViewScaleState.covering => _clampSize(
|
||||
_scaleForCovering(
|
||||
scaleBoundaries.outerSize,
|
||||
scaleBoundaries.childSize,
|
||||
),
|
||||
scaleBoundaries,
|
||||
);
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return _clampSize(1.0, scaleBoundaries);
|
||||
// Will never be reached
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
),
|
||||
PhotoViewScaleState.originalSize => _clampSize(1.0, scaleBoundaries),
|
||||
};
|
||||
}
|
||||
|
||||
/// Internal class to wraps custom scale boundaries (min, max and initial)
|
||||
|
|
|
@ -220,23 +220,20 @@ class NetworkStatusIcon extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildIcon(BuildContext context) {
|
||||
switch (status) {
|
||||
case AuxCheckStatus.loading:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: context.primaryColor,
|
||||
strokeWidth: 2,
|
||||
key: const ValueKey('loading'),
|
||||
Widget _buildIcon(BuildContext context) => switch (status) {
|
||||
AuxCheckStatus.loading => Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
color: context.primaryColor,
|
||||
strokeWidth: 2,
|
||||
key: const ValueKey('loading'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case AuxCheckStatus.valid:
|
||||
return enabled
|
||||
AuxCheckStatus.valid => enabled
|
||||
? const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
|
@ -246,9 +243,8 @@ class NetworkStatusIcon extends StatelessWidget {
|
|||
Icons.check_circle_rounded,
|
||||
color: context.colorScheme.onSurface.withAlpha(100),
|
||||
key: const ValueKey('success'),
|
||||
);
|
||||
case AuxCheckStatus.error:
|
||||
return enabled
|
||||
),
|
||||
AuxCheckStatus.error => enabled
|
||||
? const Icon(
|
||||
Icons.error_rounded,
|
||||
color: Colors.red,
|
||||
|
@ -258,9 +254,7 @@ class NetworkStatusIcon extends StatelessWidget {
|
|||
Icons.error_rounded,
|
||||
color: Colors.grey,
|
||||
key: ValueKey('error'),
|
||||
);
|
||||
default:
|
||||
return const Icon(Icons.circle_outlined, key: ValueKey('unknown'));
|
||||
}
|
||||
}
|
||||
),
|
||||
_ => const Icon(Icons.circle_outlined, key: ValueKey('unknown')),
|
||||
};
|
||||
}
|
||||
|
|
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
|
@ -18,6 +18,7 @@ class MetadataSearchDto {
|
|||
this.country,
|
||||
this.createdAfter,
|
||||
this.createdBefore,
|
||||
this.description,
|
||||
this.deviceAssetId,
|
||||
this.deviceId,
|
||||
this.encodedVideoPath,
|
||||
|
@ -85,6 +86,14 @@ class MetadataSearchDto {
|
|||
///
|
||||
DateTime? createdBefore;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
String? description;
|
||||
|
||||
///
|
||||
/// 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
|
||||
|
@ -343,6 +352,7 @@ class MetadataSearchDto {
|
|||
other.country == country &&
|
||||
other.createdAfter == createdAfter &&
|
||||
other.createdBefore == createdBefore &&
|
||||
other.description == description &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
|
@ -389,6 +399,7 @@ class MetadataSearchDto {
|
|||
(country == null ? 0 : country!.hashCode) +
|
||||
(createdAfter == null ? 0 : createdAfter!.hashCode) +
|
||||
(createdBefore == null ? 0 : createdBefore!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(deviceAssetId == null ? 0 : deviceAssetId!.hashCode) +
|
||||
(deviceId == null ? 0 : deviceId!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
|
@ -428,7 +439,7 @@ class MetadataSearchDto {
|
|||
(withStacked == null ? 0 : withStacked!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -457,6 +468,11 @@ class MetadataSearchDto {
|
|||
} else {
|
||||
// json[r'createdBefore'] = null;
|
||||
}
|
||||
if (this.description != null) {
|
||||
json[r'description'] = this.description;
|
||||
} else {
|
||||
// json[r'description'] = null;
|
||||
}
|
||||
if (this.deviceAssetId != null) {
|
||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||
} else {
|
||||
|
@ -643,6 +659,7 @@ class MetadataSearchDto {
|
|||
country: mapValueOfType<String>(json, r'country'),
|
||||
createdAfter: mapDateTime(json, r'createdAfter', r''),
|
||||
createdBefore: mapDateTime(json, r'createdBefore', r''),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId'),
|
||||
deviceId: mapValueOfType<String>(json, r'deviceId'),
|
||||
encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'),
|
||||
|
|
30
mobile/openapi/lib/model/people_update_item.dart
generated
30
mobile/openapi/lib/model/people_update_item.dart
generated
|
@ -14,8 +14,10 @@ class PeopleUpdateItem {
|
|||
/// Returns a new [PeopleUpdateItem] instance.
|
||||
PeopleUpdateItem({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.featureFaceAssetId,
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
this.isHidden,
|
||||
this.name,
|
||||
});
|
||||
|
@ -23,6 +25,8 @@ class PeopleUpdateItem {
|
|||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
/// Asset is used to get the feature face thumbnail.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -35,6 +39,14 @@ class PeopleUpdateItem {
|
|||
/// Person id.
|
||||
String id;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
/// Person visibility
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -56,8 +68,10 @@ class PeopleUpdateItem {
|
|||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.featureFaceAssetId == featureFaceAssetId &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name;
|
||||
|
||||
|
@ -65,13 +79,15 @@ class PeopleUpdateItem {
|
|||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden == null ? 0 : isHidden!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -80,12 +96,22 @@ class PeopleUpdateItem {
|
|||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.featureFaceAssetId != null) {
|
||||
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
|
||||
} else {
|
||||
// json[r'featureFaceAssetId'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
if (this.isHidden != null) {
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
} else {
|
||||
|
@ -109,8 +135,10 @@ class PeopleUpdateItem {
|
|||
|
||||
return PeopleUpdateItem(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden'),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
);
|
||||
|
|
30
mobile/openapi/lib/model/person_create_dto.dart
generated
30
mobile/openapi/lib/model/person_create_dto.dart
generated
|
@ -14,6 +14,8 @@ class PersonCreateDto {
|
|||
/// Returns a new [PersonCreateDto] instance.
|
||||
PersonCreateDto({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.isFavorite,
|
||||
this.isHidden,
|
||||
this.name,
|
||||
});
|
||||
|
@ -21,6 +23,16 @@ class PersonCreateDto {
|
|||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
/// Person visibility
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -42,6 +54,8 @@ class PersonCreateDto {
|
|||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name;
|
||||
|
||||
|
@ -49,11 +63,13 @@ class PersonCreateDto {
|
|||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden == null ? 0 : isHidden!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -62,6 +78,16 @@ class PersonCreateDto {
|
|||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
if (this.isHidden != null) {
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
} else {
|
||||
|
@ -85,6 +111,8 @@ class PersonCreateDto {
|
|||
|
||||
return PersonCreateDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden'),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
);
|
||||
|
|
38
mobile/openapi/lib/model/person_response_dto.dart
generated
38
mobile/openapi/lib/model/person_response_dto.dart
generated
|
@ -14,7 +14,9 @@ class PersonResponseDto {
|
|||
/// Returns a new [PersonResponseDto] instance.
|
||||
PersonResponseDto({
|
||||
required this.birthDate,
|
||||
this.color,
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
required this.isHidden,
|
||||
required this.name,
|
||||
required this.thumbnailPath,
|
||||
|
@ -23,8 +25,26 @@ class PersonResponseDto {
|
|||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
String? color;
|
||||
|
||||
String id;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
bool isHidden;
|
||||
|
||||
String name;
|
||||
|
@ -43,7 +63,9 @@ class PersonResponseDto {
|
|||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name &&
|
||||
other.thumbnailPath == thumbnailPath &&
|
||||
|
@ -53,14 +75,16 @@ class PersonResponseDto {
|
|||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden.hashCode) +
|
||||
(name.hashCode) +
|
||||
(thumbnailPath.hashCode) +
|
||||
(updatedAt == null ? 0 : updatedAt!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -68,8 +92,18 @@ class PersonResponseDto {
|
|||
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
json[r'name'] = this.name;
|
||||
json[r'thumbnailPath'] = this.thumbnailPath;
|
||||
|
@ -91,7 +125,9 @@ class PersonResponseDto {
|
|||
|
||||
return PersonResponseDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
|
||||
|
|
30
mobile/openapi/lib/model/person_update_dto.dart
generated
30
mobile/openapi/lib/model/person_update_dto.dart
generated
|
@ -14,7 +14,9 @@ class PersonUpdateDto {
|
|||
/// Returns a new [PersonUpdateDto] instance.
|
||||
PersonUpdateDto({
|
||||
this.birthDate,
|
||||
this.color,
|
||||
this.featureFaceAssetId,
|
||||
this.isFavorite,
|
||||
this.isHidden,
|
||||
this.name,
|
||||
});
|
||||
|
@ -22,6 +24,8 @@ class PersonUpdateDto {
|
|||
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
|
||||
DateTime? birthDate;
|
||||
|
||||
String? color;
|
||||
|
||||
/// Asset is used to get the feature face thumbnail.
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -31,6 +35,14 @@ class PersonUpdateDto {
|
|||
///
|
||||
String? featureFaceAssetId;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
/// Person visibility
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
|
@ -52,7 +64,9 @@ class PersonUpdateDto {
|
|||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
other.featureFaceAssetId == featureFaceAssetId &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name;
|
||||
|
||||
|
@ -60,12 +74,14 @@ class PersonUpdateDto {
|
|||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden == null ? 0 : isHidden!.hashCode) +
|
||||
(name == null ? 0 : name!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]';
|
||||
String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -74,11 +90,21 @@ class PersonUpdateDto {
|
|||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
if (this.featureFaceAssetId != null) {
|
||||
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
|
||||
} else {
|
||||
// json[r'featureFaceAssetId'] = null;
|
||||
}
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
if (this.isHidden != null) {
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
} else {
|
||||
|
@ -102,7 +128,9 @@ class PersonUpdateDto {
|
|||
|
||||
return PersonUpdateDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden'),
|
||||
name: mapValueOfType<String>(json, r'name'),
|
||||
);
|
||||
|
|
|
@ -14,8 +14,10 @@ class PersonWithFacesResponseDto {
|
|||
/// Returns a new [PersonWithFacesResponseDto] instance.
|
||||
PersonWithFacesResponseDto({
|
||||
required this.birthDate,
|
||||
this.color,
|
||||
this.faces = const [],
|
||||
required this.id,
|
||||
this.isFavorite,
|
||||
required this.isHidden,
|
||||
required this.name,
|
||||
required this.thumbnailPath,
|
||||
|
@ -24,10 +26,28 @@ class PersonWithFacesResponseDto {
|
|||
|
||||
DateTime? birthDate;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
String? color;
|
||||
|
||||
List<AssetFaceWithoutPersonResponseDto> faces;
|
||||
|
||||
String id;
|
||||
|
||||
/// This property was added in v1.126.0
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? isFavorite;
|
||||
|
||||
bool isHidden;
|
||||
|
||||
String name;
|
||||
|
@ -46,8 +66,10 @@ class PersonWithFacesResponseDto {
|
|||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.color == color &&
|
||||
_deepEquality.equals(other.faces, faces) &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name &&
|
||||
other.thumbnailPath == thumbnailPath &&
|
||||
|
@ -57,15 +79,17 @@ class PersonWithFacesResponseDto {
|
|||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(color == null ? 0 : color!.hashCode) +
|
||||
(faces.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(isHidden.hashCode) +
|
||||
(name.hashCode) +
|
||||
(thumbnailPath.hashCode) +
|
||||
(updatedAt == null ? 0 : updatedAt!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
@ -73,9 +97,19 @@ class PersonWithFacesResponseDto {
|
|||
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
if (this.color != null) {
|
||||
json[r'color'] = this.color;
|
||||
} else {
|
||||
// json[r'color'] = null;
|
||||
}
|
||||
json[r'faces'] = this.faces;
|
||||
json[r'id'] = this.id;
|
||||
if (this.isFavorite != null) {
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
} else {
|
||||
// json[r'isFavorite'] = null;
|
||||
}
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
json[r'name'] = this.name;
|
||||
json[r'thumbnailPath'] = this.thumbnailPath;
|
||||
|
@ -97,8 +131,10 @@ class PersonWithFacesResponseDto {
|
|||
|
||||
return PersonWithFacesResponseDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', r''),
|
||||
color: mapValueOfType<String>(json, r'color'),
|
||||
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
|
||||
|
|
|
@ -9949,6 +9949,9 @@
|
|||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceAssetId": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -10283,6 +10286,10 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"type": "string"
|
||||
|
@ -10291,6 +10298,9 @@
|
|||
"description": "Person id.",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10396,6 +10406,13 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10414,9 +10431,17 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -10460,10 +10485,17 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"featureFaceAssetId": {
|
||||
"description": "Asset is used to get the feature face thumbnail.",
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"description": "Person visibility",
|
||||
"type": "boolean"
|
||||
|
@ -10482,6 +10514,10 @@
|
|||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"color": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "string"
|
||||
},
|
||||
"faces": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto"
|
||||
|
@ -10491,6 +10527,10 @@
|
|||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isFavorite": {
|
||||
"description": "This property was added in v1.126.0",
|
||||
"type": "boolean"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -12591,7 +12631,6 @@
|
|||
"properties": {
|
||||
"color": {
|
||||
"nullable": true,
|
||||
"pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -213,8 +213,12 @@ export type AssetFaceWithoutPersonResponseDto = {
|
|||
};
|
||||
export type PersonWithFacesResponseDto = {
|
||||
birthDate: string | null;
|
||||
/** This property was added in v1.126.0 */
|
||||
color?: string;
|
||||
faces: AssetFaceWithoutPersonResponseDto[];
|
||||
id: string;
|
||||
/** This property was added in v1.126.0 */
|
||||
isFavorite?: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
thumbnailPath: string;
|
||||
|
@ -491,7 +495,11 @@ export type DuplicateResponseDto = {
|
|||
};
|
||||
export type PersonResponseDto = {
|
||||
birthDate: string | null;
|
||||
/** This property was added in v1.126.0 */
|
||||
color?: string;
|
||||
id: string;
|
||||
/** This property was added in v1.126.0 */
|
||||
isFavorite?: boolean;
|
||||
isHidden: boolean;
|
||||
name: string;
|
||||
thumbnailPath: string;
|
||||
|
@ -689,6 +697,8 @@ export type PersonCreateDto = {
|
|||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
@ -698,10 +708,12 @@ export type PeopleUpdateItem = {
|
|||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
/** Asset is used to get the feature face thumbnail. */
|
||||
featureFaceAssetId?: string;
|
||||
/** Person id. */
|
||||
id: string;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
@ -714,8 +726,10 @@ export type PersonUpdateDto = {
|
|||
/** Person date of birth.
|
||||
Note: the mobile app cannot currently set the birth date to null. */
|
||||
birthDate?: string | null;
|
||||
color?: string | null;
|
||||
/** Asset is used to get the feature face thumbnail. */
|
||||
featureFaceAssetId?: string;
|
||||
isFavorite?: boolean;
|
||||
/** Person visibility */
|
||||
isHidden?: boolean;
|
||||
/** Person name. */
|
||||
|
@ -769,6 +783,7 @@ export type MetadataSearchDto = {
|
|||
country?: string | null;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
description?: string;
|
||||
deviceAssetId?: string;
|
||||
deviceId?: string;
|
||||
encodedVideoPath?: string;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20250123@sha256:04eba5cd87d61bc3d20a3915b2302f04d08fbc329c55ee0cde103c502f59f412 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20250204@sha256:8b203f19f4d5cf4619b60ee5f50d6d4b5ea3745747f5e5170d1b7404ebeb0792 AS dev
|
||||
|
||||
RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
|
@ -42,7 +42,7 @@ RUN npm run build
|
|||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20250123@sha256:591739983913f82672d8191258f3a1a24c123db0d619ff91fca8fef431ee1338
|
||||
FROM ghcr.io/immich-app/base-server-prod:20250204@sha256:2af3da713d5ab3ccca23b216b747557ea6016117e72deac101e35069ccaf9b5e
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
|
1872
server/package-lock.json
generated
1872
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -35,16 +35,16 @@
|
|||
"email:dev": "email dev -p 3050 --dir src/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^11.0.0",
|
||||
"@nestjs/common": "^10.2.2",
|
||||
"@nestjs/core": "^10.2.2",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/platform-express": "^10.2.2",
|
||||
"@nestjs/platform-socket.io": "^10.2.2",
|
||||
"@nestjs/platform-express": "^11.0.4",
|
||||
"@nestjs/platform-socket.io": "^11.0.4",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/swagger": "^8.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@nestjs/swagger": "^11.0.2",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.24.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.57.0",
|
||||
|
@ -72,9 +72,9 @@
|
|||
"kysely-postgres-js": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"nest-commander": "^3.11.1",
|
||||
"nestjs-cls": "^4.3.0",
|
||||
"nestjs-kysely": "^1.0.0",
|
||||
"nest-commander": "^3.16.0",
|
||||
"nestjs-cls": "^5.0.0",
|
||||
"nestjs-kysely": "^1.1.0",
|
||||
"nestjs-otel": "^6.0.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"openid-client": "^5.4.3",
|
||||
|
@ -97,9 +97,9 @@
|
|||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@nestjs/cli": "^10.1.16",
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/testing": "^10.2.2",
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.4",
|
||||
"@swc/core": "^1.4.14",
|
||||
"@testcontainers/postgresql": "^10.2.1",
|
||||
"@types/archiver": "^6.0.0",
|
||||
|
|
18
server/src/db.d.ts
vendored
18
server/src/db.d.ts
vendored
|
@ -276,9 +276,11 @@ export interface Partners {
|
|||
|
||||
export interface Person {
|
||||
birthDate: Timestamp | null;
|
||||
color: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
faceAssetId: string | null;
|
||||
id: Generated<string>;
|
||||
isFavorite: Generated<boolean>;
|
||||
isHidden: Generated<boolean>;
|
||||
name: Generated<string>;
|
||||
ownerId: string;
|
||||
|
@ -327,11 +329,6 @@ export interface SocketIoAttachments {
|
|||
payload: Buffer | null;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface SystemMetadata {
|
||||
key: string;
|
||||
value: Json;
|
||||
|
@ -357,6 +354,15 @@ export interface TagsClosure {
|
|||
id_descendant: string;
|
||||
}
|
||||
|
||||
export interface TypeormMetadata {
|
||||
database: string | null;
|
||||
name: string | null;
|
||||
schema: string | null;
|
||||
table: string | null;
|
||||
type: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
key: string;
|
||||
userId: string;
|
||||
|
@ -431,11 +437,11 @@ export interface DB {
|
|||
shared_links: SharedLinks;
|
||||
smart_search: SmartSearch;
|
||||
socket_io_attachments: SocketIoAttachments;
|
||||
system_config: SystemConfig;
|
||||
system_metadata: SystemMetadata;
|
||||
tag_asset: TagAsset;
|
||||
tags: Tags;
|
||||
tags_closure: TagsClosure;
|
||||
typeorm_metadata: TypeormMetadata;
|
||||
user_metadata: UserMetadata;
|
||||
users: Users;
|
||||
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;
|
||||
|
|
|
@ -118,7 +118,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
|||
id: entity.id,
|
||||
type: entity.type,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash?.toString('base64') ?? null,
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
localDateTime: entity.localDateTime,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
|
|
|
@ -7,7 +7,14 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
import {
|
||||
IsDateStringFormat,
|
||||
MaxDateString,
|
||||
Optional,
|
||||
ValidateBoolean,
|
||||
ValidateHexColor,
|
||||
ValidateUUID,
|
||||
} from 'src/validation';
|
||||
|
||||
export class PersonCreateDto {
|
||||
/**
|
||||
|
@ -32,6 +39,13 @@ export class PersonCreateDto {
|
|||
*/
|
||||
@ValidateBoolean({ optional: true })
|
||||
isHidden?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ValidateHexColor()
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class PersonUpdateDto extends PersonCreateDto {
|
||||
|
@ -97,6 +111,10 @@ export class PersonResponseDto {
|
|||
isHidden!: boolean;
|
||||
@PropertyLifecycle({ addedAt: 'v1.107.0' })
|
||||
updatedAt?: Date;
|
||||
@PropertyLifecycle({ addedAt: 'v1.126.0' })
|
||||
isFavorite?: boolean;
|
||||
@PropertyLifecycle({ addedAt: 'v1.126.0' })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
|
@ -170,6 +188,8 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
|||
birthDate: person.birthDate,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
color: person.color ?? undefined,
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -133,6 +133,11 @@ export class MetadataSearchDto extends RandomSearchDto {
|
|||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TagCreateDto {
|
||||
@IsString()
|
||||
|
@ -18,9 +17,8 @@ export class TagCreateDto {
|
|||
}
|
||||
|
||||
export class TagUpdateDto {
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsHexColor()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ValidateHexColor()
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
|
|
|
@ -396,6 +396,11 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||
sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
|
||||
),
|
||||
)
|
||||
.$if(!!options.description, (qb) =>
|
||||
qb
|
||||
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
||||
.where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`),
|
||||
)
|
||||
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||
.$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))
|
||||
|
|
|
@ -49,4 +49,10 @@ export class PersonEntity {
|
|||
|
||||
@Column({ default: false })
|
||||
isHidden!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
isFavorite!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, default: null })
|
||||
color?: string | null;
|
||||
}
|
||||
|
|
|
@ -101,6 +101,7 @@ export interface SearchExifOptions {
|
|||
make?: string | null;
|
||||
model?: string | null;
|
||||
state?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchEmbeddingOptions {
|
||||
|
|
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
14
server/src/migrations/1734879118272-AddIsFavoritePerson.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddIsFavoritePerson1734879118272 implements MigrationInterface {
|
||||
name = 'AddIsFavoritePerson1734879118272'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`);
|
||||
}
|
||||
|
||||
}
|
14
server/src/migrations/1738889177573-AddPersonColor.ts
Normal file
14
server/src/migrations/1738889177573-AddPersonColor.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPersonColor1738889177573 implements MigrationInterface {
|
||||
name = 'AddPersonColor1738889177573'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -47,6 +47,8 @@ with
|
|||
and "asset_files"."type" = $6
|
||||
)
|
||||
and "assets"."deletedAt" is null
|
||||
order by
|
||||
(assets."localDateTime" at time zone 'UTC')::date desc
|
||||
limit
|
||||
$7
|
||||
) as "a" on true
|
||||
|
|
22
server/src/queries/map.repository.sql
Normal file
22
server/src/queries/map.repository.sql
Normal file
|
@ -0,0 +1,22 @@
|
|||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- MapRepository.getMapMarkers
|
||||
select
|
||||
"id",
|
||||
"exif"."latitude" as "lat",
|
||||
"exif"."longitude" as "lon",
|
||||
"exif"."city",
|
||||
"exif"."state",
|
||||
"exif"."country"
|
||||
from
|
||||
"assets"
|
||||
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||
and "exif"."latitude" is not null
|
||||
and "exif"."longitude" is not null
|
||||
left join "albums_assets_assets" on "assets"."id" = "albums_assets_assets"."assetsId"
|
||||
where
|
||||
"isVisible" = $1
|
||||
and "deletedAt" is null
|
||||
and "ownerId" in ($2)
|
||||
order by
|
||||
"fileCreatedAt" desc
|
|
@ -100,13 +100,14 @@ order by
|
|||
-- SharedLinkRepository.getAll
|
||||
select distinct
|
||||
on ("shared_links"."createdAt") "shared_links".*,
|
||||
to_json("assets") as "assets",
|
||||
to_json("album") as "album"
|
||||
from
|
||||
"shared_links"
|
||||
left join "shared_link__asset" on "shared_link__asset"."sharedLinksId" = "shared_links"."id"
|
||||
left join lateral (
|
||||
select
|
||||
"assets".*
|
||||
json_agg("assets") as "assets"
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
|
|
|
@ -121,6 +121,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
),
|
||||
)
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc')
|
||||
.limit(20)
|
||||
.as('a'),
|
||||
(join) => join.onTrue(),
|
||||
|
@ -466,8 +467,8 @@ export class AssetRepository implements IAssetRepository {
|
|||
)
|
||||
.$if(property === WithoutProperty.EXIF, (qb) =>
|
||||
qb
|
||||
.innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
|
||||
.where('job_status.metadataExtractedAt', 'is', null)
|
||||
.leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
|
||||
.where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)]))
|
||||
.where('assets.isVisible', '=', true),
|
||||
)
|
||||
.$if(property === WithoutProperty.FACES, (qb) =>
|
||||
|
|
|
@ -227,6 +227,7 @@ const getEnv = (): EnvData => {
|
|||
}
|
||||
|
||||
const driverOptions = {
|
||||
...parsedOptions,
|
||||
onnotice: (notice: Notice) => {
|
||||
if (notice['severity'] !== 'NOTICE') {
|
||||
console.warn('Postgres notice:', notice);
|
||||
|
@ -247,7 +248,9 @@ const getEnv = (): EnvData => {
|
|||
serialize: (value: number) => value.toString(),
|
||||
},
|
||||
},
|
||||
...parsedOptions,
|
||||
connection: {
|
||||
TimeZone: 'UTC',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
|
|||
import readLine from 'node:readline';
|
||||
import { citiesFile } from 'src/constants';
|
||||
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
|
||||
import { AssetEntity, withExif } from 'src/entities/asset.entity';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
|
||||
import { LogLevel, SystemMetadataKey } from 'src/enum';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
|
@ -76,17 +76,19 @@ export class MapRepository {
|
|||
this.logger.log('Geodata import completed');
|
||||
}
|
||||
|
||||
async getMapMarkers(
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
options: MapMarkerSearchOptions = {},
|
||||
): Promise<MapMarker[]> {
|
||||
@GenerateSql({ params: [[DummyValue.UUID], []] })
|
||||
getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) {
|
||||
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
|
||||
|
||||
const assets = (await this.db
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.$call(withExif)
|
||||
.select('id')
|
||||
.innerJoin('exif', (builder) =>
|
||||
builder
|
||||
.onRef('assets.id', '=', 'exif.assetId')
|
||||
.on('exif.latitude', 'is not', null)
|
||||
.on('exif.longitude', 'is not', null),
|
||||
)
|
||||
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
|
||||
.leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId'))
|
||||
.where('isVisible', '=', true)
|
||||
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
|
||||
|
@ -94,32 +96,21 @@ export class MapRepository {
|
|||
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
|
||||
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('exif.latitude', 'is not', null)
|
||||
.where('exif.longitude', 'is not', null)
|
||||
.where((eb) => {
|
||||
const ors: Expression<SqlBool>[] = [];
|
||||
.where((builder) => {
|
||||
const expression: Expression<SqlBool>[] = [];
|
||||
|
||||
if (ownerIds.length > 0) {
|
||||
ors.push(eb('ownerId', 'in', ownerIds));
|
||||
expression.push(builder('ownerId', 'in', ownerIds));
|
||||
}
|
||||
|
||||
if (albumIds.length > 0) {
|
||||
ors.push(eb('albums_assets_assets.albumsId', 'in', albumIds));
|
||||
expression.push(builder('albums_assets_assets.albumsId', 'in', albumIds));
|
||||
}
|
||||
|
||||
return eb.or(ors);
|
||||
return builder.or(expression);
|
||||
})
|
||||
.orderBy('fileCreatedAt', 'desc')
|
||||
.execute()) as any as AssetEntity[];
|
||||
|
||||
return assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
city: asset.exifInfo!.city,
|
||||
state: asset.exifInfo!.state,
|
||||
country: asset.exifInfo!.country,
|
||||
}));
|
||||
.execute() as Promise<MapMarker[]>;
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
|
|
|
@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository {
|
|||
)
|
||||
.where('person.ownerId', '=', userId)
|
||||
.orderBy('person.isHidden', 'asc')
|
||||
.orderBy('person.isFavorite', 'desc')
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
|
|
|
@ -103,12 +103,13 @@ export class SharedLinkRepository implements ISharedLinkRepository {
|
|||
(eb) =>
|
||||
eb
|
||||
.selectFrom('assets')
|
||||
.select((eb) => eb.fn.jsonAgg('assets').as('assets'))
|
||||
.whereRef('assets.id', '=', 'shared_link__asset.assetsId')
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.selectAll('assets')
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('assets').as('assets'))
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
|
|
|
@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = {
|
|||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
updatedAt: expect.any(Date),
|
||||
isFavorite: false,
|
||||
};
|
||||
|
||||
const statistics = { assets: 3 };
|
||||
|
@ -116,6 +117,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
],
|
||||
|
@ -125,6 +127,35 @@ describe(PersonService.name, () => {
|
|||
withHidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all visible people and favorites should be first in the array', async () => {
|
||||
personMock.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.isFavorite, personStub.withName],
|
||||
hasNextPage: false,
|
||||
});
|
||||
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
{
|
||||
id: 'person-4',
|
||||
name: personStub.isFavorite.name,
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
responseDto,
|
||||
],
|
||||
});
|
||||
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
|
@ -227,6 +258,7 @@ describe(PersonService.name, () => {
|
|||
birthDate: '1976-06-30',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
|
||||
|
@ -245,6 +277,16 @@ describe(PersonService.name, () => {
|
|||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should update a person favorite status', async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
|
||||
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
|
@ -313,7 +355,7 @@ describe(PersonService.name, () => {
|
|||
sut.reassignFaces(authStub.admin, personStub.noName.id, {
|
||||
data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
|
||||
}),
|
||||
).resolves.toEqual([personStub.noName]);
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
|
@ -375,6 +417,7 @@ describe(PersonService.name, () => {
|
|||
).resolves.toEqual({
|
||||
birthDate: personStub.noName.birthDate,
|
||||
isHidden: personStub.noName.isHidden,
|
||||
isFavorite: personStub.noName.isFavorite,
|
||||
id: personStub.noName.id,
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
|
@ -405,7 +448,7 @@ describe(PersonService.name, () => {
|
|||
it('should create a new person', async () => {
|
||||
personMock.create.mockResolvedValue(personStub.primaryPerson);
|
||||
|
||||
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
|
||||
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
|
||||
|
||||
expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
|
||||
});
|
||||
|
|
|
@ -104,7 +104,7 @@ export class PersonService extends BaseService {
|
|||
await this.personRepository.reassignFace(face.id, personId);
|
||||
}
|
||||
|
||||
result.push(person);
|
||||
result.push(mapPerson(person));
|
||||
}
|
||||
if (changeFeaturePhoto.length > 0) {
|
||||
// Remove duplicates
|
||||
|
@ -178,19 +178,23 @@ export class PersonService extends BaseService {
|
|||
});
|
||||
}
|
||||
|
||||
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
return this.personRepository.create({
|
||||
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
const person = await this.personRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
isFavorite: dto.isFavorite,
|
||||
color: dto.color,
|
||||
});
|
||||
|
||||
return mapPerson(person);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
|
||||
// TODO: set by faceId directly
|
||||
let faceId: string | undefined = undefined;
|
||||
if (assetId) {
|
||||
|
@ -203,7 +207,15 @@ export class PersonService extends BaseService {
|
|||
faceId = face.id;
|
||||
}
|
||||
|
||||
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
|
||||
const person = await this.personRepository.update({
|
||||
id,
|
||||
faceAssetId: faceId,
|
||||
name,
|
||||
birthDate,
|
||||
isHidden,
|
||||
isFavorite,
|
||||
color,
|
||||
});
|
||||
|
||||
if (assetId) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
|
@ -221,6 +233,7 @@ export class PersonService extends BaseService {
|
|||
name: person.name,
|
||||
birthDate: person.birthDate,
|
||||
featureFaceAssetId: person.featureFaceAssetId,
|
||||
isFavorite: person.isFavorite,
|
||||
});
|
||||
results.push({ id: person.id, success: true });
|
||||
} catch (error: Error | any) {
|
||||
|
|
|
@ -31,6 +31,8 @@ describe(SearchService.name, () => {
|
|||
it('should pass options to search', async () => {
|
||||
const { name } = personStub.withName;
|
||||
|
||||
personMock.getByName.mockResolvedValue([]);
|
||||
|
||||
await sut.searchPerson(authStub.user1, { name, withHidden: false });
|
||||
|
||||
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
mapPlaces,
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
|
@ -12,7 +13,6 @@ import {
|
|||
SearchSuggestionRequestDto,
|
||||
SearchSuggestionType,
|
||||
SmartSearchDto,
|
||||
mapPlaces,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
|
@ -24,7 +24,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
|
|||
@Injectable()
|
||||
export class SearchService extends BaseService {
|
||||
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
|
||||
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
|
||||
const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
|
||||
return people.map((person) => mapPerson(person));
|
||||
}
|
||||
|
||||
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
IsArray,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsHexColor,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
|
@ -97,6 +98,15 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option
|
|||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export const ValidateHexColor = () => {
|
||||
const decorators = [
|
||||
IsHexColor(),
|
||||
Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)),
|
||||
];
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
||||
export const ValidateUUID = (options?: UUIDOptions) => {
|
||||
const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options };
|
||||
|
|
24
server/test/fixtures/person.stub.ts
vendored
24
server/test/fixtures/person.stub.ts
vendored
|
@ -15,6 +15,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
hidden: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -29,6 +30,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
}),
|
||||
withName: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -43,6 +45,7 @@ export const personStub = {
|
|||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
withBirthDate: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -57,6 +60,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
noThumbnail: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -71,6 +75,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
newThumbnail: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -85,6 +90,7 @@ export const personStub = {
|
|||
faceAssetId: 'asset-id',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
primaryPerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-1',
|
||||
|
@ -99,6 +105,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
mergePerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-2',
|
||||
|
@ -113,6 +120,7 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
randomPerson: Object.freeze<PersonEntity>({
|
||||
id: 'person-3',
|
||||
|
@ -127,5 +135,21 @@ export const personStub = {
|
|||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
}),
|
||||
isFavorite: Object.freeze<PersonEntity>({
|
||||
id: 'person-4',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
ownerId: userStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
|
|||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mousedown', handleClick, true);
|
||||
node.addEventListener('keydown', handleKey, false);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mousedown', handleClick, true);
|
||||
node.removeEventListener('keydown', handleKey, false);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -26,12 +26,14 @@
|
|||
controlable?: boolean;
|
||||
hideTextOnSmallScreen?: boolean;
|
||||
title?: string | undefined;
|
||||
position?: 'bottom-left' | 'bottom-right';
|
||||
onSelect: (option: T) => void;
|
||||
onClickOutside?: () => void;
|
||||
render?: (item: T) => string | RenderedOption;
|
||||
}
|
||||
|
||||
let {
|
||||
position = 'bottom-left',
|
||||
class: className = '',
|
||||
options,
|
||||
selectedOption = $bindable(options[0]),
|
||||
|
@ -76,9 +78,24 @@
|
|||
};
|
||||
|
||||
let renderedSelectedOption = $derived(renderOption(selectedOption));
|
||||
|
||||
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
|
||||
switch (position) {
|
||||
case 'bottom-left': {
|
||||
return 'left-0';
|
||||
}
|
||||
case 'bottom-right': {
|
||||
return 'right-0';
|
||||
}
|
||||
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}>
|
||||
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
|
||||
<!-- BUTTON TITLE -->
|
||||
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
|
||||
{#if renderedSelectedOption?.icon}
|
||||
|
@ -91,7 +108,9 @@
|
|||
{#if showMenu}
|
||||
<div
|
||||
transition:fly={{ y: -30, duration: 250 }}
|
||||
class="text-sm font-medium fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
|
||||
class="text-sm font-medium absolute z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
|
||||
position,
|
||||
)}"
|
||||
>
|
||||
{#each options as option (option)}
|
||||
{@const renderedOption = renderOption(option)}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiMerge } from '@mdi/js';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
@ -36,6 +37,11 @@
|
|||
[potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]];
|
||||
choosePersonToMerge = false;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
document.querySelector<HTMLElement>('#merge-confirm-button')?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<FullScreenModal title="{$t('merge_people')} - {title}" {onClose}>
|
||||
|
@ -113,7 +119,9 @@
|
|||
</div>
|
||||
|
||||
{#snippet stickyBottom()}
|
||||
<Button fullwidth color="gray" onclick={onReject}>{$t('no')}</Button>
|
||||
<Button fullwidth onclick={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button>
|
||||
<Button fullWidth shape="round" color="secondary" onclick={onReject}>{$t('no')}</Button>
|
||||
<Button id="merge-confirm-button" fullWidth shape="round" onclick={() => onConfirm([personMerge1, personMerge2])}>
|
||||
{$t('yes')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</FullScreenModal>
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
|
@ -8,12 +11,13 @@
|
|||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiHeart,
|
||||
mdiHeartMinusOutline,
|
||||
mdiHeartOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
|
||||
interface Props {
|
||||
person: PersonResponseDto;
|
||||
|
@ -22,9 +26,18 @@
|
|||
onSetBirthDate: () => void;
|
||||
onMergePeople: () => void;
|
||||
onHidePerson: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
}
|
||||
|
||||
let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
|
||||
let {
|
||||
person,
|
||||
preload = false,
|
||||
onChangeName,
|
||||
onSetBirthDate,
|
||||
onMergePeople,
|
||||
onHidePerson,
|
||||
onToggleFavorite,
|
||||
}: Props = $props();
|
||||
|
||||
let showVerticalDots = $state(false);
|
||||
</script>
|
||||
|
@ -51,6 +64,11 @@
|
|||
title={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
{#if person.isFavorite}
|
||||
<div class="absolute bottom-2 left-2 z-10">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if person.name}
|
||||
<span
|
||||
|
@ -76,6 +94,11 @@
|
|||
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
|
||||
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
|
||||
<MenuOption
|
||||
onClick={onToggleFavorite}
|
||||
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
|
||||
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
/>
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
<div class="w-full flex place-content-start">
|
||||
<Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}>
|
||||
<Icon path={mdiArrowLeft} size="18" />
|
||||
<p>{$t('theme')}</p>
|
||||
<p>{$t('privacy')}</p>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex w-full place-content-end">
|
||||
|
|
67
web/src/lib/components/places-page/places-card-group.svelte
Normal file
67
web/src/lib/components/places-page/places-card-group.svelte
Normal file
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
|
||||
interface Props {
|
||||
places: AssetResponseDto[];
|
||||
group?: PlacesGroup | undefined;
|
||||
}
|
||||
|
||||
let { places, group = undefined }: Props = $props();
|
||||
|
||||
let isCollapsed = $derived(!!group && isPlacesGroupCollapsed($placesViewSettings, group.id));
|
||||
let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
|
||||
</script>
|
||||
|
||||
{#if group}
|
||||
<div class="grid">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlacesGroupCollapsing(group.id)}
|
||||
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<Icon
|
||||
path={mdiChevronRight}
|
||||
size="24"
|
||||
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
|
||||
/>
|
||||
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
|
||||
<span class="ml-1.5">({$t('places_count', { values: { count: places.length } })})</span>
|
||||
</button>
|
||||
<hr class="dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !isCollapsed}
|
||||
<div class="flex flex-row flex-wrap gap-4">
|
||||
{#each places as item}
|
||||
{@const city = item.exifInfo?.city}
|
||||
<a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city })}" draggable="false">
|
||||
<div
|
||||
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-[156px] justify-center overflow-hidden rounded-xl brightness-75 filter"
|
||||
>
|
||||
<img
|
||||
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={city}
|
||||
class="object-cover w-[156px] h-[156px]"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
|
||||
>
|
||||
{city}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
93
web/src/lib/components/places-page/places-controls.svelte
Normal file
93
web/src/lib/components/places-page/places-controls.svelte
Normal file
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
import { IconButton } from '@immich/ui';
|
||||
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
} from '@mdi/js';
|
||||
import {
|
||||
type PlacesGroupOptionMetadata,
|
||||
findGroupOptionMetadata,
|
||||
getSelectedPlacesGroupOption,
|
||||
groupOptionsMetadata,
|
||||
expandAllPlacesGroups,
|
||||
collapseAllPlacesGroups,
|
||||
} from '$lib/utils/places-utils';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
placesGroups: string[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
let { placesGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
|
||||
$placesViewSettings.groupBy = id;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
|
||||
});
|
||||
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
|
||||
|
||||
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
|
||||
[PlacesGroupBy.None]: $t('group_no'),
|
||||
[PlacesGroupBy.Country]: $t('group_country'),
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Search Places -->
|
||||
<div class="hidden md:block h-10 xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Group Places -->
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
title={$t('group_places_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: placesGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Countries Groups -->
|
||||
<div class="hidden xl:flex gap-0">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllPlacesGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldMoreHorizontal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapse Countries Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllPlacesGroups(placesGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
icon={mdiUnfoldLessHorizontal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
121
web/src/lib/components/places-page/places-list.svelte
Normal file
121
web/src/lib/components/places-page/places-list.svelte
Normal file
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import PlacesCardGroup from './places-card-group.svelte';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiMapMarkerOff } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { PlacesGroupBy, type PlacesViewSettings } from '$lib/stores/preferences.store';
|
||||
|
||||
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
interface Props {
|
||||
places?: AssetResponseDto[];
|
||||
searchQuery?: string;
|
||||
searchResultCount: number;
|
||||
userSettings: PlacesViewSettings;
|
||||
placesGroupIds?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
places = $bindable([]),
|
||||
searchQuery = '',
|
||||
searchResultCount = $bindable(0),
|
||||
userSettings,
|
||||
placesGroupIds = $bindable([]),
|
||||
}: Props = $props();
|
||||
|
||||
interface PlacesGroupOption {
|
||||
[option: string]: (places: AssetResponseDto[]) => PlacesGroup[];
|
||||
}
|
||||
|
||||
const groupOptions: PlacesGroupOption = {
|
||||
/** No grouping */
|
||||
[PlacesGroupBy.None]: (places): PlacesGroup[] => {
|
||||
return [
|
||||
{
|
||||
id: $t('places'),
|
||||
name: $t('places'),
|
||||
places,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
/** Group by year */
|
||||
[PlacesGroupBy.Country]: (places): PlacesGroup[] => {
|
||||
const unknownCountry = $t('unknown_country');
|
||||
|
||||
const groupedByCountry = groupBy(places, (place) => {
|
||||
return place.exifInfo?.country ?? unknownCountry;
|
||||
});
|
||||
|
||||
const sortedByCountryName = Object.entries(groupedByCountry).sort(([a], [b]) => {
|
||||
// We make sure empty albums stay at the end of the list
|
||||
if (a === unknownCountry) {
|
||||
return 1;
|
||||
} else if (b === unknownCountry) {
|
||||
return -1;
|
||||
} else {
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByCountryName.map(([country, places]) => ({
|
||||
id: country,
|
||||
name: country,
|
||||
places,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
let filteredPlaces: AssetResponseDto[] = $state([]);
|
||||
let groupedPlaces: PlacesGroup[] = $state([]);
|
||||
|
||||
let placesGroupOption: string = $state(PlacesGroupBy.None);
|
||||
|
||||
let hasPlaces = $derived(places.length > 0);
|
||||
|
||||
// Step 1: Filter using the given search query.
|
||||
run(() => {
|
||||
if (searchQuery) {
|
||||
const searchQueryNormalized = normalizeSearchString(searchQuery);
|
||||
|
||||
filteredPlaces = places.filter((place) => {
|
||||
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
|
||||
});
|
||||
} else {
|
||||
filteredPlaces = places;
|
||||
}
|
||||
|
||||
searchResultCount = filteredPlaces.length;
|
||||
});
|
||||
|
||||
// Step 2: Group places.
|
||||
run(() => {
|
||||
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
|
||||
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
|
||||
groupedPlaces = groupFunc(filteredPlaces);
|
||||
|
||||
placesGroupIds = groupedPlaces.map(({ id }) => id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hasPlaces}
|
||||
<!-- Album Cards -->
|
||||
{#if placesGroupOption === PlacesGroupBy.None}
|
||||
<PlacesCardGroup places={groupedPlaces[0].places} />
|
||||
{:else}
|
||||
{#each groupedPlaces as placeGroup (placeGroup.id)}
|
||||
<PlacesCardGroup places={placeGroup.places} group={placeGroup} />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
|
||||
<div class="flex flex-col content-center items-center text-center">
|
||||
<Icon path={mdiMapMarkerOff} size="3.5em" />
|
||||
<p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
export type SearchFilter = {
|
||||
query: string;
|
||||
queryType: 'smart' | 'metadata';
|
||||
queryType: 'smart' | 'metadata' | 'description';
|
||||
personIds: SvelteSet<string>;
|
||||
tagIds: SvelteSet<string>;
|
||||
location: SearchLocationFilter;
|
||||
|
@ -110,6 +110,7 @@
|
|||
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||
query: filter.queryType === 'smart' ? query : undefined,
|
||||
originalFileName: filter.queryType === 'metadata' ? query : undefined,
|
||||
description: filter.queryType === 'description' ? query : undefined,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
interface Props {
|
||||
query: string | undefined;
|
||||
queryType?: 'smart' | 'metadata';
|
||||
queryType?: 'smart' | 'metadata' | 'description';
|
||||
}
|
||||
|
||||
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
|
||||
|
@ -21,6 +21,13 @@
|
|||
bind:group={queryType}
|
||||
value="metadata"
|
||||
/>
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="description-radio"
|
||||
label={$t('description')}
|
||||
bind:group={queryType}
|
||||
value="description"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
@ -34,7 +41,7 @@
|
|||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
/>
|
||||
{:else}
|
||||
{:else if queryType === 'metadata'}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full !mt-1"
|
||||
|
@ -45,4 +52,15 @@
|
|||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
{:else if queryType === 'description'}
|
||||
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full !mt-1"
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -101,6 +101,14 @@ export interface AlbumViewSettings {
|
|||
};
|
||||
}
|
||||
|
||||
export interface PlacesViewSettings {
|
||||
groupBy: string;
|
||||
collapsedGroups: {
|
||||
// Grouping Option => Array<Group ID>
|
||||
[group: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarSettings {
|
||||
people: boolean;
|
||||
sharing: boolean;
|
||||
|
@ -147,6 +155,16 @@ export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settin
|
|||
collapsedGroups: {},
|
||||
});
|
||||
|
||||
export enum PlacesGroupBy {
|
||||
None = 'None',
|
||||
Country = 'Country',
|
||||
}
|
||||
|
||||
export const placesViewSettings = persisted<PlacesViewSettings>('places-view-settings', {
|
||||
groupBy: PlacesGroupBy.None,
|
||||
collapsedGroups: {},
|
||||
});
|
||||
|
||||
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});
|
||||
|
||||
export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue