Compare commits

...

16 Commits

Author SHA1 Message Date
Alex Tran
ad3e92fff0 feat(server): license verification 2024-06-05 01:56:37 -05:00
Lukas
588860455f test(server): check motion asset create arguments (#9826) 2024-06-05 01:55:04 -05:00
renovate[bot]
643309b27f chore(deps): update node.js to 696ae41 (#9986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-04 20:21:28 -04:00
renovate[bot]
e0ec75119f chore(deps): update node.js to db6fa52 (#9984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-04 15:56:12 -04:00
Manic-87
f446bc8caa feat(web): translations (#9854)
* First test

* Added translation using Weblate (French)

* Translated using Weblate (German)

Currently translated at 100.0% (4 of 4 strings)

Translation: immich/web
Translate-URL: http://familie-mach.net/projects/immich/web/de/

* Translated using Weblate (French)

Currently translated at 100.0% (4 of 4 strings)

Translation: immich/web
Translate-URL: http://familie-mach.net/projects/immich/web/fr/

* Further testing

* Further testing

* Translated using Weblate (German)

Currently translated at 100.0% (18 of 18 strings)

Translation: immich/web
Translate-URL: http://familie-mach.net/projects/immich/web/de/

* Further work

* Update string file.

* More strings

* Automatically changed strings

* Add automatically translated german file for testing purposes

* Fix merge-face-selector component

* Make server stats strings uppercase

* Fix uppercase string

* Fix some strings in jobs-panel

* Fix lower and uppercase strings. Add a few additional string. Fix a few unnecessary replacements

* Update german test translations

* Fix typo in locales file

* Change string keys

* Extract more strings

* Extract and replace some more strings

* Update testtranslationfile

* Change translation keys

* Fix rebase errors

* Fix one more rebase error

* Remove german translation file

* Co-authored-by: Daniel Dietzler <danieldietzler@users.noreply.github.com>

* chore: clean up translations

* chore: add new line

* fix formatting

* chore: fixes

* fix: loading and tests

---------

Co-authored-by: root <root@Blacki>
Co-authored-by: admin <admin@example.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2024-06-04 15:53:00 -04:00
renovate[bot]
a2bccf23c9 chore(deps): update dependency @swc/core to v1.5.24 (#9983)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-04 09:39:43 -04:00
dependabot[bot]
6937440772 chore(deps): bump stumpylog/image-cleaner-action from 0.6.0 to 0.7.0 (#9979)
Bumps [stumpylog/image-cleaner-action](https://github.com/stumpylog/image-cleaner-action) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: stumpylog/image-cleaner-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 10:17:58 +00:00
renovate[bot]
69bce6680f fix(deps): update typescript-projects (#9971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-04 10:35:24 +01:00
renovate[bot]
454c995e90 chore(deps): update machine-learning (#9969) 2024-06-03 22:41:10 -04:00
renovate[bot]
bcff21f72b fix(deps): update dependency exiftool-vendored to v26.1.0 (#9972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-03 21:38:03 -04:00
renovate[bot]
47ec6c41ec chore(deps): update node.js to 2d0ce60 (#9970)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-03 21:35:41 -04:00
Snowknight26
471cf5eaf7 fix(web): fix sidebar tooltip pluralization (#9952)
* fix(web): fix sidebar tooltip pluralization

* Rename property

* Remove data-testid attribute

* Fix variable type
2024-06-03 21:35:17 -04:00
Alex
b3ee394fdc feat(web): email notification preference settings (#9934)
* feat(web): email notification preference settings

* Update

* remove failed api generation file

* fix handle album invite return value

* Update web/src/lib/components/user-settings-page/notifications-settings.svelte

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* wording

* test

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2024-06-03 16:00:20 -05:00
aviv926
15474e81b2 docs: Update Authentik example (#9950)
* Update

* npm run format:fix

* more npm run format:fix
2024-06-03 10:57:09 +01:00
renovate[bot]
bb9e18247b chore(deps): update terraform cloudflare to v4.34.0 (#9953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-03 10:07:10 +01:00
Mathias Remshardt
e7dc1f7968 fix(web): empty album stored (#9771)
fix(web): delete album when created empty
2024-06-02 16:08:48 -05:00
223 changed files with 4489 additions and 1644 deletions

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View File

@@ -1 +1 @@
20.13
20.14

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19@sha256:291e84d956f1aff38454bbd3da38941461ad569a185c20aa289f71f37ea08e23 as core
FROM node:20-alpine3.19@sha256:696ae41fb5880949a15ade7879a2deae93b3f0723f757bdb5b8a9e4a744ce27f as core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

99
cli/package-lock.json generated
View File

@@ -54,7 +54,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/node": "^20.12.13",
"typescript": "^5.3.3"
}
},
@@ -1138,9 +1138,9 @@
}
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"version": "20.12.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz",
"integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1154,17 +1154,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.10.0.tgz",
"integrity": "sha512-PzCr+a/KAef5ZawX7nbyNwBDtM1HdLIT53aSA2DDlxmxMngZ43O8SIePOeX8H5S+FHXeI6t97mTt/dDdzY4Fyw==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz",
"integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/type-utils": "7.10.0",
"@typescript-eslint/utils": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/type-utils": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -1188,16 +1188,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz",
"integrity": "sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/typescript-estree": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1217,14 +1217,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.10.0.tgz",
"integrity": "sha512-7L01/K8W/VGl7noe2mgH0K7BE29Sq6KAbVmxurj8GGaPDZXPr8EEQ2seOeAS+mEV9DnzxBQB6ax6qQQ5C6P4xg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz",
"integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0"
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1235,14 +1235,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.10.0.tgz",
"integrity": "sha512-D7tS4WDkJWrVkuzgm90qYw9RdgBcrWmbbRkrLA4d7Pg3w0ttVGDsvYGV19SH8gPR5L7OtcN5J1hTtyenO9xE9g==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz",
"integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.10.0",
"@typescript-eslint/utils": "7.10.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1263,9 +1263,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.10.0.tgz",
"integrity": "sha512-7fNj+Ya35aNyhuqrA1E/VayQX9Elwr8NKZ4WueClR3KwJ7Xx9jcCdOrLW04h51de/+gNbyFMs+IDxh5xIwfbNg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz",
"integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1277,14 +1277,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.10.0.tgz",
"integrity": "sha512-LXFnQJjL9XIcxeVfqmNj60YhatpRLt6UhdlFwAkjNc6jSUlK8zQOl1oktAP8PlWFzPQC1jny/8Bai3/HPuvN5g==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz",
"integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1306,16 +1306,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.10.0.tgz",
"integrity": "sha512-olzif1Fuo8R8m/qKkzJqT7qwy16CzPRWBvERS0uvyc+DHd8AKbO4Jb7kpAvVzMmZm8TrHnI7hvjN4I05zow+tg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz",
"integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/typescript-estree": "7.10.0"
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1329,13 +1329,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.10.0.tgz",
"integrity": "sha512-9ntIVgsi6gg6FIq9xjEO4VQJvwOqA3jaBFQJ/6TK5AvEup2+cECI6Fh7QiBxmfMHXU0V0J4RyPeOU1VDNzl9cg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz",
"integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/types": "7.11.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -4281,10 +4281,11 @@
}
},
"node_modules/vite": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz",
"integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==",
"version": "5.2.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.12.tgz",
"integrity": "sha512-/gC8GxzxMK5ntBwb48pR32GGhENnjtY30G4A0jemunsBkiEZFw60s8InGpN8gkhHEkjnRK1aSAxeQgwvFhUHAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.20.1",
"postcss": "^8.4.38",

View File

@@ -62,6 +62,6 @@
"lodash-es": "^4.17.21"
},
"volta": {
"node": "20.13.1"
"node": "20.14.0"
}
}

View File

@@ -2,24 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.33.0"
constraints = "4.33.0"
version = "4.34.0"
constraints = "4.34.0"
hashes = [
"h1:jfvnxO1kjxUquizrBlswsytWQeHXjvNz6IZwPTuixJ4=",
"zh:1839d03c8c30d3eac4f18c78c5c095d44348eb06cc9d758136d16517094b64e3",
"zh:1fd94992cbb8ce2943cf4fe3deff01b9b1a0e913d2cdd484ceb96a859d46dc5d",
"zh:2b4f37c16a7f6d3712b03980b52b149d4ef6a544917bfe1d1c2ca2d40468daa5",
"zh:32ecb8017be0a34f72b9b0fcd43d944b99cdc903c79892a68b48719824fb194c",
"zh:6468dee137fa07b0b43f742cfdaabb6620883b00773af370e10755ba579eb7f7",
"zh:766504de95a418fd763d9474f39fb147053201d6c4efa3efa456fb39a559b28b",
"zh:800342078c0c04a36cb2558d0c5c6bf050a8b4c231abecac59e56c9868b9fa7e",
"h1:+W0+Xe1AUh7yvHjDbgR9T7CY1UbBC3Y6U7Eo+ucLnJM=",
"h1:2+1lKObDDdFZRluvROF3RKtXD66CFT3PfnHOvR6CmfA=",
"h1:7vluN2wmw8D9nI11YwTgoGv3hGDXlkt8xqQ4L/JABeQ=",
"h1:B0Urm8ZKTJ8cXzSCtEpJ+o+LsD8MXaD6LU59qVbh50Q=",
"h1:FpGLCm5oF12FaRti3E4iQJlkVbdCC7toyGVuH8og7KY=",
"h1:FunTmrCMDy+rom7YskY0WiL5/Y164zFrrD9xnBxU5NY=",
"h1:GrxZhEb+5HzmHF/BvZBdGKBJy6Wyjme0+ABVDz/63to=",
"h1:J36dda2K42/oTfHuZ4jKkW5+nI6BTWFRUvo60P17NJg=",
"h1:Kq0Wyn+j6zoQeghMYixbnfnyP9ZSIEJbOCzMbaCiAQQ=",
"h1:TKxunXCiS/z105sN/kBNFwU6tIKD67JKJ3ZKjwzoCuI=",
"h1:TR0URKFQxsRO5/v7bKm5hkD/CTTjsG7aVGllL/Mf25c=",
"h1:V+3Qs0Reb6r+8p4XjE5ZFDWYrOIN0x5SwORz4wvHOJ4=",
"h1:mZB3Ui7V/lPQMQK53eBOjIHcrul74252dT06Kgn3J+s=",
"h1:wJwZrIXxoki8omXLJ7XA7B1KaSrtcLMJp090fRtFRAc=",
"zh:02aa46743c1585ada8faa7db23af68ea614053a506f88f05d1090ff5e0e68076",
"zh:1e1a545e83e6457a0e15357b23139bc288fb4fbd5e9a5ddfedc95a6a0216b08c",
"zh:29eef2621e0b1501f620e615bf73b1b90d5417d745e38af63634bc03250faf87",
"zh:3c20989d7e1e141882e6091384bf85fdc83f70f3d29e3e047c493a07de992095",
"zh:3d39619379ba29c7ffb15196f0ea72a04c84cfcdf4b39ac42ac4cf4c19f3eae2",
"zh:805f4a2774e9279c590b8214aabe6df9dcc22bb995df2530513f2f78c647ce75",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:94420269672dc5bef37e1a8efab656ada5c2d6f162b52cc59ea74cf0cf35d633",
"zh:972481b86636f00771c2b5f3e408ffd9b66d42942645c8b8e11c4f4bf52285f1",
"zh:ac52d6698fa8b2a8ab820ffb59381da27684a205f5a78529b56358efab67fe06",
"zh:c924c7ac3a8fc08eff7588627be68fc94958c1aaa65928a9fd73cf1d610a0dbf",
"zh:cfdfaeab3fcb522a806fc5b71cbd6096df0fafa06cea2131c0db6074b3b76eed",
"zh:d554393736b99bd1f0b60e210e276531bcd8df79f435924879eeecc1a2100a0d",
"zh:df827b0e00c9e2d666cfe6409f61446908e5983a07ae32c822ef193f6b56c37c",
"zh:8af716f8655a57aa986861a8a7fa1d724594a284bd77c870eaea4db5f8b9732d",
"zh:a3d13c93b4e6ee6004782debaa9a17f990f2fe8ec8ba545c232818bb6064aba9",
"zh:bfa136acf82d3719473c0064446cc16d1b0303d98b06f55f503b7abeebceadb1",
"zh:ca6cf9254ae5436f2efbc01a0e3f7e4aa3c08b45182037b3eb3eb9539b2f7aec",
"zh:cba32d5de02674004e0a5955bd5222016d9991ca0553d4bd3bea517cd9def6ab",
"zh:d22c8cd527c6d0e84567f57be5911792e2fcd5969e3bba3747489f18bb16705b",
"zh:e4eeede9b3e72cdadd6cc252d4cbcf41baee6ecfd12bacd927e2dcbe733ab210",
"zh:facdaa787a69f86203cd3cc6922baea0b4a18bd9c36b0a8162e2e88ef6c90655",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.33.0"
version = "4.34.0"
}
}
}

View File

@@ -2,24 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.33.0"
constraints = "4.33.0"
version = "4.34.0"
constraints = "4.34.0"
hashes = [
"h1:jfvnxO1kjxUquizrBlswsytWQeHXjvNz6IZwPTuixJ4=",
"zh:1839d03c8c30d3eac4f18c78c5c095d44348eb06cc9d758136d16517094b64e3",
"zh:1fd94992cbb8ce2943cf4fe3deff01b9b1a0e913d2cdd484ceb96a859d46dc5d",
"zh:2b4f37c16a7f6d3712b03980b52b149d4ef6a544917bfe1d1c2ca2d40468daa5",
"zh:32ecb8017be0a34f72b9b0fcd43d944b99cdc903c79892a68b48719824fb194c",
"zh:6468dee137fa07b0b43f742cfdaabb6620883b00773af370e10755ba579eb7f7",
"zh:766504de95a418fd763d9474f39fb147053201d6c4efa3efa456fb39a559b28b",
"zh:800342078c0c04a36cb2558d0c5c6bf050a8b4c231abecac59e56c9868b9fa7e",
"h1:+W0+Xe1AUh7yvHjDbgR9T7CY1UbBC3Y6U7Eo+ucLnJM=",
"h1:2+1lKObDDdFZRluvROF3RKtXD66CFT3PfnHOvR6CmfA=",
"h1:7vluN2wmw8D9nI11YwTgoGv3hGDXlkt8xqQ4L/JABeQ=",
"h1:B0Urm8ZKTJ8cXzSCtEpJ+o+LsD8MXaD6LU59qVbh50Q=",
"h1:FpGLCm5oF12FaRti3E4iQJlkVbdCC7toyGVuH8og7KY=",
"h1:FunTmrCMDy+rom7YskY0WiL5/Y164zFrrD9xnBxU5NY=",
"h1:GrxZhEb+5HzmHF/BvZBdGKBJy6Wyjme0+ABVDz/63to=",
"h1:J36dda2K42/oTfHuZ4jKkW5+nI6BTWFRUvo60P17NJg=",
"h1:Kq0Wyn+j6zoQeghMYixbnfnyP9ZSIEJbOCzMbaCiAQQ=",
"h1:TKxunXCiS/z105sN/kBNFwU6tIKD67JKJ3ZKjwzoCuI=",
"h1:TR0URKFQxsRO5/v7bKm5hkD/CTTjsG7aVGllL/Mf25c=",
"h1:V+3Qs0Reb6r+8p4XjE5ZFDWYrOIN0x5SwORz4wvHOJ4=",
"h1:mZB3Ui7V/lPQMQK53eBOjIHcrul74252dT06Kgn3J+s=",
"h1:wJwZrIXxoki8omXLJ7XA7B1KaSrtcLMJp090fRtFRAc=",
"zh:02aa46743c1585ada8faa7db23af68ea614053a506f88f05d1090ff5e0e68076",
"zh:1e1a545e83e6457a0e15357b23139bc288fb4fbd5e9a5ddfedc95a6a0216b08c",
"zh:29eef2621e0b1501f620e615bf73b1b90d5417d745e38af63634bc03250faf87",
"zh:3c20989d7e1e141882e6091384bf85fdc83f70f3d29e3e047c493a07de992095",
"zh:3d39619379ba29c7ffb15196f0ea72a04c84cfcdf4b39ac42ac4cf4c19f3eae2",
"zh:805f4a2774e9279c590b8214aabe6df9dcc22bb995df2530513f2f78c647ce75",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:94420269672dc5bef37e1a8efab656ada5c2d6f162b52cc59ea74cf0cf35d633",
"zh:972481b86636f00771c2b5f3e408ffd9b66d42942645c8b8e11c4f4bf52285f1",
"zh:ac52d6698fa8b2a8ab820ffb59381da27684a205f5a78529b56358efab67fe06",
"zh:c924c7ac3a8fc08eff7588627be68fc94958c1aaa65928a9fd73cf1d610a0dbf",
"zh:cfdfaeab3fcb522a806fc5b71cbd6096df0fafa06cea2131c0db6074b3b76eed",
"zh:d554393736b99bd1f0b60e210e276531bcd8df79f435924879eeecc1a2100a0d",
"zh:df827b0e00c9e2d666cfe6409f61446908e5983a07ae32c822ef193f6b56c37c",
"zh:8af716f8655a57aa986861a8a7fa1d724594a284bd77c870eaea4db5f8b9732d",
"zh:a3d13c93b4e6ee6004782debaa9a17f990f2fe8ec8ba545c232818bb6064aba9",
"zh:bfa136acf82d3719473c0064446cc16d1b0303d98b06f55f503b7abeebceadb1",
"zh:ca6cf9254ae5436f2efbc01a0e3f7e4aa3c08b45182037b3eb3eb9539b2f7aec",
"zh:cba32d5de02674004e0a5955bd5222016d9991ca0553d4bd3bea517cd9def6ab",
"zh:d22c8cd527c6d0e84567f57be5911792e2fcd5969e3bba3747489f18bb16705b",
"zh:e4eeede9b3e72cdadd6cc252d4cbcf41baee6ecfd12bacd927e2dcbe733ab210",
"zh:facdaa787a69f86203cd3cc6922baea0b4a18bd9c36b0a8162e2e88ef6c90655",
]
}

View File

@@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.33.0"
version = "4.34.0"
}
}
}

View File

@@ -1 +1 @@
20.13
20.14

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -117,7 +117,27 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
Here's an example of OAuth configured for Authentik:
<img src={require('./img/oauth-settings.png').default} title="OAuth settings" />
Configuration of Authorised redirect URIs (Authentik OAuth2/OpenID Provider)
<img src={require('./img/authentik-redirect-uris-example.webp').default} width='70%' title="Authentik authorised redirect URIs" />
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ---------------------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/application/o/immich/.well-known/openid-configuration` |
| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
</details>
@@ -126,11 +146,13 @@ Here's an example of OAuth configured for Authentik:
### Google Example
Here's an example of OAuth configured for Google:
Configuration of Authorised redirect URIs (Google Console)
<img src={require('./img/google-example.webp').default} width='50%' title="Authorised redirect URIs" />
<img src={require('./img/google-redirect-uris-example.webp').default} width='50%' title="Google authorised redirect URIs" />
Configuration of OAuth in System Settings
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |

View File

@@ -56,6 +56,6 @@
"node": ">=20"
},
"volta": {
"node": "20.13.1"
"node": "20.14.0"
}
}

View File

@@ -15,6 +15,10 @@ button {
font-family: 'Overpass', sans-serif;
}
img {
border-radius: 15px;
}
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #4250af;

View File

@@ -1 +1 @@
20.13
20.14

117
e2e/package-lock.json generated
View File

@@ -88,7 +88,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/node": "^20.12.13",
"typescript": "^5.3.3"
}
},
@@ -1230,9 +1230,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"version": "20.12.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz",
"integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1344,17 +1344,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.10.0.tgz",
"integrity": "sha512-PzCr+a/KAef5ZawX7nbyNwBDtM1HdLIT53aSA2DDlxmxMngZ43O8SIePOeX8H5S+FHXeI6t97mTt/dDdzY4Fyw==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz",
"integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/type-utils": "7.10.0",
"@typescript-eslint/utils": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/type-utils": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -1378,16 +1378,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz",
"integrity": "sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz",
"integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/typescript-estree": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1407,14 +1407,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.10.0.tgz",
"integrity": "sha512-7L01/K8W/VGl7noe2mgH0K7BE29Sq6KAbVmxurj8GGaPDZXPr8EEQ2seOeAS+mEV9DnzxBQB6ax6qQQ5C6P4xg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz",
"integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0"
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1425,14 +1425,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.10.0.tgz",
"integrity": "sha512-D7tS4WDkJWrVkuzgm90qYw9RdgBcrWmbbRkrLA4d7Pg3w0ttVGDsvYGV19SH8gPR5L7OtcN5J1hTtyenO9xE9g==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz",
"integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.10.0",
"@typescript-eslint/utils": "7.10.0",
"@typescript-eslint/typescript-estree": "7.11.0",
"@typescript-eslint/utils": "7.11.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1453,9 +1453,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.10.0.tgz",
"integrity": "sha512-7fNj+Ya35aNyhuqrA1E/VayQX9Elwr8NKZ4WueClR3KwJ7Xx9jcCdOrLW04h51de/+gNbyFMs+IDxh5xIwfbNg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz",
"integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1467,14 +1467,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.10.0.tgz",
"integrity": "sha512-LXFnQJjL9XIcxeVfqmNj60YhatpRLt6UhdlFwAkjNc6jSUlK8zQOl1oktAP8PlWFzPQC1jny/8Bai3/HPuvN5g==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz",
"integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/visitor-keys": "7.10.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1522,16 +1522,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.10.0.tgz",
"integrity": "sha512-olzif1Fuo8R8m/qKkzJqT7qwy16CzPRWBvERS0uvyc+DHd8AKbO4Jb7kpAvVzMmZm8TrHnI7hvjN4I05zow+tg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz",
"integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.10.0",
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/typescript-estree": "7.10.0"
"@typescript-eslint/scope-manager": "7.11.0",
"@typescript-eslint/types": "7.11.0",
"@typescript-eslint/typescript-estree": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -1545,13 +1545,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.10.0.tgz",
"integrity": "sha512-9ntIVgsi6gg6FIq9xjEO4VQJvwOqA3jaBFQJ/6TK5AvEup2+cECI6Fh7QiBxmfMHXU0V0J4RyPeOU1VDNzl9cg==",
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz",
"integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.10.0",
"@typescript-eslint/types": "7.11.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -2700,10 +2700,11 @@
}
},
"node_modules/exiftool-vendored": {
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.0.0.tgz",
"integrity": "sha512-2TRxx21ovD95VvdSzHb/sTYYcwhiizQIhhVAbrgua9KoL902QRieREGvaUtfBZNjsptdjonuyku2kUBJCPqsgw==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.1.0.tgz",
"integrity": "sha512-Bhy2Ia86Agt3+PbJJhWeVMqJNXl74XJ0Oygef5F5uCL13fTxlmF8dECHiChyx8bBc3sxIw+2Q3ehWunJh3bs6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@photostructure/tz-lookup": "^10.0.0",
"@types/luxon": "^3.4.2",
@@ -2712,25 +2713,27 @@
"luxon": "^3.4.4"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.84.0",
"exiftool-vendored.pl": "12.84.0"
"exiftool-vendored.exe": "12.85.0",
"exiftool-vendored.pl": "12.85.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.84.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.84.0.tgz",
"integrity": "sha512-9ocqJb0Pr9k0TownEMd75payF/XOQLF/swr/l0Ep49D+m609uIZsW09CtowhXmk1KrIFobS3+SkdXK04CSyUwQ==",
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.85.0.tgz",
"integrity": "sha512-rWsKVp9oXsS79S3bfCNXKeEo4av0xcd7slk/TfPpCa5pojg8ZVXSVfPZMAAlhOuK63YXrKN/e3jRNReeGP+2Gw==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.84.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.84.0.tgz",
"integrity": "sha512-TxvMRaVYtd24Vupn48zy24LOYItIIWEu4dgt/VlqLwxQItTpvJTV9YH04iZRvaNh9ZdPRgVKWMuuUDBBHv+lAg==",
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.85.0.tgz",
"integrity": "sha512-AelZQCCfl0a0g7PYx90TqbNGlSu2zDbRfCTjGw6bBBYnJF0NUfUWVhTpa8XGe2lHx1KYikH8AkJaey3esAxMAg==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!win32"

View File

@@ -47,6 +47,6 @@
"vitest": "^1.3.0"
},
"volta": {
"node": "20.13.1"
"node": "20.14.0"
}
}

View File

@@ -250,10 +250,18 @@ describe('/admin/users', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } });
expect(body).toEqual({
avatar: { color: 'orange' },
memories: { enabled: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
});
const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } });
expect(after).toEqual({
avatar: { color: 'orange' },
memories: { enabled: false },
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
});
});
});

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:d889b18dcb737911bb2628a649bcbd0dc42556148b0ece298021343bc66cf84a as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:4688551ffd61358d5bebfd88e0aac12d5b4aed7a153c170dbc435da453476a13 as builder
ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \

View File

@@ -2000,21 +2000,21 @@ sympy = "*"
[[package]]
name = "onnxruntime-gpu"
version = "1.17.1"
version = "1.18.0"
description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
optional = false
python-versions = "*"
files = [
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e34ecb2b527ee1265135ae74cd99ea198ff344b8221929a920596a1e461e2bbb"},
{file = "onnxruntime_gpu-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:37786c0f225be90da0a66ca413fe125a925a0900263301cc4dbcad4ff0404673"},
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3bde190a683ec84ecf61bd390f3c275d388efe72404633df374c52c557ce6d4d"},
{file = "onnxruntime_gpu-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:5206c84caa770efcc2ca819f71ec007a244ed748ca04e7ff76b86df1a096d2c8"},
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0396ec73de565a64509d96dff154f531f8da8023c191f771ceba47a3f4efc266"},
{file = "onnxruntime_gpu-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:8531d4a833c8e978c5ff1de7b3bcc4126bbe58ea71fae54ddce58fe8777cb136"},
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7b831f9eafd626f3d44955420a4b1b84f9ffcb987712a0ab6a37d1ee9f2f7a45"},
{file = "onnxruntime_gpu-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:a389334d3797519d4b12077db32b8764f1ce54374d0f89235edc04efe8bc192c"},
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:27aeaa36385e459b3867577ed7f68c1756de79aa68f57141d4ae2a31c84f6a33"},
{file = "onnxruntime_gpu-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:8b46094ea348aff6c6494402ac4260e2d2aba0522ae13e1ae29d98a29384ed70"},
{file = "onnxruntime_gpu-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:039be9a6b7f71c6739e97eec79f4bf240793a7c0c4108a09e0e1a27b4c33dbca"},
{file = "onnxruntime_gpu-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:afd4bc090b9412ab695cb34c05f4f92f88dbb6bd52d9b38658ad0115c50ff653"},
{file = "onnxruntime_gpu-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2280f94c6be2717f010a73c30a94c2721af853c6b7110e83afa52d03de6614a8"},
{file = "onnxruntime_gpu-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:9e3b4e9a0171e53a71001805b9b0e1a98cbad5a413d795c0e132b0f058b386d6"},
{file = "onnxruntime_gpu-1.18.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:546fbf2dcb7a2830ca69bde0c38665a88a9454e923ebb76bedf85eaed33a6f4a"},
{file = "onnxruntime_gpu-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:24146aa670c45734d9b8583cd78bd790363bc8695a3808d129ec913186064e4c"},
{file = "onnxruntime_gpu-1.18.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:75884c51c2aa47c349de3b5485df7f9573e1b89c607dd55984d3fe40615ef002"},
{file = "onnxruntime_gpu-1.18.0-cp38-cp38-win_amd64.whl", hash = "sha256:f3b00a7443252dbfbd18ff72bcc2f44066fad9128eaa29bff8b315a834241701"},
{file = "onnxruntime_gpu-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:14f53bd74ad21c61fee55eca988758e5eec4c39450040c8986ec3a960cb127a8"},
{file = "onnxruntime_gpu-1.18.0-cp39-cp39-win_amd64.whl", hash = "sha256:aea5c02b3a0ee6682214a61a2a0467773401b075afdcb41dc2ef595f41c2d185"},
]
[package.dependencies]
@@ -2799,28 +2799,28 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.4.5"
version = "0.4.7"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.4.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8f58e615dec58b1a6b291769b559e12fdffb53cc4187160a2fc83250eaf54e96"},
{file = "ruff-0.4.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84dd157474e16e3a82745d2afa1016c17d27cb5d52b12e3d45d418bcc6d49264"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f483ad9d50b00e7fd577f6d0305aa18494c6af139bce7319c68a17180087f4"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63fde3bf6f3ad4e990357af1d30e8ba2730860a954ea9282c95fc0846f5f64af"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e3ba4620dee27f76bbcad97067766026c918ba0f2d035c2fc25cbdd04d9c97"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:441dab55c568e38d02bbda68a926a3d0b54f5510095c9de7f95e47a39e0168aa"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1169e47e9c4136c997f08f9857ae889d614c5035d87d38fda9b44b4338909cdf"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:755ac9ac2598a941512fc36a9070a13c88d72ff874a9781493eb237ab02d75df"},
{file = "ruff-0.4.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4b02a65985be2b34b170025a8b92449088ce61e33e69956ce4d316c0fe7cce0"},
{file = "ruff-0.4.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:75a426506a183d9201e7e5664de3f6b414ad3850d7625764106f7b6d0486f0a1"},
{file = "ruff-0.4.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6e1b139b45e2911419044237d90b60e472f57285950e1492c757dfc88259bb06"},
{file = "ruff-0.4.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6f29a8221d2e3d85ff0c7b4371c0e37b39c87732c969b4d90f3dad2e721c5b1"},
{file = "ruff-0.4.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d6ef817124d72b54cc923f3444828ba24fa45c3164bc9e8f1813db2f3d3a8a11"},
{file = "ruff-0.4.5-py3-none-win32.whl", hash = "sha256:aed8166c18b1a169a5d3ec28a49b43340949e400665555b51ee06f22813ef062"},
{file = "ruff-0.4.5-py3-none-win_amd64.whl", hash = "sha256:b0b03c619d2b4350b4a27e34fd2ac64d0dabe1afbf43de57d0f9d8a05ecffa45"},
{file = "ruff-0.4.5-py3-none-win_arm64.whl", hash = "sha256:9d15de3425f53161b3f5a5658d4522e4eee5ea002bf2ac7aa380743dd9ad5fba"},
{file = "ruff-0.4.5.tar.gz", hash = "sha256:286eabd47e7d4d521d199cab84deca135557e6d1e0f0d01c29e757c3cb151b54"},
{file = "ruff-0.4.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e089371c67892a73b6bb1525608e89a2aca1b77b5440acf7a71dda5dac958f9e"},
{file = "ruff-0.4.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:10f973d521d910e5f9c72ab27e409e839089f955be8a4c8826601a6323a89753"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c3d110970001dfa494bcd95478e62286c751126dfb15c3c46e7915fc49694f"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa9773c6c00f4958f73b317bc0fd125295110c3776089f6ef318f4b775f0abe4"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07fc80bbb61e42b3b23b10fda6a2a0f5a067f810180a3760c5ef1b456c21b9db"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fa4dafe3fe66d90e2e2b63fa1591dd6e3f090ca2128daa0be33db894e6c18648"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7c0083febdec17571455903b184a10026603a1de078428ba155e7ce9358c5f6"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad1b20e66a44057c326168437d680a2166c177c939346b19c0d6b08a62a37589"},
{file = "ruff-0.4.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf5d818553add7511c38b05532d94a407f499d1a76ebb0cad0374e32bc67202"},
{file = "ruff-0.4.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50e9651578b629baec3d1513b2534de0ac7ed7753e1382272b8d609997e27e83"},
{file = "ruff-0.4.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8874a9df7766cb956b218a0a239e0a5d23d9e843e4da1e113ae1d27ee420877a"},
{file = "ruff-0.4.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9de9a6e49f7d529decd09381c0860c3f82fa0b0ea00ea78409b785d2308a567"},
{file = "ruff-0.4.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:13a1768b0691619822ae6d446132dbdfd568b700ecd3652b20d4e8bc1e498f78"},
{file = "ruff-0.4.7-py3-none-win32.whl", hash = "sha256:769e5a51df61e07e887b81e6f039e7ed3573316ab7dd9f635c5afaa310e4030e"},
{file = "ruff-0.4.7-py3-none-win_amd64.whl", hash = "sha256:9e3ab684ad403a9ed1226894c32c3ab9c2e0718440f6f50c7c5829932bc9e054"},
{file = "ruff-0.4.7-py3-none-win_arm64.whl", hash = "sha256:10f2204b9a613988e3484194c2c9e96a22079206b22b787605c255f130db5ed7"},
{file = "ruff-0.4.7.tar.gz", hash = "sha256:2331d2b051dc77a289a653fcc6a42cce357087c5975738157cd966590b18b5e1"},
]
[[package]]
@@ -3234,13 +3234,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "uvicorn"
version = "0.29.0"
version = "0.30.1"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.8"
files = [
{file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"},
{file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"},
{file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"},
{file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"},
]
[package.dependencies]

View File

@@ -294,6 +294,8 @@ Class | Method | HTTP request | Description
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md)
- [DuplicateResponseDto](doc//DuplicateResponseDto.md)
- [EmailNotificationsResponse](doc//EmailNotificationsResponse.md)
- [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)

View File

@@ -121,6 +121,8 @@ part 'model/download_info_dto.dart';
part 'model/download_response_dto.dart';
part 'model/duplicate_detection_config.dart';
part 'model/duplicate_response_dto.dart';
part 'model/email_notifications_response.dart';
part 'model/email_notifications_update.dart';
part 'model/entity_type.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';

View File

@@ -306,6 +306,10 @@ class ApiClient {
return DuplicateDetectionConfig.fromJson(value);
case 'DuplicateResponseDto':
return DuplicateResponseDto.fromJson(value);
case 'EmailNotificationsResponse':
return EmailNotificationsResponse.fromJson(value);
case 'EmailNotificationsUpdate':
return EmailNotificationsUpdate.fromJson(value);
case 'EntityType':
return EntityTypeTypeTransformer().decode(value);
case 'ExifResponseDto':

View File

@@ -0,0 +1,114 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EmailNotificationsResponse {
/// Returns a new [EmailNotificationsResponse] instance.
EmailNotificationsResponse({
required this.albumInvite,
required this.albumUpdate,
required this.enabled,
});
bool albumInvite;
bool albumUpdate;
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is EmailNotificationsResponse &&
other.albumInvite == albumInvite &&
other.albumUpdate == albumUpdate &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumInvite.hashCode) +
(albumUpdate.hashCode) +
(enabled.hashCode);
@override
String toString() => 'EmailNotificationsResponse[albumInvite=$albumInvite, albumUpdate=$albumUpdate, enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumInvite'] = this.albumInvite;
json[r'albumUpdate'] = this.albumUpdate;
json[r'enabled'] = this.enabled;
return json;
}
/// Returns a new [EmailNotificationsResponse] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EmailNotificationsResponse? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return EmailNotificationsResponse(
albumInvite: mapValueOfType<bool>(json, r'albumInvite')!,
albumUpdate: mapValueOfType<bool>(json, r'albumUpdate')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
return null;
}
static List<EmailNotificationsResponse> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EmailNotificationsResponse>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EmailNotificationsResponse.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EmailNotificationsResponse> mapFromJson(dynamic json) {
final map = <String, EmailNotificationsResponse>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EmailNotificationsResponse.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EmailNotificationsResponse-objects as value to a dart map
static Map<String, List<EmailNotificationsResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EmailNotificationsResponse>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EmailNotificationsResponse.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumInvite',
'albumUpdate',
'enabled',
};
}

View File

@@ -0,0 +1,141 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class EmailNotificationsUpdate {
/// Returns a new [EmailNotificationsUpdate] instance.
EmailNotificationsUpdate({
this.albumInvite,
this.albumUpdate,
this.enabled,
});
///
/// 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? albumInvite;
///
/// 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? albumUpdate;
///
/// 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? enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is EmailNotificationsUpdate &&
other.albumInvite == albumInvite &&
other.albumUpdate == albumUpdate &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumInvite == null ? 0 : albumInvite!.hashCode) +
(albumUpdate == null ? 0 : albumUpdate!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode);
@override
String toString() => 'EmailNotificationsUpdate[albumInvite=$albumInvite, albumUpdate=$albumUpdate, enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.albumInvite != null) {
json[r'albumInvite'] = this.albumInvite;
} else {
// json[r'albumInvite'] = null;
}
if (this.albumUpdate != null) {
json[r'albumUpdate'] = this.albumUpdate;
} else {
// json[r'albumUpdate'] = null;
}
if (this.enabled != null) {
json[r'enabled'] = this.enabled;
} else {
// json[r'enabled'] = null;
}
return json;
}
/// Returns a new [EmailNotificationsUpdate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static EmailNotificationsUpdate? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return EmailNotificationsUpdate(
albumInvite: mapValueOfType<bool>(json, r'albumInvite'),
albumUpdate: mapValueOfType<bool>(json, r'albumUpdate'),
enabled: mapValueOfType<bool>(json, r'enabled'),
);
}
return null;
}
static List<EmailNotificationsUpdate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <EmailNotificationsUpdate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = EmailNotificationsUpdate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, EmailNotificationsUpdate> mapFromJson(dynamic json) {
final map = <String, EmailNotificationsUpdate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = EmailNotificationsUpdate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of EmailNotificationsUpdate-objects as value to a dart map
static Map<String, List<EmailNotificationsUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<EmailNotificationsUpdate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = EmailNotificationsUpdate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -14,30 +14,36 @@ class UserPreferencesResponseDto {
/// Returns a new [UserPreferencesResponseDto] instance.
UserPreferencesResponseDto({
required this.avatar,
required this.emailNotifications,
required this.memories,
});
AvatarResponse avatar;
EmailNotificationsResponse emailNotifications;
MemoryResponse memories;
@override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
other.avatar == avatar &&
other.emailNotifications == emailNotifications &&
other.memories == memories;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatar.hashCode) +
(emailNotifications.hashCode) +
(memories.hashCode);
@override
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, memories=$memories]';
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'avatar'] = this.avatar;
json[r'emailNotifications'] = this.emailNotifications;
json[r'memories'] = this.memories;
return json;
}
@@ -51,6 +57,7 @@ class UserPreferencesResponseDto {
return UserPreferencesResponseDto(
avatar: AvatarResponse.fromJson(json[r'avatar'])!,
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
memories: MemoryResponse.fromJson(json[r'memories'])!,
);
}
@@ -100,6 +107,7 @@ class UserPreferencesResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'avatar',
'emailNotifications',
'memories',
};
}

View File

@@ -14,6 +14,7 @@ class UserPreferencesUpdateDto {
/// Returns a new [UserPreferencesUpdateDto] instance.
UserPreferencesUpdateDto({
this.avatar,
this.emailNotifications,
this.memories,
});
@@ -25,6 +26,14 @@ class UserPreferencesUpdateDto {
///
AvatarUpdate? avatar;
///
/// 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.
///
EmailNotificationsUpdate? emailNotifications;
///
/// 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
@@ -36,16 +45,18 @@ class UserPreferencesUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto &&
other.avatar == avatar &&
other.emailNotifications == emailNotifications &&
other.memories == memories;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatar == null ? 0 : avatar!.hashCode) +
(emailNotifications == null ? 0 : emailNotifications!.hashCode) +
(memories == null ? 0 : memories!.hashCode);
@override
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, memories=$memories]';
String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -54,6 +65,11 @@ class UserPreferencesUpdateDto {
} else {
// json[r'avatar'] = null;
}
if (this.emailNotifications != null) {
json[r'emailNotifications'] = this.emailNotifications;
} else {
// json[r'emailNotifications'] = null;
}
if (this.memories != null) {
json[r'memories'] = this.memories;
} else {
@@ -71,6 +87,7 @@ class UserPreferencesUpdateDto {
return UserPreferencesUpdateDto(
avatar: AvatarUpdate.fromJson(json[r'avatar']),
emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']),
memories: MemoryUpdate.fromJson(json[r'memories']),
);
}

View File

@@ -2299,6 +2299,27 @@
]
}
},
"/auth/validate-license": {
"get": {
"operationId": "validateLicense",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "boolean"
}
}
},
"description": ""
}
},
"tags": [
"Authentication"
]
}
},
"/auth/validateToken": {
"post": {
"operationId": "validateAccessToken",
@@ -8152,6 +8173,39 @@
],
"type": "object"
},
"EmailNotificationsResponse": {
"properties": {
"albumInvite": {
"type": "boolean"
},
"albumUpdate": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
}
},
"required": [
"albumInvite",
"albumUpdate",
"enabled"
],
"type": "object"
},
"EmailNotificationsUpdate": {
"properties": {
"albumInvite": {
"type": "boolean"
},
"albumUpdate": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"EntityType": {
"enum": [
"ASSET",
@@ -11205,12 +11259,16 @@
"avatar": {
"$ref": "#/components/schemas/AvatarResponse"
},
"emailNotifications": {
"$ref": "#/components/schemas/EmailNotificationsResponse"
},
"memories": {
"$ref": "#/components/schemas/MemoryResponse"
}
},
"required": [
"avatar",
"emailNotifications",
"memories"
],
"type": "object"
@@ -11220,6 +11278,9 @@
"avatar": {
"$ref": "#/components/schemas/AvatarUpdate"
},
"emailNotifications": {
"$ref": "#/components/schemas/EmailNotificationsUpdate"
},
"memories": {
"$ref": "#/components/schemas/MemoryUpdate"
}

View File

@@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"version": "20.12.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.13.tgz",
"integrity": "sha512-gBGeanV41c1L171rR7wjbMiEpEI/l5XFQdLLfhr/REwpgDy/4U8y89+i8kRiLzDyZdOkXh+cRaTetUnCYutoXA==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -78,21 +78,33 @@ export type UserAdminUpdateDto = {
export type AvatarResponse = {
color: UserAvatarColor;
};
export type EmailNotificationsResponse = {
albumInvite: boolean;
albumUpdate: boolean;
enabled: boolean;
};
export type MemoryResponse = {
enabled: boolean;
};
export type UserPreferencesResponseDto = {
avatar: AvatarResponse;
emailNotifications: EmailNotificationsResponse;
memories: MemoryResponse;
};
export type AvatarUpdate = {
color?: UserAvatarColor;
};
export type EmailNotificationsUpdate = {
albumInvite?: boolean;
albumUpdate?: boolean;
enabled?: boolean;
};
export type MemoryUpdate = {
enabled?: boolean;
};
export type UserPreferencesUpdateDto = {
avatar?: AvatarUpdate;
emailNotifications?: EmailNotificationsUpdate;
memories?: MemoryUpdate;
};
export type AlbumUserResponseDto = {

View File

@@ -1 +1 @@
20.13
20.14

1039
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.51.0",
"@opentelemetry/sdk-node": "^0.51.0",
"@react-email/components": "^0.0.18",
"@react-email/components": "^0.0.19",
"@socket.io/postgres-adapter": "^0.3.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@@ -60,7 +60,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~26.0.0",
"exiftool-vendored": "~26.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -133,6 +133,6 @@
"vitest": "^1.5.0"
},
"volta": {
"node": "20.13.1"
"node": "20.14.0"
}
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import * as crypto from 'node:crypto';
import { AuthType } from 'src/constants';
import {
AuthDto,
@@ -75,4 +76,28 @@ export class AuthController {
ImmichCookie.IS_AUTHENTICATED,
]);
}
@Get('validate-license')
validateLicense(@Body() dto: { licenseKey: string }) {
console.log(dto);
const publicKey =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';
const licenseKey = 'TN6F-4DN5-733W-24VT-NC6P-D49S-WPX6-SDK4';
const activationKey =
'x2BH8Oq-pPtSxkNv1yexzTJELTW8fblk_VZmYHsrV7aJ3s79RPsfk547JSmtmTWAv28Bmw5m8rTxutYxNX7ws6ysysqHg0OinRBykH_LJHNbURzSjrmDXOffbFQWchqnuSXZYt-SN5rpI_2sZDlUxZ72wkhhrrKUb3UQXHhsQt6MQNnumgLfswvJQVKnvhJ3tzHEDYOHuKo4w-p0l7rIi0WRUWW2FrNOIh9HrvccEBLIwfIWjKz9xIJaN-Qwp0xYuwUqQ-p5jJn6XqEXWUQmRer8RWxk589qiTi238MMG_YvkTRd68Iqn10OyxJ4N0ua0qXS64xhgK5dfCsEVQfCMA';
const publicKeyBuffer = Buffer.from(publicKey, 'base64');
const publicPaymentKey = crypto.createPublicKey({
key: publicKeyBuffer,
type: 'spki',
format: 'pem',
});
const verifier = crypto.createVerify('SHA256');
verifier.update(licenseKey);
verifier.end();
const activationKeyBuffer = Buffer.from(activationKey, 'base64');
return verifier.verify(publicPaymentKey, activationKeyBuffer);
}
}

View File

@@ -16,6 +16,17 @@ class MemoryUpdate {
enabled?: boolean;
}
class EmailNotificationsUpdate {
@ValidateBoolean({ optional: true })
enabled?: boolean;
@ValidateBoolean({ optional: true })
albumInvite?: boolean;
@ValidateBoolean({ optional: true })
albumUpdate?: boolean;
}
export class UserPreferencesUpdateDto {
@Optional()
@ValidateNested()
@@ -26,6 +37,11 @@ export class UserPreferencesUpdateDto {
@ValidateNested()
@Type(() => MemoryUpdate)
memories?: MemoryUpdate;
@Optional()
@ValidateNested()
@Type(() => EmailNotificationsUpdate)
emailNotifications?: EmailNotificationsUpdate;
}
class AvatarResponse {
@@ -37,9 +53,16 @@ class MemoryResponse {
enabled!: boolean;
}
class EmailNotificationsResponse {
enabled!: boolean;
albumInvite!: boolean;
albumUpdate!: boolean;
}
export class UserPreferencesResponseDto implements UserPreferences {
memories!: MemoryResponse;
avatar!: AvatarResponse;
emailNotifications!: EmailNotificationsResponse;
}
export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => {

View File

@@ -36,6 +36,11 @@ export interface UserPreferences {
avatar: {
color: UserAvatarColor;
};
emailNotifications: {
enabled: boolean;
albumInvite: boolean;
albumUpdate: boolean;
};
}
export const getDefaultPreferences = (user: { email: string }): UserPreferences => {
@@ -51,6 +56,11 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences
avatar: {
color: values[randomIndex],
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
};
};

View File

@@ -383,7 +383,21 @@ describe(MetadataService.name, () => {
'MotionPhotoVideo',
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]);
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(assetMock.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
@@ -412,7 +426,21 @@ describe(MetadataService.name, () => {
'EmbeddedVideoFile',
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]);
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(assetMock.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
@@ -442,7 +470,21 @@ describe(MetadataService.name, () => {
assetStub.livePhotoWithOriginalFileName.originalPath,
expect.any(Object),
);
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(assetMock.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {

View File

@@ -19,6 +19,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class NotificationService {
@@ -95,6 +96,12 @@ export class NotificationService {
return JobStatus.SKIPPED;
}
const { emailNotifications } = getPreferences(recipient);
if (!emailNotifications.enabled || !emailNotifications.albumInvite) {
return JobStatus.SKIPPED;
}
const attachment = await this.getAlbumThumbnailAttachment(album);
const { server } = await this.configCore.getConfig();
@@ -142,6 +149,12 @@ export class NotificationService {
const { server } = await this.configCore.getConfig();
for (const recipient of recipients) {
const { emailNotifications } = getPreferences(recipient);
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
continue;
}
const { html, text } = this.notificationRepository.renderEmail({
template: EmailTemplate.ALBUM_UPDATE,
data: {

View File

@@ -492,7 +492,7 @@ export const assetStub = {
originalFileName: fileStub.livePhotoStill.originalName,
ownerId: authStub.user1.user.id,
type: AssetType.IMAGE,
livePhotoVideoId: 'live-photo-motion-asset123',
livePhotoVideoId: 'live-photo-motion-asset',
isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),

View File

@@ -1 +1 @@
20.13
20.14

768
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -75,10 +75,11 @@
"luxon": "^3.4.4",
"socket.io-client": "^4.7.4",
"svelte-local-storage-store": "^0.6.4",
"svelte-i18n": "^4.0.0",
"svelte-maplibre": "^0.9.0",
"thumbhash": "^0.1.1"
},
"volta": {
"node": "20.13.1"
"node": "20.14.0"
}
}

View File

@@ -5,6 +5,7 @@
import { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte';
import Checkbox from '$lib/components/elements/checkbox.svelte';
import { t } from 'svelte-i18n';
export let user: UserResponseDto;
@@ -31,7 +32,7 @@
dispatch('success');
}
} catch (error) {
handleError(error, 'Unable to delete user');
handleError(error, $t('errors.unable_to_delete_user'));
dispatch('fail');
}
};
@@ -43,8 +44,8 @@
</script>
<ConfirmDialog
title="Delete user"
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
title={$t('delete_user')}
confirmText={forceDelete ? $t('permanently_delete') : $t('delete')}
onConfirm={handleDeleteUser}
onCancel={() => dispatch('cancel')}
disabled={deleteButtonDisabled}

View File

@@ -16,6 +16,7 @@
import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let title: string;
export let subtitle: string | undefined;
@@ -43,9 +44,9 @@
>
<div class="flex w-full flex-col">
{#if queueStatus.isPaused}
<JobTileStatus color="warning">Paused</JobTileStatus>
<JobTileStatus color="warning">{$t('paused')}</JobTileStatus>
{:else if queueStatus.isActive}
<JobTileStatus color="success">Active</JobTileStatus>
<JobTileStatus color="success">{$t('active')}</JobTileStatus>
{/if}
<div class="flex flex-col gap-2 p-5 sm:p-7 md:p-9">
<div class="flex items-center gap-4 text-xl font-semibold text-immich-primary dark:text-immich-dark-primary">
@@ -63,7 +64,7 @@
<CircleIconButton
color="primary"
icon={mdiClose}
title="Clear message"
title={$t('clear_message')}
size="12"
padding="1"
on:click={() => dispatch('command', { command: JobCommand.ClearFailed, force: false })}
@@ -95,7 +96,7 @@
<div
class="{commonClasses} rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-l-lg sm:rounded-r-none"
>
<p>Active</p>
<p>{$t('active')}</p>
<p class="text-2xl">
{jobCounts.active.toLocaleString($locale)}
</p>
@@ -107,7 +108,7 @@
<p class="text-2xl">
{waitingCount.toLocaleString($locale)}
</p>
<p>Waiting</p>
<p>{$t('waiting')}</p>
</div>
</div>
</div>

View File

@@ -23,6 +23,7 @@
import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
export let jobs: AllJobStatusResponseDto;
@@ -60,38 +61,38 @@
[JobName.ThumbnailGeneration]: {
icon: mdiFileJpgBox,
title: getJobName(JobName.ThumbnailGeneration),
subtitle: 'Generate large, small and blurred thumbnails for each asset, as well as thumbnails for each person',
subtitle: $t('thumbnail_generation_job_description'),
},
[JobName.MetadataExtraction]: {
icon: mdiTable,
title: getJobName(JobName.MetadataExtraction),
subtitle: 'Extract metadata information from each asset, such as GPS and resolution',
subtitle: $t('metadata_extraction_job_description'),
},
[JobName.Library]: {
icon: mdiLibraryShelves,
title: getJobName(JobName.Library),
subtitle: 'Perform library tasks',
allText: 'ALL',
missingText: 'REFRESH',
subtitle: $t('perform_library_tasks'),
allText: $t('all').toUpperCase(),
missingText: $t('refresh').toUpperCase(),
},
[JobName.Sidecar]: {
title: getJobName(JobName.Sidecar),
icon: mdiFileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC',
missingText: 'DISCOVER',
subtitle: $t('sidecar_job_description'),
allText: $t('sync').toUpperCase(),
missingText: $t('discover').toUpperCase(),
disabled: !$featureFlags.sidecar,
},
[JobName.SmartSearch]: {
icon: mdiImageSearch,
title: getJobName(JobName.SmartSearch),
subtitle: 'Run machine learning on assets to support smart search',
subtitle: $t('smart_search_job_description'),
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
subtitle: $t('duplicate_detection_job_description'),
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
@@ -113,7 +114,7 @@
[JobName.VideoConversion]: {
icon: mdiVideo,
title: getJobName(JobName.VideoConversion),
subtitle: 'Transcode videos for wider compatibility with browsers and devices',
subtitle: $t('video_conversion_job_description'),
},
[JobName.StorageTemplateMigration]: {
icon: mdiFolderMove,
@@ -124,7 +125,7 @@
[JobName.Migration]: {
icon: mdiFolderMove,
title: getJobName(JobName.Migration),
subtitle: 'Migrate thumbnails for assets and faces to the latest folder structure',
subtitle: $t('migration_job_description'),
allowForceCommand: false,
},
};
@@ -159,8 +160,8 @@
{title}
{disabled}
{subtitle}
allText={allText || 'ALL'}
missingText={missingText || 'MISSING'}
allText={allText || $t('all').toUpperCase()}
missingText={missingText || $t('missing').toUpperCase()}
{allowForceCommand}
{jobCounts}
{queueStatus}

View File

@@ -1,9 +1,10 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { t } from 'svelte-i18n';
</script>
Apply the current
<a href="{AppRoute.ADMIN_SETTINGS}?open=storageTemplate" class="text-immich-primary dark:text-immich-dark-primary"
>Storage template</a
>{$t('storage_template_settings')}</a
>
to previously uploaded assets

View File

@@ -3,6 +3,7 @@
import { handleError } from '$lib/utils/handle-error';
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
export let user: UserResponseDto;
@@ -21,15 +22,15 @@
dispatch('fail');
}
} catch (error) {
handleError(error, 'Unable to restore user');
handleError(error, $t('errors.unable_to_restore_user'));
dispatch('fail');
}
};
</script>
<ConfirmDialog
title="Restore user"
confirmText="Continue"
title={$t('restore_user')}
confirmText={$t('continue')}
confirmColor="green"
onConfirm={handleRestoreUser}
onCancel={() => dispatch('cancel')}

View File

@@ -5,6 +5,7 @@
import type { ServerStatsResponseDto } from '@immich/sdk';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import StatsCard from './stats-card.svelte';
import { t } from 'svelte-i18n';
export let stats: ServerStatsResponseDto = {
photos: 0,
@@ -27,19 +28,19 @@
<div class="flex flex-col gap-5">
<div>
<p class="text-sm dark:text-immich-dark-fg">TOTAL USAGE</p>
<p class="text-sm dark:text-immich-dark-fg">{$t('total_usage').toUpperCase()}</p>
<div class="mt-5 hidden justify-between lg:flex">
<StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard icon={mdiChartPie} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
<StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={stats.videos} />
<StatsCard icon={mdiChartPie} title={$t('storage').toUpperCase()} value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiCameraIris} size="25" />
<p>PHOTOS</p>
<p>{$t('photos').toUpperCase()}</p>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
@@ -51,7 +52,7 @@
<div class="flex flex-wrap gap-x-12">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiPlayCircle} size="25" />
<p>VIDEOS</p>
<p>{$t('videos').toUpperCase()}</p>
</div>
<div class="relative text-center font-mono text-2xl font-semibold">
@@ -63,7 +64,7 @@
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiChartPie} size="25" />
<p>STORAGE</p>
<p>{$t('storage').toUpperCase()}</p>
</div>
<div class="relative flex text-center font-mono text-2xl font-semibold">
@@ -78,16 +79,16 @@
</div>
<div>
<p class="text-sm dark:text-immich-dark-fg">USER USAGE DETAIL</p>
<p class="text-sm dark:text-immich-dark-fg">{$t('user_usage_detail').toUpperCase()}</p>
<table class="mt-5 w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center">
<th class="w-1/4 text-center text-sm font-medium">User</th>
<th class="w-1/4 text-center text-sm font-medium">Photos</th>
<th class="w-1/4 text-center text-sm font-medium">Videos</th>
<th class="w-1/4 text-center text-sm font-medium">Usage</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('user')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('photos')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('videos')}</th>
<th class="w-1/4 text-center text-sm font-medium">{$t('usage')}</th>
</tr>
</thead>
<tbody

View File

@@ -11,6 +11,7 @@
import { cloneDeep } from 'lodash-es';
import { createEventDispatcher, onMount } from 'svelte';
import type { SettingsEventType } from './admin-settings';
import { t } from 'svelte-i18n';
export let config: SystemConfigDto;
@@ -34,13 +35,13 @@
config = cloneDeep(newConfig);
savedConfig = cloneDeep(newConfig);
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
notificationController.show({ message: $t('settings_saved'), type: NotificationType.Info });
await loadConfig();
dispatch('save');
} catch (error) {
handleError(error, 'Unable to save settings');
handleError(error, $t('errors.unable_to_save_settings'));
}
};
@@ -63,7 +64,7 @@
}
notificationController.show({
message: 'Reset settings to default',
message: $t('reset_settings_to_default'),
type: NotificationType.Info,
});
};

View File

@@ -11,6 +11,7 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -42,7 +43,11 @@
</script>
{#if isConfirmOpen}
<ConfirmDialog title="Disable login" onCancel={() => (isConfirmOpen = false)} onConfirm={() => handleSave(true)}>
<ConfirmDialog
title={$t('admin.disable_login')}
onCancel={() => (isConfirmOpen = false)}
onConfirm={() => handleSave(true)}
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
@@ -66,7 +71,11 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion key="oauth" title="OAuth" subtitle="Manage OAuth login settings">
<SettingAccordion
key="oauth"
title={$t('admin.oauth_settings')}
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
@@ -77,13 +86,18 @@
>.
</p>
<SettingSwitch {disabled} title="ENABLE" subtitle="Login with OAuth" bind:checked={config.oauth.enabled} />
<SettingSwitch
{disabled}
title={$t('enable').toUpperCase()}
subtitle={$t('admin.oauth_enable_description')}
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
label={$t('admin.oauth_issuer_url').toUpperCase()}
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -92,7 +106,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
label={$t('admin.oauth_client_id').toUpperCase()}
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -101,7 +115,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
label={$t('admin.oauth_client_secret').toUpperCase()}
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -110,7 +124,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
label={$t('admin.oauth_scope').toUpperCase()}
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -119,7 +133,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
label={$t('admin.oauth_signing_algorithm').toUpperCase()}
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -128,8 +142,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
desc={$t('admin.oauth_storage_label_claim_description')}
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -138,8 +152,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
desc={$t('admin.oauth_storage_quota_claim_description')}
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -148,8 +162,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
desc={$t('admin.oauth_storage_quota_default_description')}
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -158,7 +172,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
label={$t('admin.oauth_button_text').toUpperCase()}
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
@@ -166,22 +180,22 @@
/>
<SettingSwitch
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
title={$t('admin.oauth_auto_register').toUpperCase()}
subtitle={$t('admin.oauth_auto_register_description')}
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
title={$t('admin.oauth_auto_launch').toUpperCase()}
subtitle={$t('admin.oauth_auto_launch_description')}
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
title={$t('admin.oauth_mobile_redirect_uri_override').toUpperCase()}
subtitle={$t('admin.oauth_mobile_redirect_uri_override_description')}
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
@@ -190,7 +204,7 @@
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
label={$t('admin.oauth_mobile_redirect_uri').toUpperCase()}
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
@@ -201,13 +215,17 @@
</div>
</SettingAccordion>
<SettingAccordion key="password" title="Password" subtitle="Manage password login settings">
<SettingAccordion
key="password"
title={$t('admin.password_settings')}
subtitle={$t('admin.password_settings_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
title="ENABLED"
title={$t('enabled')}
{disabled}
subtitle="Login with email and password"
subtitle={$t('admin.password_enable_description')}
bind:checked={config.passwordLogin.enabled}
/>
</div>

View File

@@ -22,6 +22,7 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -42,7 +43,7 @@
>H.264 codec</a
>,
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer"
>HEVC codec</a
>{$t('admin.transcoding_hevc_codec')}</a
>
and
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer"
@@ -53,17 +54,17 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="CONSTANT RATE FACTOR (-crf)"
desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files."
label={$t('admin.transcoding_constant_rate_factor')}
desc={$t('admin.transcoding_constant_rate_factor_description')}
bind:value={config.ffmpeg.crf}
required={true}
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
/>
<SettingSelect
label="PRESET (-preset)"
label={$t('admin.transcoding_preset_preset')}
{disabled}
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above faster."
desc={$t('admin.transcoding_preset_preset_description')}
bind:value={config.ffmpeg.preset}
name="preset"
options={[
@@ -81,9 +82,9 @@
/>
<SettingSelect
label="AUDIO CODEC"
label={$t('admin.transcoding_audio_codec').toUpperCase()}
{disabled}
desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
desc={$t('admin.transcoding_audio_codec_description')}
bind:value={config.ffmpeg.targetAudioCodec}
options={[
{ value: AudioCodec.Aac, text: 'aac' },
@@ -99,9 +100,9 @@
/>
<SettingCheckboxes
label="ACCEPTED AUDIO CODECS"
label={$t('admin.transcoding_accepted_audio_codecs').toUpperCase()}
{disabled}
desc="Select which audio codecs do not need to be transcoded. Only used for certain transcode policies."
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={config.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
options={[
@@ -113,9 +114,9 @@
/>
<SettingSelect
label="VIDEO CODEC"
label={$t('admin.transcoding_video_codec').toUpperCase()}
{disabled}
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files. AV1 is the most efficient codec but lacks support on older devices."
desc={$t('admin.transcoding_video_codec_description')}
bind:value={config.ffmpeg.targetVideoCodec}
options={[
{ value: VideoCodec.H264, text: 'h264' },
@@ -129,9 +130,9 @@
/>
<SettingCheckboxes
label="ACCEPTED VIDEO CODECS"
label={$t('admin.transcoding_accepted_video_codecs').toUpperCase()}
{disabled}
desc="Select which video codecs do not need to be transcoded. Only used for certain transcode policies."
desc={$t('admin.transcoding_accepted_video_codecs_description')}
bind:value={config.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
options={[
@@ -144,9 +145,9 @@
/>
<SettingSelect
label="TARGET RESOLUTION"
label={$t('admin.transcoding_target_resolution').toUpperCase()}
{disabled}
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
desc={$t('admin.transcoding_target_resolution_description')}
bind:value={config.ffmpeg.targetResolution}
options={[
{ value: '2160', text: '4k' },
@@ -163,8 +164,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
{disabled}
label="MAX BITRATE"
desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
label={$t('admin.transcoding_max_bitrate').toUpperCase()}
desc={$t('admin.transcoding_max_bitrate_description')}
bind:value={config.ffmpeg.maxBitrate}
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
/>
@@ -172,44 +173,44 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="THREADS"
desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
label={$t('admin.transcoding_threads').toUpperCase()}
desc={$t('admin.transcoding_threads_description')}
bind:value={config.ffmpeg.threads}
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
/>
<SettingSelect
label="TRANSCODE POLICY"
label={$t('admin.transcoding_transcode_policy').toUpperCase()}
{disabled}
desc="Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled)."
desc={$t('admin.transcoding_transcode_policy_description')}
bind:value={config.ffmpeg.transcode}
name="transcode"
options={[
{ value: TranscodePolicy.All, text: 'All videos' },
{
value: TranscodePolicy.Optimal,
text: 'Videos higher than target resolution or not in an accepted format',
text: $t('admin.transcoding_optimal_description'),
},
{
value: TranscodePolicy.Bitrate,
text: 'Videos higher than max bitrate or not in an accepted format',
text: $t('admin.transcoding_bitrate_description'),
},
{
value: TranscodePolicy.Required,
text: 'Only videos not in an accepted format',
text: $t('admin.transcoding_required_description'),
},
{
value: TranscodePolicy.Disabled,
text: "Don't transcode any videos, may break playback on some clients",
text: $t('admin.transcoding_disabled_description'),
},
]}
isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode}
/>
<SettingSelect
label="TONE-MAPPING"
label={$t('admin.transcoding_tone_mapping').toUpperCase()}
{disabled}
desc="Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness."
desc={$t('admin.transcoding_tone_mapping_description')}
bind:value={config.ffmpeg.tonemap}
name="tonemap"
options={[
@@ -234,58 +235,58 @@
/>
<SettingSwitch
title="TWO-PASS ENCODING"
title={$t('admin.transcoding_two_pass_encoding').toUpperCase()}
{disabled}
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
bind:checked={config.ffmpeg.twoPass}
isEdited={config.ffmpeg.twoPass !== savedConfig.ffmpeg.twoPass}
/>
<SettingAccordion
key="hardware-acceleration"
title="Hardware Acceleration"
subtitle="Experimental; much faster, but will have lower quality at the same bitrate"
title={$t('admin.transcoding_hardware_acceleration')}
subtitle={$t('admin.transcoding_hardware_acceleration_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="ACCELERATION API"
label={$t('admin.transcoding_acceleration_api').toUpperCase()}
{disabled}
desc="The API that will interact with your device to accelerate transcoding. This setting is 'best effort': it will fallback to software transcoding on failure. VP9 may or may not work depending on your hardware."
desc={$t('admin.transcoding_acceleration_api_description')}
bind:value={config.ffmpeg.accel}
name="accel"
options={[
{ value: TranscodeHWAccel.Nvenc, text: 'NVENC (requires NVIDIA GPU)' },
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
{
value: TranscodeHWAccel.Qsv,
text: 'Quick Sync (requires 7th gen Intel CPU or later)',
text: $t('admin.transcoding_acceleration_qsv'),
},
{
value: TranscodeHWAccel.Vaapi,
text: 'VAAPI',
text: $t('admin.transcoding_acceleration_vaapi'),
},
{
value: TranscodeHWAccel.Rkmpp,
text: 'RKMPP (only on Rockchip SOCs)',
text: $t('admin.transcoding_acceleration_rkmpp'),
},
{
value: TranscodeHWAccel.Disabled,
text: 'Disabled',
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
/>
<SettingSwitch
title="HARDWARE DECODING"
title={$t('admin.transcoding_hardware_decoding').toUpperCase()}
{disabled}
subtitle="Applies only to NVENC and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos."
subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
bind:checked={config.ffmpeg.accelDecode}
isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
/>
<SettingSelect
label="CONSTANT QUALITY MODE"
desc="ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ."
label={$t('admin.transcoding_constant_quality_mode').toUpperCase()}
desc={$t('admin.transcoding_constant_quality_mode_description')}
bind:value={config.ffmpeg.cqMode}
options={[
{ value: CQMode.Auto, text: 'Auto' },
@@ -297,17 +298,17 @@
/>
<SettingSwitch
title="TEMPORAL AQ"
title={$t('admin.transcoding_temporal_aq').toUpperCase()}
{disabled}
subtitle="Applies only to NVENC. Increases quality of high-detail, low-motion scenes. May not be compatible with older devices."
subtitle={$t('admin.transcoding_temporal_aq_description')}
bind:checked={config.ffmpeg.temporalAQ}
isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PREFERRED HARDWARE DEVICE"
desc="Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding."
label={$t('admin.transcoding_preferred_hardware_device').toUpperCase()}
desc={$t('admin.transcoding_preferred_hardware_device_description')}
bind:value={config.ffmpeg.preferredHwDevice}
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
{disabled}
@@ -317,14 +318,14 @@
<SettingAccordion
key="advanced-options"
title="Advanced"
subtitle="Options most users should not need to change"
title={$t('advanced')}
subtitle={$t('admin.transcoding_advanced_options_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="TONE-MAPPING NPL"
desc="Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically."
label={$t('admin.transcoding_tone_mapping_npl').toUpperCase()}
desc={$t('admin.transcoding_tone_mapping_npl_description')}
bind:value={config.ffmpeg.npl}
isEdited={config.ffmpeg.npl !== savedConfig.ffmpeg.npl}
{disabled}
@@ -332,8 +333,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX B-FRAMES"
desc="Higher values improve compression efficiency, but slow down encoding. May not be compatible with hardware acceleration on older devices. 0 disables B-frames, while -1 sets this value automatically."
label={$t('admin.transcoding_max_b_frames').toUpperCase()}
desc={$t('admin.transcoding_max_b_frames_description')}
bind:value={config.ffmpeg.bframes}
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
{disabled}
@@ -341,8 +342,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="REFERENCE FRAMES"
desc="The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically."
label={$t('admin.transcoding_reference_frames').toUpperCase()}
desc={$t('admin.transcoding_reference_frames_description')}
bind:value={config.ffmpeg.refs}
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
{disabled}
@@ -350,8 +351,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX KEYFRAME INTERVAL"
desc="Sets the maximum frame distance between keyframes. Lower values worsen compression efficiency, but improve seek times and may improve quality in scenes with fast movement. 0 sets this value automatically."
label={$t('admin.transcoding_max_keyframe_interval').toUpperCase()}
desc={$t('admin.transcoding_max_keyframe_interval_description')}
bind:value={config.ffmpeg.gopSize}
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
{disabled}

View File

@@ -11,6 +11,7 @@
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -25,8 +26,8 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="THUMBNAIL FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
label={$t('admin.image_thumbnail_format').toUpperCase()}
desc={$t('admin.image_format_description')}
bind:value={config.image.thumbnailFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
@@ -38,8 +39,8 @@
/>
<SettingSelect
label="THUMBNAIL RESOLUTION"
desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
label={$t('admin.image_thumbnail_resolution').toUpperCase()}
desc={$t('admin.image_thumbnail_resolution_description')}
number
bind:value={config.image.thumbnailSize}
options={[
@@ -55,8 +56,8 @@
/>
<SettingSelect
label="PREVIEW FORMAT"
desc="WebP produces smaller files than JPEG, but is slower to encode."
label={$t('admin.image_preview_format').toUpperCase()}
desc={$t('admin.image_format_description')}
bind:value={config.image.previewFormat}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
@@ -68,8 +69,8 @@
/>
<SettingSelect
label="PREVIEW RESOLUTION"
desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
label={$t('admin.image_preview_resolution').toUpperCase()}
desc={$t('admin.image_preview_resolution_description')}
number
bind:value={config.image.previewSize}
options={[
@@ -85,16 +86,16 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="QUALITY"
desc="Image quality from 1-100. Higher is better for quality but produces larger files."
label={$t('admin.image_quality').toUpperCase()}
desc={$t('admin.image_quality_description')}
bind:value={config.image.quality}
isEdited={config.image.quality !== savedConfig.image.quality}
{disabled}
/>
<SettingSwitch
title="PREFER WIDE GAMUT"
subtitle="Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts."
title={$t('admin.image_prefer_wide_gamut').toUpperCase()}
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
checked={config.image.colorspace === Colorspace.P3}
on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)}
isEdited={config.image.colorspace !== savedConfig.image.colorspace}
@@ -102,8 +103,8 @@
/>
<SettingSwitch
title="PREFER EMBEDDED PREVIEW"
subtitle="Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts."
title={$t('admin.image_prefer_embedded_preview').toUpperCase()}
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
checked={config.image.extractEmbedded}
on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)}
isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded}

View File

@@ -10,6 +10,7 @@
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -17,10 +18,10 @@
export let disabled = false;
const cronExpressionOptions = [
{ title: 'Every night at midnight', expression: '0 0 * * *' },
{ title: 'Every night at 2am', expression: '0 2 * * *' },
{ title: 'Every day at 1pm', expression: '0 13 * * *' },
{ title: 'Every 6 hours', expression: '0 */6 * * *' },
{ title: $t('interval.night_at_midnight'), expression: '0 0 * * *' },
{ title: $t('interval.night_at_twoam'), expression: '0 2 * * *' },
{ title: $t('interval.day_at_onepm'), expression: '0 13 * * *' },
{ title: $t('interval.hours', { values: { hours: 6 } }), expression: '0 */6 * * *' },
];
const dispatch = createEventDispatcher<SettingsEventType>();
@@ -30,16 +31,16 @@
<div in:fade={{ duration: 500 }}>
<SettingAccordion
key="library-watching"
title="Library watching (EXPERIMENTAL)"
subtitle="Automatically watch for changed files"
title={$t('admin.library_watching_settings')}
subtitle={$t('admin.library_watching_settings_description')}
isOpen
>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="Watch filesystem"
title={$t('enable')}
{disabled}
subtitle="Watch external libraries for file changes"
subtitle={$t('admin.library_watching_enable_description')}
bind:checked={config.library.watch.enabled}
/>
</div>
@@ -57,21 +58,21 @@
<SettingAccordion
key="library-scanning"
title="Periodic Scanning"
subtitle="Configure periodic library scanning"
title={$t('admin.library_scanning')}
subtitle={$t('admin.library_scanning_description')}
isOpen
>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
title={$t('enabled').toUpperCase()}
{disabled}
subtitle="Enable periodic library scanning"
subtitle={$t('admin.library_scanning_enable_description')}
bind:checked={config.library.scan.enabled}
/>
<div class="flex flex-col my-2 dark:text-immich-dark-fg">
<label class="text-sm" for="expression-select">Cron Expression Presets</label>
<label class="text-sm" for="expression-select">{$t('admin.library_cron_expression_presets')}</label>
<select
class="p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !config.library.scan.enabled}
@@ -89,7 +90,7 @@
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !config.library.scan.enabled}
label="Cron Expression"
label={$t('admin.library_cron_expression')}
bind:value={config.library.scan.cronExpression}
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
>
@@ -99,7 +100,7 @@
href="https://crontab.guru"
class="underline"
target="_blank"
rel="noreferrer">Crontab Guru</a
rel="noreferrer">{$t('crontab_guru')}</a
>
</p>
</svelte:fragment>

View File

@@ -7,6 +7,7 @@
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -20,10 +21,15 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch title="ENABLED" {disabled} subtitle="Logging" bind:checked={config.logging.enabled} />
<SettingSwitch
title={$t('enabled').toUpperCase()}
{disabled}
subtitle={$t('admin.logging_enable_description')}
bind:checked={config.logging.enabled}
/>
<SettingSelect
label="LEVEL"
desc="When enabled, what log level to use."
label={$t('level').toUpperCase()}
desc={$t('admin.logging_level_description')}
bind:value={config.logging.level}
options={[
{ value: LogLevel.Fatal, text: 'Fatal' },

View File

@@ -12,6 +12,7 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -26,8 +27,8 @@
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
<div class="flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
subtitle="If disabled, all ML features will be disabled regardless of the below settings."
title={$t('enabled').toUpperCase()}
subtitle={$t('admin.machine_learning_enabled_description')}
{disabled}
bind:checked={config.machineLearning.enabled}
/>
@@ -36,8 +37,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="URL"
desc="URL of the machine learning server"
label={$t('url').toUpperCase()}
desc={$t('admin.machine_learning_url_description')}
bind:value={config.machineLearning.url}
required={true}
disabled={disabled || !config.machineLearning.enabled}
@@ -47,13 +48,13 @@
<SettingAccordion
key="smart-search"
title="Smart Search"
subtitle="Search for images semantically using CLIP embeddings"
title={$t('admin.machine_learning_smart_search')}
subtitle={$t('admin.machine_learning_smart_search_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
subtitle="If disabled, images will not be encoded for smart search."
title={$t('enabled').toUpperCase()}
subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
bind:checked={config.machineLearning.clip.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
@@ -62,7 +63,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIP MODEL"
label={$t('admin.machine_learning_clip_model').toUpperCase()}
bind:value={config.machineLearning.clip.modelName}
required={true}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
@@ -78,13 +79,13 @@
<SettingAccordion
key="duplicate-detection"
title="Duplicate Detection"
subtitle="Use CLIP embeddings to find likely duplicates"
title={$t('admin.machine_learning_duplicate_detection')}
subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
subtitle="If disabled, exactly identical assets will still be de-duplicated."
title={$t('enabled').toUpperCase()}
subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
@@ -93,12 +94,12 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX DETECTION DISTANCE"
label={$t('admin.machine_learning_max_detection_distance').toUpperCase()}
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.0005"
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
desc={$t('admin.machine_learning_max_detection_distance_description')}
disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
@@ -108,13 +109,13 @@
<SettingAccordion
key="facial-recognition"
title="Facial Recognition"
subtitle="Detect, recognize and group faces in images"
title={$t('admin.machine_learning_facial_recognition')}
subtitle={$t('admin.machine_learning_facial_recognition_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
subtitle="If disabled, images will not be encoded for facial recognition and will not populate the People section in the Explore page."
title={$t('enabled').toUpperCase()}
subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
bind:checked={config.machineLearning.facialRecognition.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
@@ -122,8 +123,8 @@
<hr />
<SettingSelect
label="FACIAL RECOGNITION MODEL"
desc="Models are listed in descending order of size. Larger models are slower and use more memory, but produce better results. Note that you must re-run the Face Detection job for all images upon changing a model."
label={$t('admin.machine_learning_facial_recognition_model').toUpperCase()}
desc={$t('admin.machine_learning_facial_recognition_model_description')}
name="facial-recognition-model"
bind:value={config.machineLearning.facialRecognition.modelName}
options={[
@@ -139,8 +140,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MIN DETECTION SCORE"
desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
label={$t('admin.machine_learning_min_detection_score').toUpperCase()}
desc={$t('admin.machine_learning_min_detection_score_description')}
bind:value={config.machineLearning.facialRecognition.minScore}
step="0.1"
min={0}
@@ -152,8 +153,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX RECOGNITION DISTANCE"
desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
label={$t('admin.machine_learning_max_recognition_distance').toUpperCase()}
desc={$t('admin.machine_learning_max_recognition_distance_description')}
bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.1"
min={0}
@@ -165,8 +166,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MIN RECOGNIZED FACES"
desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
label={$t('admin.machine_learning_min_recognized_faces').toUpperCase()}
desc={$t('admin.machine_learning_min_recognized_faces_description')}
bind:value={config.machineLearning.facialRecognition.minFaces}
step="1"
min={1}

View File

@@ -10,6 +10,7 @@
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -23,12 +24,12 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4">
<SettingAccordion key="map" title="Map Settings" subtitle="Manage map settings">
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
title={$t('enabled').toUpperCase()}
{disabled}
subtitle="Enable map features"
subtitle={$t('admin.map_enable_description')}
bind:checked={config.map.enabled}
/>
@@ -36,16 +37,16 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Light Style"
desc="URL to a style.json map theme"
label={$t('admin.map_light_style')}
desc={$t('admin.map_style_description')}
bind:value={config.map.lightStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Dark Style"
desc="URL to a style.json map theme"
label={$t('admin.map_dark_style')}
desc={$t('admin.map_style_description')}
bind:value={config.map.darkStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
@@ -53,22 +54,22 @@
</div></SettingAccordion
>
<SettingAccordion key="reverse-geocoding" title="Reverse Geocoding Settings">
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
<svelte:fragment slot="subtitle">
<p class="text-sm dark:text-immich-dark-fg">
Manage <a
href="https://immich.app/docs/features/reverse-geocoding"
class="underline"
target="_blank"
rel="noreferrer">Reverse Geocoding</a
rel="noreferrer">{$t('admin.map_reverse_geocoding')}</a
> settings
</p>
</svelte:fragment>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
title={$t('enabled').toUpperCase()}
{disabled}
subtitle="Enable reverse geocoding"
subtitle={$t('admin.map_reverse_geocoding_enable_description')}
bind:checked={config.reverseGeocoding.enabled}
/>
</div></SettingAccordion

View File

@@ -6,6 +6,7 @@
import type { SettingsEventType } from '../admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -20,8 +21,8 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4">
<SettingSwitch
title="ENABLED"
subtitle="Enable periodic requests to GitHub to check for new releases"
title={$t('enabled').toUpperCase()}
subtitle={$t('admin.version_check_enabled_description')}
bind:checked={config.newVersionCheck.enabled}
{disabled}
/>

View File

@@ -10,6 +10,7 @@
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -23,11 +24,11 @@
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion key="email" title="Email" subtitle="Settings for sending email notifications">
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="Enabled"
subtitle="Enable email notifications"
title={$t('enabled')}
subtitle={$t('admin.notification_enable_email_notifications')}
{disabled}
bind:checked={config.notifications.smtp.enabled}
/>
@@ -37,8 +38,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label="Host"
desc="Host of the email server (e.g. smtp.immich.app)"
label={$t('host')}
desc={$t('admin.notification_email_host_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.host}
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
@@ -47,8 +48,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required
label="Port"
desc="Port of the email server (e.g 25, 465, or 587)"
label={$t('port')}
desc={$t('admin.notification_email_port_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.port}
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
@@ -56,8 +57,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Username"
desc="Username to use when authenticating with the email server"
label={$t('username')}
desc={$t('admin.notification_email_username_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.username}
isEdited={config.notifications.smtp.transport.username !==
@@ -66,8 +67,8 @@
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Password"
desc="Password to use when authenticating with the email server"
label={$t('password')}
desc={$t('admin.notification_email_password_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.password}
isEdited={config.notifications.smtp.transport.password !==
@@ -75,8 +76,8 @@
/>
<SettingSwitch
title="Ignore certificate errors"
subtitle="Ignore TLS certificate validation errors (not recommended)"
title={$t('admin.notification_email_ignore_certificate_errors')}
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:checked={config.notifications.smtp.transport.ignoreCert}
/>
@@ -86,8 +87,8 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label="From address"
desc="Sender email address, for example: &quot;Immich Photo Server <noreply@immich.app>&quot;"
label={$t('admin.notification_email_from_address')}
desc={$t('admin.notification_email_from_address_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}

View File

@@ -8,6 +8,7 @@
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -23,16 +24,16 @@
<div class="mt-4 ml-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="EXTERNAL DOMAIN"
desc="Domain for public shared links, including http(s)://"
label={$t('admin.server_external_domain_settings').toUpperCase()}
desc={$t('admin.server_external_domain_settings_description')}
bind:value={config.server.externalDomain}
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="WELCOME MESSAGE"
desc="A message that is displayed on the login page."
label={$t('admin.server_welcome_message').toUpperCase()}
desc={$t('admin.server_welcome_message_description')}
bind:value={config.server.loginPageMessage}
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/>

View File

@@ -20,6 +20,7 @@
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -54,10 +55,10 @@
const substitutions: Record<string, string> = {
filename: 'IMAGE_56437',
ext: 'jpg',
filetype: 'IMG',
filetype: $t('img').toUpperCase(),
filetypefull: 'IMAGE',
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
album: 'Album Name',
album: $t('album_name'),
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
@@ -107,18 +108,18 @@
{#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
<SettingSwitch
title="ENABLED"
title={$t('enabled').toUpperCase()}
{disabled}
subtitle="Enable storage template engine"
subtitle={$t('admin.storage_template_enable_description')}
bind:checked={config.storageTemplate.enabled}
isEdited={!(config.storageTemplate.enabled === savedConfig.storageTemplate.enabled)}
/>
{#if !minified}
<SettingSwitch
title="HASH VERIFICATION ENABLED"
title={$t('admin.storage_template_hash_verification_enabled').toUpperCase()}
{disabled}
subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
bind:checked={config.storageTemplate.hashVerificationEnabled}
isEdited={!(
config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled
@@ -129,7 +130,7 @@
{#if config.storageTemplate.enabled}
<hr />
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
@@ -146,10 +147,10 @@
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Template</h3>
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
<div class="my-2 text-sm">
<h4>PREVIEW</h4>
<h4>{$t('preview').toUpperCase()}</h4>
</div>
<p class="text-sm">
@@ -172,7 +173,7 @@
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="flex flex-col my-2">
<label class="text-sm" for="preset-select">PRESET</label>
<label class="text-sm" for="preset-select">{$t('preset').toUpperCase()}</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !config.storageTemplate.enabled}
@@ -188,7 +189,7 @@
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label="TEMPLATE"
label={$t('template').toUpperCase()}
disabled={disabled || !config.storageTemplate.enabled}
required
inputType={SettingInputFieldType.TEXT}
@@ -197,19 +198,24 @@
/>
<div class="flex-0">
<SettingInputField label="EXTENSION" inputType={SettingInputFieldType.TEXT} value={'.jpg'} disabled />
<SettingInputField
label={$t('extension')}
inputType={SettingInputFieldType.TEXT}
value={'.jpg'}
disabled
/>
</div>
</div>
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Notes</h3>
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
<section class="flex flex-col gap-2">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously
uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>{$t('admin.storage_template_migration_job')}</a
>.
</p>
<p>
@@ -217,7 +223,7 @@
assets, so manually running the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
>Storage Migration Job</a
>{$t('admin.storage_template_migration_job')}</a
>
is required in order to successfully use the variable.
</p>

View File

@@ -2,6 +2,7 @@
import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
export let options: SystemConfigTemplateStorageOptionDto;
@@ -21,7 +22,7 @@
</div>
<div class="flex gap-[40px]">
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">YEAR</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year').toUpperCase()}</p>
<ul>
{#each options.yearOptions as yearFormat}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
@@ -30,7 +31,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">MONTH</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month').toUpperCase()}</p>
<ul>
{#each options.monthOptions as monthFormat}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
@@ -39,7 +40,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">WEEK</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week').toUpperCase()}</p>
<ul>
{#each options.weekOptions as weekFormat}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
@@ -48,7 +49,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">DAY</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day').toUpperCase()}</p>
<ul>
{#each options.dayOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -57,7 +58,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">HOUR</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour').toUpperCase()}</p>
<ul>
{#each options.hourOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -66,7 +67,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">MINUTE</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute').toUpperCase()}</p>
<ul>
{#each options.minuteOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
@@ -75,7 +76,7 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">SECOND</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second').toUpperCase()}</p>
<ul>
{#each options.secondOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>

View File

@@ -1,11 +1,15 @@
<script lang="ts">
import { t } from 'svelte-i18n';
</script>
<div class="mt-4 text-sm">
<h4>OTHER VARIABLES</h4>
<h4>{$t('other_variables').toUpperCase()}</h4>
</div>
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="flex gap-[50px]">
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILENAME</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename').toUpperCase()}</p>
<ul>
<li>{`{{filename}}`} - IMG_123</li>
<li>{`{{ext}}`} - jpg</li>
@@ -13,14 +17,14 @@
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">FILETYPE</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype').toUpperCase()}</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
<div>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">OTHER</p>
<p class="font-medium text-immich-primary dark:text-immich-dark-primary uppercase">{$t('other').toUpperCase()}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{album}}`} - Album Name</li>

View File

@@ -6,6 +6,7 @@
import type { SettingsEventType } from '../admin-settings';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -21,8 +22,8 @@
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingTextarea
{disabled}
label="Custom CSS"
desc="Cascading Style Sheets allow the design of Immich to be customized."
label={$t('admin.theme_custom_css_settings')}
desc={$t('admin.theme_custom_css_settings_description')}
bind:value={config.theme.customCss}
required={true}
isEdited={config.theme.customCss !== savedConfig.theme.customCss}

View File

@@ -9,6 +9,7 @@
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -23,9 +24,9 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title="ENABLED"
title={$t('enabled').toUpperCase()}
{disabled}
subtitle="Enable Trash features"
subtitle={$t('admin.trash_enabled_description')}
bind:checked={config.trash.enabled}
/>
@@ -33,8 +34,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="Number of days"
desc="Number of days to keep the assets in trash before permanently removing them"
label={$t('admin.trash_number_of_days')}
desc={$t('admin.trash_number_of_days_description')}
bind:value={config.trash.days}
required={true}
disabled={disabled || !config.trash.enabled}

View File

@@ -9,6 +9,7 @@
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -25,8 +26,8 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
min={1}
label="DELETE DELAY"
desc="Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution."
label={$t('admin.user_delete_delay_settings').toUpperCase()}
desc={$t('admin.user_delete_delay_settings_description')}
bind:value={config.user.deleteDelay}
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
/>

View File

@@ -2,6 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { albumFactory } from '@test-data';
import '@testing-library/jest-dom';
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
import { init, register, waitLocale } from 'svelte-i18n';
import AlbumCard from '../album-card.svelte';
const onShowContextMenu = vi.fn();
@@ -9,6 +10,12 @@ const onShowContextMenu = vi.fn();
describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>;
beforeAll(async () => {
await init({ fallbackLocale: 'en-US' });
register('en-US', () => import('$lib/i18n/en-US.json'));
await waitLocale('en-US');
});
it.each([
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),

View File

@@ -8,6 +8,7 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
export let showOwner = false;
@@ -35,7 +36,7 @@
>
<CircleIconButton
color="opaque"
title="Show album options"
title={$t('show_album_options')}
icon={mdiDotsVertical}
size="20"
padding="2"
@@ -76,14 +77,14 @@
{#if showOwner}
{#if $user.id === album.ownerId}
<p>Owned</p>
<p>{$t('owned')}</p>
{:else if album.owner}
<p>Shared by {album.owner.name}</p>
{:else}
<p>Shared</p>
<p>{$t('shared')}</p>
{/if}
{:else if album.shared}
<p>Shared</p>
<p>{$t('shared')}</p>
{/if}
</span>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getAssetThumbnailUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto | undefined;
export let preload = false;
@@ -15,7 +16,7 @@
<img
loading={preload ? 'eager' : 'lazy'}
src={thumbnailUrl}
alt={album?.albumName ?? 'Unknown Album'}
alt={album?.albumName ?? $t('unknown_album')}
class="z-0 rounded-xl object-cover {css}"
data-testid="album-image"
draggable="false"
@@ -25,7 +26,7 @@
loading={preload ? 'eager' : 'lazy'}
src="$lib/assets/no-thumbnail.png"
sizes="min(271px,186px)"
alt={album?.albumName ?? 'Empty Album'}
alt={album?.albumName ?? $t('empty_album')}
class="z-0 rounded-xl object-cover {css}"
data-testid="album-image"
draggable="false"

View File

@@ -1,9 +1,14 @@
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import { init } from 'svelte-i18n';
import { describe } from 'vitest';
describe('AlbumDescription component', () => {
beforeAll(async () => {
await init({ fallbackLocale: 'en-US' });
});
it('shows an AutogrowTextarea component when isOwned is true', () => {
render(AlbumDescription, { isOwned: true, id: '', description: '' });
const autogrowTextarea = screen.getByTestId('autogrow-textarea');

View File

@@ -2,6 +2,7 @@
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { t } from 'svelte-i18n';
export let id: string;
export let description: string;
@@ -16,7 +17,7 @@
},
});
} catch (error) {
handleError(error, 'Error updating album description');
handleError(error, $t('errors.unable_to_save_album'));
}
description = newDescription;
};
@@ -27,7 +28,7 @@
content={description}
class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
onContentUpdate={handleUpdateDescription}
placeholder="Add a description"
placeholder={$t('add_a_description')}
/>
{:else if description}
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">

View File

@@ -10,6 +10,7 @@
import type { RenderedOption } from '../elements/dropdown.svelte';
import { handleError } from '$lib/utils/handle-error';
import { findKey } from 'lodash-es';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
export let order: AssetOrder | undefined;
@@ -17,8 +18,8 @@
export let onChangeOrder: (order: AssetOrder) => void;
const options: Record<AssetOrder, RenderedOption> = {
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: 'Oldest first' },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: 'Newest first' },
[AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
[AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
};
$: selectedOption = order ? options[order] : options[AssetOrder.Desc];
@@ -45,19 +46,19 @@
});
onChangeOrder(order);
} catch (error) {
handleError(error, 'Error updating album order');
handleError(error, $t('errors.unable_to_save_album'));
}
};
</script>
<FullScreenModal title="Options" onClose={() => dispatch('close')}>
<FullScreenModal title={$t('options')} onClose={() => dispatch('close')}>
<div class="items-center justify-center">
<div class="py-2">
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
<h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
<div class="grid p-2 gap-y-2">
{#if order}
<SettingDropdown
title="Display order"
title={$t('display_order')}
options={Object.values(options)}
selectedOption={options[order]}
onToggle={handleToggle}
@@ -65,27 +66,27 @@
{/if}
<SettingSwitch
title="Comments & likes"
subtitle="Let others respond"
subtitle={$t('let_others_respond')}
checked={album.isActivityEnabled}
on:toggle={() => dispatch('toggleEnableActivity')}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
<div class="p-2">
<button type="button" class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>Invite People</div>
<div>{$t('invite_people')}</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{user.name}</div>
<div>Owner</div>
<div>{$t('owner')}</div>
</div>
{#each album.albumUsers as { user } (user.id)}
<div class="flex items-center gap-2 py-2">

View File

@@ -2,6 +2,7 @@
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/actions/shortcut';
import { t } from 'svelte-i18n';
export let id: string;
export let albumName: string;
@@ -22,7 +23,7 @@
},
});
} catch (error) {
handleError(error, 'Unable to update album name');
handleError(error, $t('errors.unable_to_save_album'));
return;
}
albumName = newAlbumName;
@@ -38,6 +39,6 @@
type="text"
bind:value={newAlbumName}
disabled={!isOwned}
title="Edit Title"
placeholder="Add a title"
title={$t('edit_title')}
placeholder={$t('add_a_title')}
/>

View File

@@ -18,6 +18,7 @@
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
import { t } from 'svelte-i18n';
export let sharedLink: SharedLinkResponseDto;
export let user: UserResponseDto | undefined = undefined;
@@ -72,14 +73,18 @@
<svelte:fragment slot="trailing">
{#if sharedLink.allowUpload}
<CircleIconButton
title="Add Photos"
title={$t('add_photos')}
on:click={() => openFileUploadDialog({ albumId: album.id })}
icon={mdiFileImagePlusOutline}
/>
{/if}
{#if album.assetCount > 0 && sharedLink.allowDownload}
<CircleIconButton title="Download" on:click={() => downloadAlbum(album)} icon={mdiFolderDownloadOutline} />
<CircleIconButton
title={$t('download')}
on:click={() => downloadAlbum(album)}
icon={mdiFolderDownloadOutline}
/>
{/if}
<ThemeButton />

View File

@@ -34,6 +34,7 @@
import GroupTab from '$lib/components/elements/group-tab.svelte';
import { createAlbumAndRedirect, collapseAllAlbumGroups, expandAllAlbumGroups } from '$lib/utils/album-utils';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
export let albumGroups: string[];
export let searchQuery: string;
@@ -100,20 +101,20 @@
<!-- Search Albums -->
<div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
<SearchBar placeholder="Search albums" bind:name={searchQuery} showLoadingSpinner={false} />
<SearchBar placeholder={$t('search_albums')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>
<!-- Create Album -->
<LinkButton on:click={() => createAlbumAndRedirect()}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
<p class="hidden md:block">Create album</p>
<p class="hidden md:block">{$t('create_album')}</p>
</div>
</LinkButton>
<!-- Sort Albums -->
<Dropdown
title="Sort albums by..."
title={$t('sort_albums_by')}
options={Object.values(sortOptionsMetadata)}
selectedOption={selectedSortOption}
on:select={({ detail }) => handleChangeSortBy(detail)}
@@ -125,7 +126,7 @@
<!-- Group Albums -->
<Dropdown
title="Group albums by..."
title={$t('group_albums_by')}
options={Object.values(groupOptionsMetadata)}
selectedOption={selectedGroupOption}
on:select={({ detail }) => handleChangeGroupBy(detail)}
@@ -141,7 +142,7 @@
<!-- Expand Album Groups -->
<div class="hidden xl:flex gap-0">
<div class="block">
<LinkButton title="Expand all" on:click={() => expandAllAlbumGroups()}>
<LinkButton title={$t('expand_all')} on:click={() => expandAllAlbumGroups()}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldMoreHorizontal} size="18" />
</div>
@@ -150,7 +151,7 @@
<!-- Collapse Album Groups -->
<div class="block">
<LinkButton title="Collapse all" on:click={() => collapseAllAlbumGroups(albumGroups)}>
<LinkButton title={$t('collapse_all')} on:click={() => collapseAllAlbumGroups(albumGroups)}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiUnfoldLessHorizontal} size="18" />
</div>
@@ -165,10 +166,10 @@
<div class="flex place-items-center gap-2 text-sm">
{#if $albumViewSettings.view === AlbumViewMode.List}
<Icon path={mdiViewGridOutline} size="18" />
<p class="hidden md:block">Covers</p>
<p class="hidden md:block">{$t('covers')}</p>
{:else}
<Icon path={mdiFormatListBulletedSquare} size="18" />
<p class="hidden md:block">List</p>
<p class="hidden md:block">{$t('list')}</p>
{/if}
</div>
</LinkButton>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { groupBy, orderBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto } from '@immich/sdk';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
@@ -33,6 +33,7 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
export let ownedAlbums: AlbumResponseDto[] = [];
export let sharedAlbums: AlbumResponseDto[] = [];
@@ -55,8 +56,8 @@
[AlbumGroupBy.None]: (order, albums): AlbumGroup[] => {
return [
{
id: 'Albums',
name: 'Albums',
id: $t('albums'),
name: $t('albums'),
albums,
},
];
@@ -64,7 +65,7 @@
/** Group by year */
[AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => {
const unknownYear = 'Unknown Year';
const unknownYear = $t('unknown_year');
const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto;
const groupedByYear = groupBy(albums, (album) => {
@@ -111,7 +112,7 @@
return sortedByOwnerNames.map(([ownerId, albums]) => ({
id: ownerId,
name: ownerId === currentUserId ? 'My albums' : albums[0].owner.name,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
albums,
}));
},
@@ -267,9 +268,19 @@
};
const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => {
await deleteAlbum({
id: albumToDelete.id,
});
try {
await deleteAlbum({
id: albumToDelete.id,
});
} catch (error) {
// In rare cases deleting an album completes after the list of albums has been requested,
// leading to a bad request error.
// Since the album is already deleted, the error is ignored.
const isBadRequest = isHttpError(error) && error.status === 400;
if (!isBadRequest) {
throw error;
}
}
ownedAlbums = ownedAlbums.filter(({ id }) => id !== albumToDelete.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id);
@@ -304,7 +315,7 @@
await handleDeleteAlbum(albumToDelete);
} catch {
notificationController.show({
message: 'Error deleting album',
message: $t('errors.errors.unable_to_delete_album'),
type: NotificationType.Error,
});
} finally {
@@ -326,7 +337,7 @@
albumToEdit = null;
notificationController.show({
message: 'Album info updated',
message: $t('album_info_updated'),
type: NotificationType.Info,
button: {
text: 'View Album',
@@ -352,7 +363,7 @@
});
updateAlbumInfo(album);
} catch (error) {
handleError(error, 'Error adding users to album');
handleError(error, $t('errors.unable_to_add_album_users'));
} finally {
albumToShare = null;
}

View File

@@ -7,6 +7,7 @@
import { locale } from '$lib/stores/preferences.store';
import { mdiShareVariantOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
@@ -33,7 +34,7 @@
path={mdiShareVariantOutline}
size="16"
class="inline ml-1 opacity-70"
title={album.ownerId === $user.id ? 'Shared by you' : `Shared by ${album.owner.name}`}
title={album.ownerId === $user.id ? $t('shared_by_you') : `Shared by ${album.owner.name}`}
/>
{/if}
</td>

View File

@@ -18,6 +18,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
export let onClose: () => void;
@@ -38,7 +39,7 @@
try {
currentUser = await getMyUser();
} catch (error) {
handleError(error, 'Unable to refresh user');
handleError(error, $t('errors.unable_to_refresh_user'));
}
});
@@ -66,7 +67,7 @@
const message = userId === 'me' ? `Left ${album.albumName}` : `Removed ${selectedRemoveUser.name}`;
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, 'Unable to remove user');
handleError(error, $t('errors.unable_to_remove_album_users'));
} finally {
selectedRemoveUser = null;
}
@@ -79,7 +80,7 @@
dispatch('refreshAlbum');
notificationController.show({ type: NotificationType.Info, message });
} catch (error) {
handleError(error, 'Unable to set user role');
handleError(error, $t('errors.unable_to_change_album_user_role'));
} finally {
selectedRemoveUser = null;
}
@@ -87,7 +88,7 @@
</script>
{#if !selectedRemoveUser}
<FullScreenModal title="Options" {onClose}>
<FullScreenModal title={$t('options')} {onClose}>
<section class="immich-scrollbar max-h-[400px] overflow-y-auto pb-4">
<div class="flex w-full place-items-center justify-between gap-4 p-5">
<div class="flex place-items-center gap-4">
@@ -96,7 +97,7 @@
</div>
<div id="icon-{album.owner.id}" class="flex place-items-center">
<p class="text-sm">Owner</p>
<p class="text-sm">{$t('owner')}</p>
</div>
</div>
{#each album.albumUsers as { user, role }}
@@ -119,7 +120,7 @@
{#if isOwned}
<div>
<CircleIconButton
title="Options"
title={$t('options')}
on:click={(event) => showContextMenu(event, user)}
icon={mdiDotsVertical}
size="20"
@@ -128,14 +129,17 @@
{#if selectedMenuUser === user}
<ContextMenu {...position} onClose={() => (selectedMenuUser = null)}>
{#if role === AlbumUserRole.Viewer}
<MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text="Allow edits" />
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
text="Disallow edits"
text={$t('disallow_edits')}
/>
{/if}
<MenuOption on:click={handleMenuRemove} text="Remove" />
<MenuOption on:click={handleMenuRemove} text={$t('remove')} />
</ContextMenu>
{/if}
</div>
@@ -144,7 +148,7 @@
type="button"
on:click={() => (selectedRemoveUser = user)}
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
>Leave</button
>{$t('leave')}</button
>
{/if}
</div>
@@ -158,7 +162,7 @@
<ConfirmDialog
title="Leave album?"
prompt="Are you sure you want to leave {album.albumName}?"
confirmText="Leave"
confirmText={$t('leave')}
onConfirm={handleRemoveUser}
onCancel={() => (selectedRemoveUser = null)}
/>
@@ -168,7 +172,7 @@
<ConfirmDialog
title="Remove user?"
prompt="Are you sure you want to remove {selectedRemoveUser.name}?"
confirmText="Remove"
confirmText={$t('remove')}
onConfirm={handleRemoveUser}
onCancel={() => (selectedRemoveUser = null)}
/>

View File

@@ -6,6 +6,7 @@
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
@@ -26,7 +27,7 @@
>
<ControlAppBar on:close={() => dispatch('close')}>
<svelte:fragment slot="leading">
<p class="text-lg">Select album cover</p>
<p class="text-lg">{$t('select_album_cover')}</p>
</svelte:fragment>
<svelte:fragment slot="trailing">

View File

@@ -16,6 +16,7 @@
import { createEventDispatcher, onMount } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { t } from 'svelte-i18n';
export let album: AlbumResponseDto;
export let onClose: () => void;
@@ -23,9 +24,9 @@
let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
{ title: 'Editor', value: AlbumUserRole.Editor, icon: mdiPencil },
{ title: 'Viewer', value: AlbumUserRole.Viewer, icon: mdiEye },
{ title: 'Remove', value: 'none' },
{ title: $t('editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
{ title: $t('viewer'), value: AlbumUserRole.Viewer, icon: mdiEye },
{ title: $t('remove'), value: 'none' },
];
const dispatch = createEventDispatcher<{
@@ -70,10 +71,10 @@
};
</script>
<FullScreenModal title="Invite to album" showLogo {onClose}>
<FullScreenModal title={$t('invite_to_album')} showLogo {onClose}>
{#if Object.keys(selectedUsers).length > 0}
<div class="mb-2 py-2 sticky">
<p class="text-xs font-medium">SELECTED</p>
<p class="text-xs font-medium">{$t('selected').toUpperCase()}</p>
<div class="my-2">
{#each Object.values(selectedUsers) as { user }}
{#key user.id}
@@ -95,7 +96,7 @@
</div>
<Dropdown
title="Role"
title={$t('role')}
options={roleOptions}
render={({ title, icon }) => ({ title, icon })}
on:select={({ detail: { value } }) => handleChangeRole(user, value)}
@@ -115,7 +116,7 @@
<div class="immich-scrollbar max-h-[500px] overflow-y-auto">
{#if users.length > 0 && users.length !== Object.keys(selectedUsers).length}
<p class="text-xs font-medium">SUGGESTIONS</p>
<p class="text-xs font-medium">{$t('suggestions').toUpperCase()}</p>
<div class="my-2">
{#each users as user}
@@ -154,7 +155,7 @@
dispatch(
'select',
Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
)}>Add</Button
)}>{$t('add')}</Button
>
</div>
{/if}
@@ -168,7 +169,7 @@
on:click={() => dispatch('share')}
>
<Icon path={mdiLink} size={24} />
<p class="text-sm">Create link</p>
<p class="text-sm">{$t('create_link')}</p>
</button>
{#if sharedLinks.length}
@@ -177,7 +178,7 @@
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
>
<Icon path={mdiShareCircle} size={24} />
<p class="text-sm">View links</p>
<p class="text-sm">{$t('view_links')}</p>
</a>
{/if}
</div>

View File

@@ -3,6 +3,7 @@
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import Icon from '../elements/icon.svelte';
import { t } from 'svelte-i18n';
export let isLiked: ActivityResponseDto | null;
export let numberOfComments: number | undefined;
@@ -29,7 +30,7 @@
{#if numberOfComments}
<div class="text-xl">{numberOfComments}</div>
{:else if !isShowActivity}
<div class="text-lg">Say something</div>
<div class="text-lg">{$t('say_something')}</div>
{/if}
</div>
</button>

View File

@@ -25,6 +25,7 @@
import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
import { shortcut } from '$lib/actions/shortcut';
import { t } from 'svelte-i18n';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -91,7 +92,7 @@
try {
reactions = await getActivities({ assetId, albumId });
} catch (error) {
handleError(error, 'Error when fetching reactions');
handleError(error, $t('errors.unable_to_load_asset_activity'));
}
};
@@ -120,7 +121,7 @@
type: NotificationType.Info,
});
} catch (error) {
handleError(error, `Can't remove ${reaction.type}`);
handleError(error, $t('errors.unable_to_remove_reaction'));
}
};
@@ -140,7 +141,7 @@
// Re-render the activity feed
reactions = reactions;
} catch (error) {
handleError(error, "Can't add your comment");
handleError(error, $t('errors.unable_to_add_comment'));
} finally {
clearTimeout(timeout);
}
@@ -159,9 +160,9 @@
bind:clientHeight={activityHeight}
>
<div class="flex place-items-center gap-2">
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} title="Close" />
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} title={$t('close')} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Activity</p>
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
</div>
</div>
{#if innerHeight}
@@ -190,7 +191,7 @@
<div class="flex items-start w-fit pt-[5px]">
<CircleIconButton
icon={mdiDotsVertical}
title="Comment options"
title={$t('comment_options')}
size="16"
on:click={() => (showDeleteReaction[index] ? '' : showOptionsMenu(index))}
/>
@@ -242,7 +243,7 @@
<div class="flex items-start w-fit">
<CircleIconButton
icon={mdiDotsVertical}
title="Reaction options"
title={$t('reaction_options')}
size="16"
on:click={() => (showDeleteReaction[index] ? '' : showOptionsMenu(index))}
/>
@@ -289,7 +290,7 @@
bind:this={textArea}
bind:value={message}
use:autoGrowHeight={'5px'}
placeholder={disabled ? 'Comments are disabled' : 'Say something'}
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
on:input={() => autoGrowHeight(textArea, '5px')}
use:shortcut={{
shortcut: { key: 'Enter' },
@@ -308,7 +309,12 @@
</div>
{:else if message}
<div class="flex items-end w-fit ml-0">
<CircleIconButton title="Send message" size="15" icon={mdiSend} class="dark:text-immich-dark-gray" />
<CircleIconButton
title={$t('send_message')}
size="15"
icon={mdiSend}
class="dark:text-immich-dark-gray"
/>
</div>
{/if}
</form>

View File

@@ -38,6 +38,7 @@
import { createEventDispatcher } from 'svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null;
@@ -107,7 +108,7 @@
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
>
<div class="text-white">
<CircleIconButton color="opaque" icon={mdiArrowLeft} title="Go back" on:click={() => dispatch('back')} />
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={() => dispatch('back')} />
</div>
<div
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
@@ -118,7 +119,7 @@
color="opaque"
icon={mdiShareVariantOutline}
on:click={() => dispatch('showShareModal')}
title="Share"
title={$t('share')}
/>
{/if}
{#if asset.isOffline}
@@ -126,7 +127,7 @@
color="opaque"
icon={mdiAlertOutline}
on:click={() => dispatch('showDetail')}
title="Asset Offline"
title={$t('asset_offline')}
/>
{/if}
{#if showMotionPlayButton}
@@ -134,14 +135,14 @@
<CircleIconButton
color="opaque"
icon={mdiMotionPauseOutline}
title="Stop Motion Photo"
title={$t('stop_motion_photo')}
on:click={() => dispatch('stopMotionPhoto')}
/>
{:else}
<CircleIconButton
color="opaque"
icon={mdiPlaySpeed}
title="Play Motion Photo"
title={$t('play_motion_photo')}
on:click={() => dispatch('playMotionPhoto')}
/>
{/if}
@@ -151,7 +152,7 @@
color="opaque"
hideMobile={true}
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
title="Zoom Image"
title={$t('zoom_image')}
on:click={() => {
const zoomImage = new CustomEvent('zoomImage');
window.dispatchEvent(zoomImage);
@@ -162,7 +163,7 @@
<CircleIconButton
color="opaque"
icon={mdiContentCopy}
title="Copy Image"
title={$t('copy_image')}
on:click={() => {
const copyEvent = new CustomEvent('copyImage');
window.dispatchEvent(copyEvent);
@@ -175,7 +176,7 @@
color="opaque"
icon={mdiFolderDownloadOutline}
on:click={() => dispatch('download')}
title="Download"
title={$t('download')}
/>
{/if}
@@ -184,7 +185,7 @@
color="opaque"
icon={mdiInformationOutline}
on:click={() => dispatch('showDetail')}
title="Info"
title={$t('info')}
/>
{/if}
@@ -193,45 +194,58 @@
color="opaque"
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
on:click={() => dispatch('favorite')}
title={asset.isFavorite ? 'Unfavorite' : 'Favorite'}
title={asset.isFavorite ? $t('unfavorite') : $t('favorite')}
/>
{/if}
{#if isOwner}
<CircleIconButton color="opaque" icon={mdiDeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<CircleIconButton
color="opaque"
icon={mdiDeleteOutline}
on:click={() => dispatch('delete')}
title={$t('delete')}
/>
<div
use:clickOutside={{
onOutclick: () => (isShowAssetOptions = false),
onEscape: () => (isShowAssetOptions = false),
}}
>
<CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title="More" />
<CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title={$t('more')} />
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">
{#if showSlideshow}
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text="Slideshow" />
<MenuOption
icon={mdiPresentationPlay}
on:click={() => onMenuClick('playSlideShow')}
text={$t('slideshow')}
/>
{/if}
{#if showDownloadButton}
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" />
<MenuOption
icon={mdiFolderDownloadOutline}
on:click={() => onMenuClick('download')}
text={$t('download')}
/>
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text="Restore" />
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
{:else}
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" />
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
on:click={() => onMenuClick('addToSharedAlbum')}
text="Add to shared album"
text={$t('add_to_shared_album')}
/>
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text="Un-stack" />
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
{/if}
{#if album}
<MenuOption
text="Set as album cover"
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
on:click={() => onMenuClick('setAsAlbumCover')}
/>
@@ -240,18 +254,18 @@
<MenuOption
icon={mdiAccountCircleOutline}
on:click={() => onMenuClick('asProfileImage')}
text="Set as profile picture"
text={$t('set_as_profile_picture')}
/>
{/if}
<MenuOption
on:click={() => dispatch('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
text={asset.isArchived ? $t('unarchive') : $t('archive')}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text="Replace with upload"
text={$t('replace_with_upload')}
/>
<hr />
<MenuOption

View File

@@ -53,6 +53,7 @@
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
import { websocketEvents } from '$lib/stores/websocket';
import { t } from 'svelte-i18n';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@@ -169,7 +170,7 @@
});
isLiked = data.length > 0 ? data[0] : null;
} catch (error) {
handleError(error, "Can't get Favorite");
handleError(error, $t('errors.unable_to_load_liked_status'));
}
}
};
@@ -352,11 +353,11 @@
dispatch('action', { type: AssetAction.TRASH, asset });
notificationController.show({
message: 'Moved to trash',
message: $t('moved_to_trash'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to trash asset');
handleError(error, $t('errors.unable_to_trash_asset'));
}
};
@@ -367,11 +368,11 @@
dispatch('action', { type: AssetAction.DELETE, asset });
notificationController.show({
message: 'Permanently deleted asset',
message: $t('permanently_deleted_asset'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to delete asset');
handleError(error, $t('errors.unable_to_delete_asset'));
} finally {
isShowDeleteConfirmation = false;
}
@@ -428,7 +429,7 @@
message: `Restored asset`,
});
} catch (error) {
handleError(error, 'Error restoring asset');
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
@@ -483,7 +484,7 @@
try {
await assetViewerHtmlElement.requestFullscreen();
} catch (error) {
console.error('Error entering fullscreen', error);
handleError(error, $t('errors.unable_to_enter_fullscreen'));
$slideshowState = SlideshowState.StopSlideshow;
}
};
@@ -495,7 +496,7 @@
await document.exitFullscreen();
}
} catch (error) {
console.error('Error exiting fullscreen', error);
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
$stopSlideshowProgress = true;
$slideshowState = SlideshowState.None;
@@ -534,7 +535,7 @@
});
notificationController.show({
type: NotificationType.Info,
message: 'Album cover updated',
message: $t('album_cover_updated'),
timeout: 1500,
});
} catch (error) {
@@ -606,7 +607,7 @@
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label={$t('view_previous_asset')}>
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
</div>
@@ -703,7 +704,7 @@
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label={$t('view_next_asset')}>
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
</div>

View File

@@ -6,6 +6,7 @@
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let isOwner: boolean;
@@ -20,7 +21,7 @@
message: 'Asset description has been updated',
});
} catch (error) {
handleError(error, 'Cannot update the description');
handleError(error, $t('cannot_update_the_description'));
}
description = newDescription;
};
@@ -32,7 +33,7 @@
content={description}
class="max-h-[500px] w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
onContentUpdate={handleFocusOut}
placeholder="Add a description"
placeholder={$t('add_a_description')}
/>
</section>
{:else if description}

View File

@@ -5,6 +5,7 @@
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n';
export let isOwner: boolean;
export let asset: AssetResponseDto;
@@ -20,7 +21,7 @@
updateAssetDto: { latitude: gps.lat, longitude: gps.lng },
});
} catch (error) {
handleError(error, 'Unable to change location');
handleError(error, $t('errors.unable_to_change_location'));
}
}
</script>
@@ -30,7 +31,7 @@
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? 'Edit location' : ''}
title={isOwner ? $t('edit_location') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
@@ -63,12 +64,12 @@
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)}
title="Add location"
title={$t('add_location')}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<p>Add a location</p>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />

View File

@@ -40,6 +40,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import AlbumListItemDetails from './album-list-item-details.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import { t } from 'svelte-i18n';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@@ -130,21 +131,21 @@
try {
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } });
} catch (error) {
handleError(error, 'Unable to change date');
handleError(error, $t('errors.unable_to_change_date'));
}
}
</script>
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={() => dispatch('close')} />
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
</div>
{#if asset.isOffline}
<section class="px-4 py-4">
<div role="alert">
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">Asset offline</div>
<div class="rounded-t bg-red-500 px-4 py-2 font-bold text-white">{$t('asset_offline')}</div>
<div class="rounded-b border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p>
This asset is offline. Immich can not access its file location. Please ensure the asset is available and
@@ -160,11 +161,11 @@
{#if !isSharedLink() && people.length > 0}
<section class="px-4 py-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>PEOPLE</h2>
<h2>{$t('people').toUpperCase()}</h2>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<CircleIconButton
title="Show hidden people"
title={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
padding="1"
buttonSize="32"
@@ -172,7 +173,7 @@
/>
{/if}
<CircleIconButton
title="Edit people"
title={$t('edit_people')}
icon={mdiPencil}
padding="1"
size="20"
@@ -247,10 +248,10 @@
<div class="px-4 py-4">
{#if asset.exifInfo}
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>DETAILS</h2>
<h2>{$t('details').toUpperCase()}</h2>
</div>
{:else}
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
<p class="text-sm">{$t('no_exif_info_available').toUpperCase()}</p>
{/if}
{#if asset.exifInfo?.dateTimeOriginal}
@@ -261,7 +262,7 @@
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeDate = true) : null)}
title={isOwner ? 'Edit date' : ''}
title={isOwner ? $t('edit_date') : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
@@ -340,7 +341,7 @@
{#if isOwner}
<CircleIconButton
icon={mdiInformationOutline}
title="Show file location"
title={$t('show_file_location')}
size="16"
padding="2"
on:click={toggleAssetPath}
@@ -448,7 +449,7 @@
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="text-sm">SHARED BY</p>
<p class="text-sm">{$t('shared_by').toUpperCase()}</p>
<div class="flex gap-4 pt-4">
<div>
<UserAvatar user={asset.owner} size="md" />
@@ -465,7 +466,7 @@
{#if albums.length > 0}
<section class="p-6 dark:text-immich-dark-fg">
<p class="pb-4 text-sm">APPEARS IN</p>
<p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
{#each albums as album}
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
<div class="flex gap-4 py-2 hover:cursor-pointer items-center">

View File

@@ -5,6 +5,7 @@
import { asByteUnitString } from '../../utils/byte-units';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
const abort = (downloadKey: string, download: DownloadProgress) => {
download.abort?.abort();
@@ -17,7 +18,7 @@
transition:fly={{ x: -100, duration: 350 }}
class="absolute bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
>
<p class="mb-2 text-xs text-gray-500">DOWNLOADING</p>
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
{#each Object.keys($downloadAssets) as downloadKey (downloadKey)}
{@const download = $downloadAssets[downloadKey]}
@@ -40,7 +41,7 @@
</div>
<div class="absolute right-2">
<CircleIconButton
title="Close"
title={$t('close')}
on:click={() => abort(downloadKey, download)}
size="20"
icon={mdiClose}

View File

@@ -15,6 +15,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { getAltText } from '$lib/utils/thumbnail-util';
import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { t } from 'svelte-i18n';
const { slideshowState, slideshowLook } = slideshowStore;
@@ -99,7 +100,7 @@
await copyImageToClipboard(assetData);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
message: $t('copied_image_to_clipboard'),
timeout: 3000,
});
} catch (error) {
@@ -134,7 +135,7 @@
});
const onCopyShortcut = (event: KeyboardEvent) => {
if (window.getSelection()?.type === 'Range') {
if (window.getSelection()?.type === $t('range')) {
return;
}
event.preventDefault();

View File

@@ -6,6 +6,7 @@
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
export let isFullScreen: boolean;
export let onNext = () => {};
@@ -94,23 +95,28 @@
transition:fly={{ duration: 150 }}
role="navigation"
>
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title="Exit Slideshow" />
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} />
<CircleIconButton
buttonSize="50"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
/>
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} />
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} />
<CircleIconButton
buttonSize="50"
icon={mdiCog}
on:click={() => (showSettings = !showSettings)}
title={$t('next')}
/>
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title="Previous" />
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title="Next" />
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
{#if !isFullScreen}
<CircleIconButton
buttonSize="50"
icon={mdiFullscreen}
on:click={onSetToFullScreen}
title="Set Slideshow to fullscreen"
title={$t('set_slideshow_to_fullscreen')}
/>
{/if}
</div>

View File

@@ -6,6 +6,7 @@
import { AssetMediaSize } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
export let assetId: string;
export let loopVideo: boolean;
@@ -31,7 +32,7 @@
await video.play();
dispatch('onVideoStarted');
} catch (error) {
handleError(error, 'Unable to play video');
handleError(error, $t('errors.unable_to_play_video'));
} finally {
isVideoLoading = false;
}

View File

@@ -4,6 +4,7 @@
import type { SearchOptions } from '$lib/utils/dipatch';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let name: string;
export let roundedBottom = true;
@@ -34,7 +35,7 @@
>
<CircleIconButton
icon={mdiMagnify}
title="Search"
title={$t('search')}
size="16"
padding="2"
on:click={() => dispatch('search', { force: true })}
@@ -54,6 +55,6 @@
</div>
{/if}
{#if name}
<CircleIconButton icon={mdiClose} title="Clear value" size="16" padding="2" on:click={resetSearch} />
<CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" on:click={resetSearch} />
{/if}
</div>

View File

@@ -12,6 +12,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let peopleWithFaces: AssetFaceResponseDto[];
export let allPeople: PersonResponseDto[];
@@ -119,19 +120,19 @@
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}
<div class="flex items-center gap-2">
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Select face</p>
<CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p>
</div>
<div class="flex justify-end gap-2">
<CircleIconButton
icon={mdiMagnify}
title="Search for existing person"
title={$t('search_for_existing_person')}
on:click={() => {
searchFaces = true;
}}
/>
{#if !isShowLoadingNewPerson}
<CircleIconButton icon={mdiPlus} title="Create new person" on:click={handleCreatePerson} />
<CircleIconButton icon={mdiPlus} title={$t('create_new_person')} on:click={handleCreatePerson} />
{:else}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
@@ -139,7 +140,7 @@
{/if}
</div>
{:else}
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
<CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
<div class="w-full flex">
<SearchPeople
type="input"
@@ -153,11 +154,11 @@
</div>
{/if}
</div>
<CircleIconButton icon={mdiClose} title="Cancel search" on:click={() => (searchFaces = false)} />
<CircleIconButton icon={mdiClose} title={$t('cancel_search')} on:click={() => (searchFaces = false)} />
{/if}
</div>
<div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">All people</h2>
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#each showPeople as person (person.id)}
{#if person.id !== editedPerson.id}

View File

@@ -4,6 +4,7 @@
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { t } from 'svelte-i18n';
export let person: PersonResponseDto;
export let name: string;
@@ -35,6 +36,6 @@
inputClass="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
bind:showLoadingSpinner={isSearchingPeople}
/>
<Button size="sm" type="submit">Done</Button>
<Button size="sm" type="submit">{$t('done')}</Button>
</form>
</div>

View File

@@ -17,6 +17,7 @@
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
export let person: PersonResponseDto;
let people: PersonResponseDto[] = [];
@@ -86,7 +87,7 @@
});
dispatch('merge', mergedPerson);
} catch (error) {
handleError(error, 'Cannot merge people');
handleError(error, $t('cannot_merge_people'));
}
};
</script>
@@ -134,7 +135,7 @@
{#if selectedPeople.length === 1}
<div class="absolute bottom-2">
<CircleIconButton
title="Swap merge direction"
title={$t('swap_merge_direction')}
icon={mdiSwapHorizontal}
size="24"
on:click={handleSwapPeople}

View File

@@ -8,6 +8,7 @@
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';
export let personMerge1: PersonResponseDto;
export let personMerge2: PersonResponseDto;
@@ -44,7 +45,7 @@
</div>
<div class="mx-0.5 flex md:mx-2">
<CircleIconButton
title="Swap merge direction"
title={$t('swap_merge_direction')}
icon={mdiMerge}
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
/>
@@ -104,7 +105,7 @@
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
</div>
<svelte:fragment slot="sticky-bottom">
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>{$t('no')}</Button>
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>{$t('yes')}</Button>
</svelte:fragment>
</FullScreenModal>

View File

@@ -16,6 +16,7 @@
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
export let person: PersonResponseDto;
export let preload = false;
@@ -76,7 +77,7 @@
<CircleIconButton
color="opaque"
icon={mdiDotsVertical}
title="Show person options"
title={$t('show_person_options')}
size="20"
padding="2"
class="icon-white-drop-shadow"
@@ -88,17 +89,17 @@
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
<MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text="Hide person" />
<MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text="Change name" />
<MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
<MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption
on:click={() => onMenuClick('set-birth-date')}
icon={mdiCalendarEditOutline}
text="Set date of birth"
text={$t('set_date_of_birth')}
/>
<MenuOption
on:click={() => onMenuClick('merge-people')}
icon={mdiAccountMultipleCheckOutline}
text="Merge people"
text={$t('merge_people')}
/>
</ContextMenu>
</Portal>

View File

@@ -3,6 +3,7 @@
import { createEventDispatcher } from 'svelte';
import FaceThumbnail from './face-thumbnail.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { t } from 'svelte-i18n';
export let screenHeight: number;
export let people: PersonResponseDto[];
@@ -25,7 +26,7 @@
</script>
<div class=" w-40 sm:w-48 md:w-96 h-14 mb-8">
<SearchPeople type="searchBar" placeholder="Search people" bind:searchName={name} bind:searchedPeopleLocal />
<SearchPeople type="searchBar" placeholder={$t('search_people')} bind:searchName={name} bind:searchedPeopleLocal />
</div>
<div

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