Parcourir la source

Merge branch 'immich-app:main' into feat-web-photo-editor

faupau il y a 1 an
Parent
commit
376a64ac17
76 fichiers modifiés avec 1382 ajouts et 748 suppressions
  1. 2 1
      .github/workflows/cache-cleanup.yml
  2. 2 1
      .github/workflows/cli-release.yml
  3. 1 1
      .github/workflows/docker-cleanup.yml
  4. 2 1
      .github/workflows/docker.yml
  5. 14 10
      .github/workflows/test.yml
  6. 517 75
      cli/package-lock.json
  7. 3 3
      cli/package.json
  8. 1 1
      docker/docker-compose.dev.yml
  9. 58 0
      docs/docs/guides/remote-access.md
  10. 104 16
      docs/package-lock.json
  11. 2 2
      docs/package.json
  12. 9 3
      docs/src/theme/SearchBar/algolia.css
  13. 2 2
      machine-learning/Dockerfile
  14. 1 1
      machine-learning/export/Dockerfile
  15. 0 1
      mobile/analysis_options.yaml
  16. 1 1
      mobile/integration_test/test_utils/general_helper.dart
  17. 3 3
      mobile/lib/constants/immich_colors.dart
  18. 19 9
      mobile/lib/extensions/asyncvalue_extensions.dart
  19. 1 1
      mobile/lib/extensions/build_context_extensions.dart
  20. 33 31
      mobile/lib/main.dart
  21. 4 7
      mobile/lib/modules/activities/views/activities_page.dart
  22. 1 1
      mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart
  23. 17 16
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  24. 8 9
      mobile/lib/modules/album/views/album_options_part.dart
  25. 18 26
      mobile/lib/modules/album/views/album_viewer_page.dart
  26. 3 6
      mobile/lib/modules/album/views/asset_selection_page.dart
  27. 3 7
      mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart
  28. 9 8
      mobile/lib/modules/album/views/select_user_for_sharing_page.dart
  29. 34 42
      mobile/lib/modules/archive/views/archive_page.dart
  30. 8 2
      mobile/lib/modules/asset_viewer/ui/advanced_bottom_sheet.dart
  31. 4 4
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  32. 3 0
      mobile/lib/modules/backup/views/backup_controller_page.dart
  33. 15 20
      mobile/lib/modules/favorite/views/favorites_page.dart
  34. 3 7
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  35. 3 1
      mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart
  36. 8 12
      mobile/lib/modules/home/views/home_page.dart
  37. 12 5
      mobile/lib/modules/login/ui/login_form.dart
  38. 1 1
      mobile/lib/modules/memories/ui/memory_lane.dart
  39. 3 5
      mobile/lib/modules/partner/views/partner_detail_page.dart
  40. 12 8
      mobile/lib/modules/partner/views/partner_page.dart
  41. 3 7
      mobile/lib/modules/search/views/all_motion_videos_page.dart
  42. 3 7
      mobile/lib/modules/search/views/all_people_page.dart
  43. 3 7
      mobile/lib/modules/search/views/all_videos_page.dart
  44. 3 7
      mobile/lib/modules/search/views/curated_location_page.dart
  45. 1 3
      mobile/lib/modules/search/views/person_result_page.dart
  46. 3 7
      mobile/lib/modules/search/views/recently_added_page.dart
  47. 8 9
      mobile/lib/modules/search/views/search_page.dart
  48. 2 3
      mobile/lib/modules/settings/views/settings_page.dart
  49. 16 6
      mobile/lib/modules/shared_link/ui/shared_link_item.dart
  50. 6 1
      mobile/lib/modules/shared_link/views/shared_link_edit_page.dart
  51. 8 6
      mobile/lib/modules/shared_link/views/shared_link_page.dart
  52. 12 20
      mobile/lib/modules/trash/views/trash_page.dart
  53. 1 1
      mobile/lib/shared/ui/immich_loading_indicator.dart
  54. 14 11
      mobile/lib/shared/ui/scaffold_error_body.dart
  55. 16 2
      mobile/lib/shared/views/app_log_detail_page.dart
  56. 52 29
      mobile/lib/shared/views/immich_loading_overlay.dart
  57. 18 18
      mobile/lib/utils/immich_app_theme.dart
  58. 2 2
      mobile/lib/utils/url_helper.dart
  59. 20 8
      renovate.json
  60. 1 0
      server/.eslintrc.js
  61. 3 3
      server/Dockerfile
  62. 90 90
      server/package-lock.json
  63. 3 1
      server/src/domain/metadata/metadata.service.ts
  64. 3 1
      server/src/infra/repositories/system-metadata.repository.ts
  65. 1 0
      web/.eslintrc.cjs
  66. 2 1
      web/Dockerfile
  67. 94 131
      web/package-lock.json
  68. 2 3
      web/package.json
  69. 5 2
      web/src/lib/components/admin-page/restore-dialoge.svelte
  70. 2 1
      web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
  71. 12 2
      web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte
  72. 2 0
      web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
  73. 2 0
      web/src/lib/components/faces-page/people-card.svelte
  74. 1 1
      web/src/lib/utils/byte-units.ts
  75. 20 6
      web/src/lib/utils/thumbnail-util.ts
  76. 4 2
      web/src/routes/(user)/people/+page.svelte

+ 2 - 1
.github/workflows/cache-cleanup.yml

@@ -1,4 +1,4 @@
-name: Clean up actions cache on PR close
+name: Cache Cleanup
 on:
   pull_request:
     types:
@@ -10,6 +10,7 @@ concurrency:
 
 jobs:
   cleanup:
+    name: Cleanup
     runs-on: ubuntu-latest
     steps:
       - name: Check out code

+ 2 - 1
.github/workflows/cli-release.yml

@@ -1,9 +1,10 @@
-name: Publish Package to npmjs
+name: CLI Release
 on:
   workflow_dispatch:
 
 jobs:
   publish:
+    name: Publish
     runs-on: ubuntu-latest
     defaults:
       run:

+ 1 - 1
.github/workflows/docker-cleanup.yml

@@ -5,7 +5,7 @@
 #
 # This workflow will not trigger runs on forked repos.
 
-name: Cleanup Old Docker Images
+name: Docker Cleanup
 
 on:
   pull_request:

+ 2 - 1
.github/workflows/docker.yml

@@ -1,4 +1,4 @@
-name: Build and Push Docker Images
+name: Docker
 
 on:
   workflow_dispatch:
@@ -18,6 +18,7 @@ permissions:
 
 jobs:
   build_and_push:
+    name: Build and Push
     runs-on: ubuntu-latest
     strategy:
       # Prevent a failure in one image from stopping the other builds

+ 14 - 10
.github/workflows/test.yml

@@ -11,7 +11,7 @@ concurrency:
 
 jobs:
   e2e-tests:
-    name: Run end-to-end test suites
+    name: Server (e2e)
     runs-on: ubuntu-latest
 
     steps:
@@ -24,7 +24,7 @@ jobs:
         run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
 
   doc-tests:
-    name: Run documentation checks
+    name: Docs
     runs-on: ubuntu-latest
     defaults:
       run:
@@ -45,8 +45,12 @@ jobs:
         run: npm run check
         if: ${{ !cancelled() }}
 
+      - name: Run build
+        run: npm run build
+        if: ${{ !cancelled() }}
+
   server-unit-tests:
-    name: Run server unit test suites and checks
+    name: Server
     runs-on: ubuntu-latest
     defaults:
       run:
@@ -76,7 +80,7 @@ jobs:
         if: ${{ !cancelled() }}
 
   cli-unit-tests:
-    name: Run cli test suites
+    name: CLI
     runs-on: ubuntu-latest
     defaults:
       run:
@@ -106,7 +110,7 @@ jobs:
         if: ${{ !cancelled() }}
 
   web-unit-tests:
-    name: Run web unit test suites and checks
+    name: Web
     runs-on: ubuntu-latest
     defaults:
       run:
@@ -140,7 +144,7 @@ jobs:
       #   if: ${{ !cancelled() }}
 
   mobile-unit-tests:
-    name: Run mobile unit tests
+    name: Mobile
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
@@ -154,7 +158,7 @@ jobs:
         run: flutter test -j 1
 
   ml-unit-tests:
-    name: Run ML unit tests and checks
+    name: Machine Learning
     runs-on: ubuntu-latest
     defaults:
       run:
@@ -184,7 +188,7 @@ jobs:
           poetry run pytest --cov app
 
   generated-api-up-to-date:
-    name: Check generated files are up-to-date
+    name: OpenAPI Clients
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
@@ -205,11 +209,11 @@ jobs:
           exit 1
 
   generated-typeorm-migrations-up-to-date:
-    name: Check generated TypeORM migrations are up-to-date
+    name: TypeORM Migrations
     runs-on: ubuntu-latest
     services:
       postgres:
-        image: postgres
+        image: postgres@sha256:71da05df8c4f1e1bac9b92ebfba2a0eeb183f6ac6a972fd5e55e8146e29efe9c
         env:
           POSTGRES_PASSWORD: postgres
           POSTGRES_USER: postgres

+ 517 - 75
cli/package-lock.json

@@ -29,8 +29,8 @@
         "@types/mime-types": "^2.1.1",
         "@types/mock-fs": "^4.13.1",
         "@types/node": "^20.3.1",
-        "@typescript-eslint/eslint-plugin": "^5.60.1",
-        "@typescript-eslint/parser": "^5.48.1",
+        "@typescript-eslint/eslint-plugin": "^6.0.0",
+        "@typescript-eslint/parser": "^6.0.0",
         "chai": "^4.3.7",
         "eslint": "^8.43.0",
         "eslint-config-prettier": "^9.0.0",
@@ -46,7 +46,7 @@
         "ts-jest": "^29.1.0",
         "ts-node": "^10.9.1",
         "tslib": "^2.5.3",
-        "typescript": "^4.9.4"
+        "typescript": "^5.0.0"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -1646,32 +1646,90 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
-      "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz",
+      "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==",
       "dev": true,
       "dependencies": {
-        "@eslint-community/regexpp": "^4.4.0",
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/type-utils": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@eslint-community/regexpp": "^4.5.1",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/type-utils": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
-        "ignore": "^5.2.0",
-        "natural-compare-lite": "^1.4.0",
-        "semver": "^7.3.7",
-        "tsutils": "^3.21.0"
+        "ignore": "^5.2.4",
+        "natural-compare": "^1.4.0",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^5.0.0",
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+      "dev": true,
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -1679,26 +1737,126 @@
         }
       }
     },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+      "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
-      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz",
+      "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/typescript-estree": "5.62.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+      "dev": true,
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -1706,6 +1864,23 @@
         }
       }
     },
+    "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
     "node_modules/@typescript-eslint/scope-manager": {
       "version": "5.62.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
@@ -1724,25 +1899,82 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
-      "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz",
+      "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
         "debug": "^4.3.4",
-        "tsutils": "^3.21.0"
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "*"
+        "eslint": "^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+      "dev": true,
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
+        "debug": "^4.3.4",
+        "globby": "^11.1.0",
+        "is-glob": "^4.0.3",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -1750,6 +1982,48 @@
         }
       }
     },
+    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+      "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "6.13.1",
+        "eslint-visitor-keys": "^3.4.1"
+      },
+      "engines": {
+        "node": "^16.0.0 || >=18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
     "node_modules/@typescript-eslint/types": {
       "version": "5.62.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
@@ -4864,12 +5138,6 @@
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
-    "node_modules/natural-compare-lite": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
-      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
-      "dev": true
-    },
     "node_modules/node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -6023,6 +6291,18 @@
         "node": ">=8.0"
       }
     },
+    "node_modules/ts-api-utils": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
+      "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
+      "dev": true,
+      "engines": {
+        "node": ">=16.13.0"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.2.0"
+      }
+    },
     "node_modules/ts-jest": {
       "version": "29.1.1",
       "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz",
@@ -6170,16 +6450,16 @@
       }
     },
     "node_modules/typescript": {
-      "version": "4.9.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
-      "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
+      "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
       "dev": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
       },
       "engines": {
-        "node": ">=4.2.0"
+        "node": ">=14.17"
       }
     },
     "node_modules/undici-types": {
@@ -7752,33 +8032,136 @@
       "dev": true
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
-      "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz",
+      "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==",
       "dev": true,
       "requires": {
-        "@eslint-community/regexpp": "^4.4.0",
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/type-utils": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@eslint-community/regexpp": "^4.5.1",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/type-utils": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
-        "ignore": "^5.2.0",
-        "natural-compare-lite": "^1.4.0",
-        "semver": "^7.3.7",
-        "tsutils": "^3.21.0"
+        "ignore": "^5.2.4",
+        "natural-compare": "^1.4.0",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
+      },
+      "dependencies": {
+        "@typescript-eslint/scope-manager": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+          "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1"
+          }
+        },
+        "@typescript-eslint/types": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+          "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+          "dev": true
+        },
+        "@typescript-eslint/typescript-estree": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+          "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1",
+            "debug": "^4.3.4",
+            "globby": "^11.1.0",
+            "is-glob": "^4.0.3",
+            "semver": "^7.5.4",
+            "ts-api-utils": "^1.0.1"
+          }
+        },
+        "@typescript-eslint/utils": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+          "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
+          "dev": true,
+          "requires": {
+            "@eslint-community/eslint-utils": "^4.4.0",
+            "@types/json-schema": "^7.0.12",
+            "@types/semver": "^7.5.0",
+            "@typescript-eslint/scope-manager": "6.13.1",
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/typescript-estree": "6.13.1",
+            "semver": "^7.5.4"
+          }
+        },
+        "@typescript-eslint/visitor-keys": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+          "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "eslint-visitor-keys": "^3.4.1"
+          }
+        }
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
-      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz",
+      "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/typescript-estree": "5.62.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4"
+      },
+      "dependencies": {
+        "@typescript-eslint/scope-manager": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+          "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1"
+          }
+        },
+        "@typescript-eslint/types": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+          "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+          "dev": true
+        },
+        "@typescript-eslint/typescript-estree": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+          "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1",
+            "debug": "^4.3.4",
+            "globby": "^11.1.0",
+            "is-glob": "^4.0.3",
+            "semver": "^7.5.4",
+            "ts-api-utils": "^1.0.1"
+          }
+        },
+        "@typescript-eslint/visitor-keys": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+          "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "eslint-visitor-keys": "^3.4.1"
+          }
+        }
       }
     },
     "@typescript-eslint/scope-manager": {
@@ -7792,15 +8175,73 @@
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
-      "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz",
+      "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/typescript-estree": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
         "debug": "^4.3.4",
-        "tsutils": "^3.21.0"
+        "ts-api-utils": "^1.0.1"
+      },
+      "dependencies": {
+        "@typescript-eslint/scope-manager": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+          "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1"
+          }
+        },
+        "@typescript-eslint/types": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+          "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
+          "dev": true
+        },
+        "@typescript-eslint/typescript-estree": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+          "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/visitor-keys": "6.13.1",
+            "debug": "^4.3.4",
+            "globby": "^11.1.0",
+            "is-glob": "^4.0.3",
+            "semver": "^7.5.4",
+            "ts-api-utils": "^1.0.1"
+          }
+        },
+        "@typescript-eslint/utils": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+          "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
+          "dev": true,
+          "requires": {
+            "@eslint-community/eslint-utils": "^4.4.0",
+            "@types/json-schema": "^7.0.12",
+            "@types/semver": "^7.5.0",
+            "@typescript-eslint/scope-manager": "6.13.1",
+            "@typescript-eslint/types": "6.13.1",
+            "@typescript-eslint/typescript-estree": "6.13.1",
+            "semver": "^7.5.4"
+          }
+        },
+        "@typescript-eslint/visitor-keys": {
+          "version": "6.13.1",
+          "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+          "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
+          "dev": true,
+          "requires": {
+            "@typescript-eslint/types": "6.13.1",
+            "eslint-visitor-keys": "^3.4.1"
+          }
+        }
       }
     },
     "@typescript-eslint/types": {
@@ -10035,12 +10476,6 @@
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
-    "natural-compare-lite": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
-      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
-      "dev": true
-    },
     "node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -10865,6 +11300,13 @@
         "is-number": "^7.0.0"
       }
     },
+    "ts-api-utils": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
+      "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
+      "dev": true,
+      "requires": {}
+    },
     "ts-jest": {
       "version": "29.1.1",
       "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz",
@@ -10947,9 +11389,9 @@
       "dev": true
     },
     "typescript": {
-      "version": "4.9.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
-      "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
+      "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
       "dev": true
     },
     "undici-types": {

+ 3 - 3
cli/package.json

@@ -29,8 +29,8 @@
     "@types/mime-types": "^2.1.1",
     "@types/mock-fs": "^4.13.1",
     "@types/node": "^20.3.1",
-    "@typescript-eslint/eslint-plugin": "^5.60.1",
-    "@typescript-eslint/parser": "^5.48.1",
+    "@typescript-eslint/eslint-plugin": "^6.0.0",
+    "@typescript-eslint/parser": "^6.0.0",
     "chai": "^4.3.7",
     "eslint": "^8.43.0",
     "eslint-config-prettier": "^9.0.0",
@@ -46,7 +46,7 @@
     "ts-jest": "^29.1.0",
     "ts-node": "^10.9.1",
     "tslib": "^2.5.3",
-    "typescript": "^4.9.4"
+    "typescript": "^5.0.0"
   },
   "scripts": {
     "build": "tsc --project tsconfig.build.json",

+ 1 - 1
docker/docker-compose.dev.yml

@@ -59,7 +59,7 @@ services:
     build:
       context: ../web
       dockerfile: Dockerfile
-    command: npm run dev --host
+    command: "node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000"
     env_file:
       - .env
     ports:

+ 58 - 0
docs/docs/guides/remote-access.md

@@ -0,0 +1,58 @@
+# Remote Access
+
+This page gives a few pointers on how to access your Immich instance from outside your LAN.
+
+:::danger
+Never forward port 2283 directly to the internet without additional configuration. This will expose the web interface via http to the internet, making you succeptible to [man in the middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) attacks.
+:::
+
+## Option 1: VPN to home network
+
+You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/)
+
+### Pros:
+
+- Simple to set up and very secure.
+- Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk.
+- Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal.
+
+### Cons:
+
+- If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider.
+- VPN software needs to be installed and active on both server-side and client-side.
+- Requires you to open a port on your router to your server.
+
+## Option 2: Tailscale
+
+If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation).
+
+### Pros
+
+- Minimal configuration needed on server and client sides.
+- You are protected against zero-day vulnerabilities on Immich.
+
+### Cons
+
+- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
+- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
+- Tailscale needs to be installed and running on both server-side and client-side.
+
+## Option 3: Reverse Proxy
+
+A reverse proxy is a service that sits between web servers and clients. A reverse proxy can either be hosted on the server itself or remotely. Clients can connect to the reverse proxy via https, and the proxy relays data to Immich. This setup makes most sense if you have your own domain and want to access your Immich instance just like any other website, from outside your LAN. You can also use a DDNS provider like DuckDNS or no-ip if you don't have a domain. This configuration allows the Immich Android and iphone apps to connect to your server without a VPN or tailscale app on the client side.
+
+If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](https://immich.app/docs/administration/reverse-proxy).
+
+You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser.
+
+A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder.
+
+### Pros
+
+- No additional software needs to be installed client-side
+- If you only need access to the web interface remotely, it is possible to set up access controls that shield you from zero-day vulnerabilities on Immich. [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/) has a generous free tier.
+
+### Cons
+
+- Complex configuration
+- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active developement and the existence of severe security vulnerabilities cannot be ruled out.

+ 104 - 16
docs/package-lock.json

@@ -15,7 +15,7 @@
         "@mdx-js/react": "^1.6.22",
         "autoprefixer": "^10.4.13",
         "classnames": "^2.3.2",
-        "clsx": "^1.2.1",
+        "clsx": "^2.0.0",
         "docusaurus-lunr-search": "^2.3.2",
         "docusaurus-preset-openapi": "^0.6.3",
         "postcss": "^8.4.25",
@@ -28,7 +28,7 @@
       "devDependencies": {
         "@docusaurus/module-type-aliases": "^2.4.1",
         "@tsconfig/docusaurus": "^1.0.5",
-        "prettier": "^2.8.8",
+        "prettier": "^3.0.0",
         "typescript": "^5.1.6"
       },
       "engines": {
@@ -2603,6 +2603,14 @@
         "react-dom": "^16.8.4 || ^17.0.0"
       }
     },
+    "node_modules/@docusaurus/theme-classic/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/@docusaurus/theme-common": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.4.3.tgz",
@@ -2633,6 +2641,14 @@
         "react-dom": "^16.8.4 || ^17.0.0"
       }
     },
+    "node_modules/@docusaurus/theme-common/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/@docusaurus/theme-search-algolia": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.3.tgz",
@@ -2663,6 +2679,14 @@
         "react-dom": "^16.8.4 || ^17.0.0"
       }
     },
+    "node_modules/@docusaurus/theme-search-algolia/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/@docusaurus/theme-translations": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.4.3.tgz",
@@ -4948,9 +4972,9 @@
       }
     },
     "node_modules/clsx": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
-      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
+      "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
       "engines": {
         "node": ">=6"
       }
@@ -5995,6 +6019,14 @@
         "react-dom": "^16.8.4 || ^17"
       }
     },
+    "node_modules/docusaurus-lunr-search/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/docusaurus-plugin-openapi": {
       "version": "0.6.4",
       "resolved": "https://registry.npmjs.org/docusaurus-plugin-openapi/-/docusaurus-plugin-openapi-0.6.4.tgz",
@@ -6025,6 +6057,14 @@
         "react-dom": "^16.8.4 || ^17.0.0"
       }
     },
+    "node_modules/docusaurus-plugin-openapi/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/docusaurus-plugin-openapi/node_modules/fs-extra": {
       "version": "9.1.0",
       "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -6098,6 +6138,14 @@
         "react-dom": "^16.8.4 || ^17.0.0"
       }
     },
+    "node_modules/docusaurus-theme-openapi/node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/dom-converter": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -10801,15 +10849,15 @@
       }
     },
     "node_modules/prettier": {
-      "version": "2.8.8",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
-      "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
+      "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
       "dev": true,
       "bin": {
-        "prettier": "bin-prettier.js"
+        "prettier": "bin/prettier.cjs"
       },
       "engines": {
-        "node": ">=10.13.0"
+        "node": ">=14"
       },
       "funding": {
         "url": "https://github.com/prettier/prettier?sponsor=1"
@@ -16745,6 +16793,13 @@
         "rtlcss": "^3.5.0",
         "tslib": "^2.4.0",
         "utility-types": "^3.10.0"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        }
       }
     },
     "@docusaurus/theme-common": {
@@ -16768,6 +16823,13 @@
         "tslib": "^2.4.0",
         "use-sync-external-store": "^1.2.0",
         "utility-types": "^3.10.0"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        }
       }
     },
     "@docusaurus/theme-search-algolia": {
@@ -16791,6 +16853,13 @@
         "lodash": "^4.17.21",
         "tslib": "^2.4.0",
         "utility-types": "^3.10.0"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        }
       }
     },
     "@docusaurus/theme-translations": {
@@ -18515,9 +18584,9 @@
       }
     },
     "clsx": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
-      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
+      "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="
     },
     "collapse-white-space": {
       "version": "1.0.6",
@@ -19243,6 +19312,13 @@
         "to-vfile": "^6.1.0",
         "unified": "^9.0.0",
         "unist-util-is": "^4.0.2"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        }
       }
     },
     "docusaurus-plugin-openapi": {
@@ -19268,6 +19344,11 @@
         "webpack": "^5.73.0"
       },
       "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        },
         "fs-extra": {
           "version": "9.1.0",
           "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -19321,6 +19402,13 @@
         "react-redux": "^7.2.0",
         "redux-devtools-extension": "^2.13.8",
         "webpack": "^5.73.0"
+      },
+      "dependencies": {
+        "clsx": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+          "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+        }
       }
     },
     "dom-converter": {
@@ -22663,9 +22751,9 @@
       "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="
     },
     "prettier": {
-      "version": "2.8.8",
-      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
-      "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
+      "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
       "dev": true
     },
     "pretty-error": {

+ 2 - 2
docs/package.json

@@ -24,7 +24,7 @@
     "@mdx-js/react": "^1.6.22",
     "autoprefixer": "^10.4.13",
     "classnames": "^2.3.2",
-    "clsx": "^1.2.1",
+    "clsx": "^2.0.0",
     "docusaurus-lunr-search": "^2.3.2",
     "docusaurus-preset-openapi": "^0.6.3",
     "postcss": "^8.4.25",
@@ -37,7 +37,7 @@
   "devDependencies": {
     "@docusaurus/module-type-aliases": "^2.4.1",
     "@tsconfig/docusaurus": "^1.0.5",
-    "prettier": "^2.8.8",
+    "prettier": "^3.0.0",
     "typescript": "^5.1.6"
   },
   "browserslist": {

+ 9 - 3
docs/src/theme/SearchBar/algolia.css

@@ -61,8 +61,12 @@
 .searchbox__input {
   display: inline-block;
   box-sizing: border-box;
-  -webkit-transition: box-shadow 0.4s ease, background 0.4s ease;
-  transition: box-shadow 0.4s ease, background 0.4s ease;
+  -webkit-transition:
+    box-shadow 0.4s ease,
+    background 0.4s ease;
+  transition:
+    box-shadow 0.4s ease,
+    background 0.4s ease;
   border: 0;
   border-radius: 16px;
   box-shadow: inset 0 0 0 1px #cccccc;
@@ -243,7 +247,9 @@
 }
 
 .algolia-autocomplete .ds-dropdown-menu {
-  box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2), 0 2px 3px 0 rgba(0, 0, 0, 0.1);
+  box-shadow:
+    0 1px 0 0 rgba(0, 0, 0, 0.2),
+    0 2px 3px 0 rgba(0, 0, 0, 0.1);
 }
 
 @media (min-width: 601px) {

+ 2 - 2
machine-learning/Dockerfile

@@ -1,4 +1,4 @@
-FROM python:3.11-bookworm as builder
+FROM python:3.11-bookworm@sha256:e5a1b0a194a5fbf94f6e350b31c9a508723f9eeb2f9e9e32c3b65df8520a40cc as builder
 
 ENV PYTHONDONTWRITEBYTECODE=1 \
   PYTHONUNBUFFERED=1 \
@@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
 COPY poetry.lock pyproject.toml ./
 RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
 
-FROM python:3.11-slim-bookworm
+FROM python:3.11-slim-bookworm@sha256:1bc6a3e9356d64ea632791653bc71a56340e8741dab66434ab2739ebf6aed29d
 
 RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*
 

+ 1 - 1
machine-learning/export/Dockerfile

@@ -1,4 +1,4 @@
-FROM mambaorg/micromamba:bookworm-slim as builder
+FROM mambaorg/micromamba:bookworm-slim@sha256:d20c621f3ae42f50f380166b15b6c88b14fa62ab6ea188f2cef33451d64057c7 as builder
 
 ENV NODE_ENV=production \
   TRANSFORMERS_CACHE=/cache \

+ 0 - 1
mobile/analysis_options.yaml

@@ -49,7 +49,6 @@ dart_code_metrics:
     # Common
     - avoid-accessing-collections-by-constant-index
     - avoid-accessing-other-classes-private-members
-    - avoid-async-call-in-sync-function
     - avoid-cascade-after-if-null
     - avoid-collapsible-if
     - avoid-collection-methods-with-unrelated-types

+ 1 - 1
mobile/integration_test/test_utils/general_helper.dart

@@ -45,7 +45,7 @@ class ImmichTestHelper {
     await tester.pumpWidget(
       ProviderScope(
         overrides: [dbProvider.overrideWithValue(db)],
-        child: app.getMainWidget(),
+        child: const app.MainWidget(),
       ),
     );
     // Post run tasks

+ 3 - 3
mobile/lib/constants/immich_colors.dart

@@ -1,5 +1,5 @@
 import 'package:flutter/material.dart';
 
-Color immichBackgroundColor = const Color(0xFFf6f8fe);
-Color immichDarkBackgroundColor = const Color.fromARGB(255, 0, 0, 0);
-Color immichDarkThemePrimaryColor = const Color.fromARGB(255, 173, 203, 250);
+const Color immichBackgroundColor = Color(0xFFf6f8fe);
+const Color immichDarkBackgroundColor = Color.fromARGB(255, 0, 0, 0);
+const Color immichDarkThemePrimaryColor = Color.fromARGB(255, 173, 203, 250);

+ 19 - 9
mobile/lib/extensions/asyncvalue_extensions.dart

@@ -4,22 +4,32 @@ import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/scaffold_error_body.dart';
 import 'package:logging/logging.dart';
 
-extension ScaffoldBody<T> on AsyncValue<T> {
-  static final Logger _scaffoldBodyLog = Logger("ScaffoldBody");
+extension LogOnError<T> on AsyncValue<T> {
+  static final Logger _asyncErrorLogger = Logger("AsyncValue");
 
-  Widget scaffoldBodyWhen({
+  Widget widgetWhen({
+    bool skipLoadingOnRefresh = true,
+    Widget Function()? onLoading,
+    Widget Function(Object? error, StackTrace? stack)? onError,
     required Widget Function(T data) onData,
-    Widget? onError,
   }) {
     if (isLoading) {
-      return const Center(
-        child: ImmichLoadingIndicator(),
-      );
+      bool skip = false;
+      if (isRefreshing) {
+        skip = skipLoadingOnRefresh;
+      }
+
+      if (!skip) {
+        return onLoading?.call() ??
+            const Center(
+              child: ImmichLoadingIndicator(),
+            );
+      }
     }
 
     if (hasError && !hasValue) {
-      _scaffoldBodyLog.severe("Error occured in AsyncValue", error, stackTrace);
-      return onError ?? const ScaffoldErrorBody();
+      _asyncErrorLogger.severe("Error occured", error, stackTrace);
+      return onError?.call(error, stackTrace) ?? const ScaffoldErrorBody();
     }
 
     return onData(requireValue);

+ 1 - 1
mobile/lib/extensions/build_context_extensions.dart

@@ -45,7 +45,7 @@ extension ContextHelper on BuildContext {
   ) =>
       AutoRouter.of(this).navigate(route);
 
-// Auto-Push replace route from the current context
+  // Auto-Push replace route from the current context
   Future<T?> autoReplace<T extends Object?>(PageRouteInfo<dynamic> route) =>
       AutoRouter.of(this).replace(route);
 

+ 33 - 31
mobile/lib/main.dart

@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:io';
 
 import 'package:device_info_plus/device_info_plus.dart';
@@ -7,6 +8,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_displaymode/flutter_displaymode.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:timezone/data/latest.dart';
 import 'package:immich_mobile/constants/locales.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
@@ -28,7 +30,6 @@ import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 import 'package:immich_mobile/shared/services/local_notification.service.dart';
-import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/migration.dart';
@@ -43,10 +44,11 @@ void main() async {
   await initApp();
   await migrateDatabaseIfNeeded(db);
   HttpOverrides.global = HttpSSLCertOverride();
+
   runApp(
     ProviderScope(
       overrides: [dbProvider.overrideWithValue(db)],
-      child: getMainWidget(),
+      child: const MainWidget(),
     ),
   );
 }
@@ -108,16 +110,6 @@ Future<Isar> loadDb() async {
   return db;
 }
 
-Widget getMainWidget() {
-  return EasyLocalization(
-    supportedLocales: locales,
-    path: translationsPath,
-    useFallbackTranslations: true,
-    fallbackLocale: locales.first,
-    child: const ImmichApp(),
-  );
-}
-
 class ImmichApp extends ConsumerStatefulWidget {
   const ImmichApp({super.key});
 
@@ -167,10 +159,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
       // Android 8 does not support transparent app bars
       final info = await DeviceInfoPlugin().androidInfo;
       if (info.version.sdkInt <= 26) {
-        overlayStyle =
-            MediaQuery.of(context).platformBrightness == Brightness.light
-                ? SystemUiOverlayStyle.light
-                : SystemUiOverlayStyle.dark;
+        overlayStyle = context.isDarkTheme
+            ? SystemUiOverlayStyle.dark
+            : SystemUiOverlayStyle.light;
       }
     }
     SystemChrome.setSystemUIOverlayStyle(overlayStyle);
@@ -202,22 +193,33 @@ class ImmichAppState extends ConsumerState<ImmichApp>
       supportedLocales: context.supportedLocales,
       locale: context.locale,
       debugShowCheckedModeBanner: false,
-      home: Stack(
-        children: [
-          MaterialApp.router(
-            title: 'Immich',
-            debugShowCheckedModeBanner: false,
-            themeMode: ref.watch(immichThemeProvider),
-            darkTheme: immichDarkTheme,
-            theme: immichLightTheme,
-            routeInformationParser: router.defaultRouteParser(),
-            routerDelegate: router.delegate(
-              navigatorObservers: () => [TabNavigationObserver(ref: ref)],
-            ),
-          ),
-          const ImmichLoadingOverlay(),
-        ],
+      home: MaterialApp.router(
+        title: 'Immich',
+        debugShowCheckedModeBanner: false,
+        themeMode: ref.watch(immichThemeProvider),
+        darkTheme: immichDarkTheme,
+        theme: immichLightTheme,
+        routeInformationParser: router.defaultRouteParser(),
+        routerDelegate: router.delegate(
+          navigatorObservers: () => [TabNavigationObserver(ref: ref)],
+        ),
       ),
     );
   }
 }
+
+// ignore: prefer-single-widget-per-file
+class MainWidget extends StatelessWidget {
+  const MainWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return EasyLocalization(
+      supportedLocales: locales,
+      path: translationsPath,
+      useFallbackTranslations: true,
+      fallbackLocale: locales.first,
+      child: const ImmichApp(),
+    );
+  }
+}

+ 4 - 7
mobile/lib/modules/activities/views/activities_page.dart

@@ -4,12 +4,12 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/activities/models/activity.model.dart';
 import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/extensions/datetime_extensions.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -88,7 +88,7 @@ class ActivitiesPage extends HookConsumerWidget {
               width: 40,
               height: 30,
               decoration: BoxDecoration(
-                borderRadius: BorderRadius.circular(4),
+                borderRadius: const BorderRadius.all(Radius.circular(4)),
                 image: DecorationImage(
                   image: CachedNetworkImageProvider(
                     getThumbnailUrlForRemoteId(
@@ -231,11 +231,8 @@ class ActivitiesPage extends HookConsumerWidget {
 
     return Scaffold(
       appBar: AppBar(title: Text(appBarTitle)),
-      body: activities.maybeWhen(
-        orElse: () {
-          return const Center(child: ImmichLoadingIndicator());
-        },
-        data: (data) {
+      body: activities.widgetWhen(
+        onData: (data) {
           final liked = data.firstWhereOrNull(
             (a) =>
                 a.type == ActivityType.like &&

+ 1 - 1
mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart

@@ -65,7 +65,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
       }
 
       ref.invalidate(albumDetailProvider(album.id));
-      Navigator.pop(context);
+      context.pop();
     }
 
     return Card(

+ 17 - 16
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -43,6 +43,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
   Widget build(BuildContext context, WidgetRef ref) {
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
+    final isProcessing = useProcessingOverlay();
     final comments = album.shared
         ? ref.watch(
             activityStatisticsStateProvider(
@@ -52,7 +53,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         : 0;
 
     deleteAlbum() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       final bool success;
       if (album.shared) {
@@ -74,7 +75,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     Future<void> showConfirmationDialog() async {
@@ -89,7 +90,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
             ),
             actions: <Widget>[
               TextButton(
-                onPressed: () => Navigator.pop(context, 'Cancel'),
+                onPressed: () => context.pop('Cancel'),
                 child: Text(
                   'Cancel',
                   style: TextStyle(
@@ -100,7 +101,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
               ),
               TextButton(
                 onPressed: () {
-                  Navigator.pop(context, 'Confirm');
+                  context.pop('Confirm');
                   deleteAlbum();
                 },
                 child: Text(
@@ -122,7 +123,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
 
     void onLeaveAlbumPressed() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       bool isSuccess =
           await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album);
@@ -131,7 +132,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         context
             .autoNavigate(const TabControllerRoute(children: [SharingRoute()]));
       } else {
-        Navigator.pop(context);
+        context.pop();
         ImmichToast.show(
           context: context,
           msg: "album_viewer_appbar_share_err_leave".tr(),
@@ -140,11 +141,11 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void onRemoveFromAlbumPressed() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       bool isSuccess =
           await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum(
@@ -153,12 +154,12 @@ class AlbumViewerAppbar extends HookConsumerWidget
               );
 
       if (isSuccess) {
-        Navigator.pop(context);
+        context.pop();
         selectionDisabled();
         ref.watch(albumProvider.notifier).getAllAlbums();
         ref.invalidate(albumDetailProvider(album.id));
       } else {
-        Navigator.pop(context);
+        context.pop();
         ImmichToast.show(
           context: context,
           msg: "album_viewer_appbar_share_err_remove".tr(),
@@ -167,7 +168,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         );
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void handleShareAssets(
@@ -198,9 +199,9 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
 
     void onShareAssetsTo() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
       handleShareAssets(ref, context, selected);
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     buildBottomSheetActions() {
@@ -253,7 +254,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         ListTile(
           leading: const Icon(Icons.person_add_alt_rounded),
           onTap: () {
-            Navigator.pop(context);
+            context.pop();
             onAddUsers!(album);
           },
           title: const Text(
@@ -265,7 +266,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
           leading: const Icon(Icons.share_rounded),
           onTap: () {
             context.autoPush(SharedLinkEditRoute(albumId: album.remoteId));
-            Navigator.pop(context);
+            context.pop();
           },
           title: const Text(
             "control_bottom_app_bar_share",
@@ -286,7 +287,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
         ListTile(
           leading: const Icon(Icons.add_photo_alternate_outlined),
           onTap: () {
-            Navigator.pop(context);
+            context.pop();
             onAddPhotos!(album);
           },
           title: const Text(

+ 8 - 9
mobile/lib/modules/album/views/album_options_part.dart

@@ -24,10 +24,11 @@ class AlbumOptionsPage extends HookConsumerWidget {
     final owner = album.owner.value;
     final userId = ref.watch(authenticationProvider).userId;
     final activityEnabled = useState(album.activityEnabled);
+    final isProcessing = useProcessingOverlay();
     final isOwner = owner?.id == userId;
 
     void showErrorMessage() {
-      Navigator.pop(context);
+      context.pop();
       ImmichToast.show(
         context: context,
         msg: "shared_album_section_people_action_error".tr(),
@@ -37,7 +38,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
     }
 
     void leaveAlbum() async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       try {
         final isSuccess =
@@ -54,11 +55,11 @@ class AlbumOptionsPage extends HookConsumerWidget {
         showErrorMessage();
       }
 
-      ImmichLoadingOverlayController.appLoader.hide();
+      isProcessing.value = false;
     }
 
     void removeUserFromAlbum(User user) async {
-      ImmichLoadingOverlayController.appLoader.show();
+      isProcessing.value = true;
 
       try {
         await ref
@@ -70,8 +71,8 @@ class AlbumOptionsPage extends HookConsumerWidget {
         showErrorMessage();
       }
 
-      Navigator.pop(context);
-      ImmichLoadingOverlayController.appLoader.hide();
+      context.pop();
+      isProcessing.value = false;
     }
 
     void handleUserClick(User user) {
@@ -180,9 +181,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
       appBar: AppBar(
         leading: IconButton(
           icon: const Icon(Icons.arrow_back_ios_new_rounded),
-          onPressed: () {
-            context.autoPop(null);
-          },
+          onPressed: () => context.autoPop(null),
         ),
         centerTitle: true,
         title: Text("translated_text_options".tr()),

+ 18 - 26
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
 import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
@@ -17,7 +18,6 @@ import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 
@@ -33,6 +33,7 @@ class AlbumViewerPage extends HookConsumerWidget {
     final userId = ref.watch(authenticationProvider).userId;
     final selection = useState<Set<Asset>>({});
     final multiSelectEnabled = useState(false);
+    final isProcessing = useProcessingOverlay();
 
     useEffect(
       () {
@@ -75,24 +76,21 @@ class AlbumViewerPage extends HookConsumerWidget {
         ),
       );
 
-      if (returnPayload != null) {
+      if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) {
         // Check if there is new assets add
-        if (returnPayload.selectedAssets.isNotEmpty) {
-          ImmichLoadingOverlayController.appLoader.show();
+        isProcessing.value = true;
 
-          var addAssetsResult =
-              await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
-                    returnPayload.selectedAssets,
-                    albumInfo,
-                  );
+        var addAssetsResult =
+            await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum(
+                  returnPayload.selectedAssets,
+                  albumInfo,
+                );
 
-          if (addAssetsResult != null &&
-              addAssetsResult.successfullyAdded > 0) {
-            ref.invalidate(albumDetailProvider(albumId));
-          }
-
-          ImmichLoadingOverlayController.appLoader.hide();
+        if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
+          ref.invalidate(albumDetailProvider(albumId));
         }
+
+        isProcessing.value = false;
       }
     }
 
@@ -102,7 +100,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
 
       if (sharedUserIds != null) {
-        ImmichLoadingOverlayController.appLoader.show();
+        isProcessing.value = true;
 
         var isSuccess = await ref
             .watch(albumServiceProvider)
@@ -112,7 +110,7 @@ class AlbumViewerPage extends HookConsumerWidget {
           ref.invalidate(albumDetailProvider(album.id));
         }
 
-        ImmichLoadingOverlayController.appLoader.hide();
+        isProcessing.value = false;
       }
     }
 
@@ -260,13 +258,11 @@ class AlbumViewerPage extends HookConsumerWidget {
         error: (error, stackTrace) => AppBar(title: const Text("Error")),
         loading: () => AppBar(),
       ),
-      body: album.when(
-        data: (data) => WillPopScope(
+      body: album.widgetWhen(
+        onData: (data) => WillPopScope(
           onWillPop: onWillPop,
           child: GestureDetector(
-            onTap: () {
-              titleFocusNode.unfocus();
-            },
+            onTap: () => titleFocusNode.unfocus(),
             child: ImmichAssetGrid(
               renderList: data.renderList,
               listener: selectionListener,
@@ -285,10 +281,6 @@ class AlbumViewerPage extends HookConsumerWidget {
             ),
           ),
         ),
-        error: (e, _) => Center(child: Text("Error loading album info!\n$e")),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 3 - 6
mobile/lib/modules/album/views/asset_selection_page.dart

@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
@@ -85,12 +86,8 @@ class AssetSelectionPage extends HookConsumerWidget {
             ),
         ],
       ),
-      body: renderList.when(
-        data: (data) => buildBody(data),
-        error: (error, stackTrace) => Center(
-          child: Text(error.toString()),
-        ),
-        loading: () => const Center(child: CircularProgressIndicator()),
+      body: renderList.widgetWhen(
+        onData: (data) => buildBody(data),
       ),
     );
   }

+ 3 - 7
mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart

@@ -2,11 +2,11 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/user.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
@@ -137,8 +137,8 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
           ),
         ],
       ),
-      body: suggestedShareUsers.when(
-        data: (users) {
+      body: suggestedShareUsers.widgetWhen(
+        onData: (users) {
           for (var sharedUsers in album.sharedUsers) {
             users.removeWhere(
               (u) => u.id == sharedUsers.id || u.id == album.ownerId,
@@ -147,10 +147,6 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
 
           return buildUserList(users);
         },
-        error: (e, _) => Text("Error loading suggested users $e"),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 9 - 8
mobile/lib/modules/album/views/select_user_for_sharing_page.dart

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/album/providers/album_title.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
@@ -9,7 +10,6 @@ import 'package:immich_mobile/modules/album/providers/suggested_shared_users.pro
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/user.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 class SelectUserForSharingPage extends HookConsumerWidget {
@@ -42,7 +42,12 @@ class SelectUserForSharingPage extends HookConsumerWidget {
 
       ScaffoldMessenger(
         child: SnackBar(
-          content: const Text('select_user_for_sharing_page_err_album').tr(),
+          content: Text(
+            'select_user_for_sharing_page_err_album',
+            style: context.textTheme.bodyLarge?.copyWith(
+              color: context.primaryColor,
+            ),
+          ).tr(),
         ),
       );
     }
@@ -166,14 +171,10 @@ class SelectUserForSharingPage extends HookConsumerWidget {
           ),
         ],
       ),
-      body: suggestedShareUsers.when(
-        data: (users) {
+      body: suggestedShareUsers.widgetWhen(
+        onData: (users) {
           return buildUserList(users);
         },
-        error: (e, _) => Text("Error loading suggested users $e"),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 34 - 42
mobile/lib/modules/archive/views/archive_page.dart

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
@@ -48,37 +49,33 @@ class ArchivePage extends HookConsumerWidget {
           child: SizedBox(
             height: 64,
             child: Card(
-              child: Column(
-                children: [
-                  ListTile(
-                    shape: RoundedRectangleBorder(
-                      borderRadius: BorderRadius.circular(10),
-                    ),
-                    leading: const Icon(
-                      Icons.unarchive_rounded,
-                    ),
-                    title: Text(
-                      'control_bottom_app_bar_unarchive'.tr(),
-                      style: const TextStyle(fontSize: 14),
-                    ),
-                    onTap: processing.value
-                        ? null
-                        : () async {
-                            processing.value = true;
-                            try {
-                              await handleArchiveAssets(
-                                ref,
-                                context,
-                                selection.value.toList(),
-                                shouldArchive: false,
-                              );
-                            } finally {
-                              processing.value = false;
-                              selectionEnabledHook.value = false;
-                            }
-                          },
-                  ),
-                ],
+              child: ListTile(
+                shape: const RoundedRectangleBorder(
+                  borderRadius: BorderRadius.all(Radius.circular(10)),
+                ),
+                leading: const Icon(
+                  Icons.unarchive_rounded,
+                ),
+                title: Text(
+                  'control_bottom_app_bar_unarchive'.tr(),
+                  style: const TextStyle(fontSize: 14),
+                ),
+                onTap: processing.value
+                    ? null
+                    : () async {
+                        processing.value = true;
+                        try {
+                          await handleArchiveAssets(
+                            ref,
+                            context,
+                            selection.value.toList(),
+                            shouldArchive: false,
+                          );
+                        } finally {
+                          processing.value = false;
+                          selectionEnabledHook.value = false;
+                        }
+                      },
               ),
             ),
           ),
@@ -86,18 +83,13 @@ class ArchivePage extends HookConsumerWidget {
       );
     }
 
-    return archivedAssets.when(
-      loading: () => Scaffold(
-        appBar: buildAppBar("?"),
-        body: const Center(child: CircularProgressIndicator()),
-      ),
-      error: (error, stackTrace) => Scaffold(
-        appBar: buildAppBar("Error"),
-        body: Center(child: Text(error.toString())),
+    return Scaffold(
+      appBar: archivedAssets.maybeWhen(
+        data: (data) => buildAppBar(data.totalAssets.toString()),
+        orElse: () => buildAppBar("?"),
       ),
-      data: (data) => Scaffold(
-        appBar: buildAppBar(data.totalAssets.toString()),
-        body: data.isEmpty
+      body: archivedAssets.widgetWhen(
+        onData: (data) => data.isEmpty
             ? Center(
                 child: Text('archive_page_no_archived_assets'.tr()),
               )

+ 8 - 2
mobile/lib/modules/asset_viewer/ui/advanced_bottom_sheet.dart

@@ -62,8 +62,14 @@ class AdvancedBottomSheet extends HookConsumerWidget {
                                   ClipboardData(text: assetDetail.toString()),
                                 ).then((_) {
                                   ScaffoldMessenger.of(context).showSnackBar(
-                                    const SnackBar(
-                                      content: Text("Copied to clipboard"),
+                                    SnackBar(
+                                      content: Text(
+                                        "Copied to clipboard",
+                                        style: context.textTheme.bodyLarge
+                                            ?.copyWith(
+                                          color: context.primaryColor,
+                                        ),
+                                      ),
                                     ),
                                   );
                                 });

+ 4 - 4
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -514,7 +514,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                               currentAsset,
                               stackElements.elementAt(stackIndex.value),
                             );
-                        Navigator.pop(ctx);
+                        ctx.pop();
                         context.autoPop();
                       },
                       title: const Text(
@@ -541,7 +541,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                           stackElements.elementAt(1),
                           childrenToRemove: [currentAsset],
                         );
-                        Navigator.pop(ctx);
+                        ctx.pop();
                         context.autoPop();
                       } else {
                         await ref.read(assetStackServiceProvider).updateStack(
@@ -551,7 +551,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                           ],
                         );
                         removeAssetFromStack();
-                        Navigator.pop(ctx);
+                        ctx.pop();
                       }
                     },
                     title: const Text(
@@ -569,7 +569,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                             currentAsset,
                             childrenToRemove: stack,
                           );
-                      Navigator.pop(ctx);
+                      ctx.pop();
                       context.autoPop();
                     },
                     title: const Text(

+ 3 - 0
mobile/lib/modules/backup/views/backup_controller_page.dart

@@ -229,6 +229,9 @@ class BackupControllerPage extends HookConsumerWidget {
       final snackBar = SnackBar(
         content: Text(
           msg.tr(),
+          style: context.textTheme.bodyLarge?.copyWith(
+            color: context.primaryColor,
+          ),
         ),
         backgroundColor: Colors.red,
       );

+ 15 - 20
mobile/lib/modules/favorite/views/favorites_page.dart

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
@@ -62,22 +63,18 @@ class FavoritesPage extends HookConsumerWidget {
           child: SizedBox(
             height: 64,
             child: Card(
-              child: Column(
-                children: [
-                  ListTile(
-                    shape: RoundedRectangleBorder(
-                      borderRadius: BorderRadius.circular(10),
-                    ),
-                    leading: const Icon(
-                      Icons.star_border,
-                    ),
-                    title: const Text(
-                      "Unfavorite",
-                      style: TextStyle(fontSize: 14),
-                    ),
-                    onTap: processing.value ? null : unfavorite,
-                  ),
-                ],
+              child: ListTile(
+                shape: const RoundedRectangleBorder(
+                  borderRadius: BorderRadius.all(Radius.circular(10)),
+                ),
+                leading: const Icon(
+                  Icons.star_border,
+                ),
+                title: const Text(
+                  "Unfavorite",
+                  style: TextStyle(fontSize: 14),
+                ),
+                onTap: processing.value ? null : unfavorite,
               ),
             ),
           ),
@@ -87,10 +84,8 @@ class FavoritesPage extends HookConsumerWidget {
 
     return Scaffold(
       appBar: buildAppBar(),
-      body: ref.watch(favoriteAssetsProvider).when(
-            loading: () => const Center(child: CircularProgressIndicator()),
-            error: (error, stackTrace) => Center(child: Text(error.toString())),
-            data: (data) => data.isEmpty
+      body: ref.watch(favoriteAssetsProvider).widgetWhen(
+            onData: (data) => data.isEmpty
                 ? Center(
                     child: Text('favorites_page_no_favorites'.tr()),
                   )

+ 3 - 7
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -5,13 +5,13 @@ import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 
 class ImmichAssetGrid extends HookConsumerWidget {
@@ -130,12 +130,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
     if (renderList != null) return buildAssetGridView(renderList!);
 
     final renderListFuture = ref.watch(renderListProvider(assets!));
-    return renderListFuture.when(
-      data: (renderList) => buildAssetGridView(renderList),
-      error: (err, stack) => Center(child: Text("$err")),
-      loading: () => const Center(
-        child: ImmichLoadingIndicator(),
-      ),
+    return renderListFuture.widgetWhen(
+      onData: (renderList) => buildAssetGridView(renderList),
     );
   }
 }

+ 3 - 1
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -197,7 +197,9 @@ class ThumbnailImage extends StatelessWidget {
       },
       child: Stack(
         children: [
-          Container(
+          AnimatedContainer(
+            duration: const Duration(milliseconds: 300),
+            curve: Curves.decelerate,
             decoration: BoxDecoration(
               border: multiselectEnabled && isSelected
                   ? Border.all(

+ 8 - 12
mobile/lib/modules/home/views/home_page.dart

@@ -28,6 +28,7 @@ import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/utils/selection_handlers.dart';
 
 class HomePage extends HookConsumerWidget {
@@ -50,7 +51,7 @@ class HomePage extends HookConsumerWidget {
 
     final tipOneOpacity = useState(0.0);
     final refreshCount = useState(0);
-    final processing = useState(false);
+    final processing = useProcessingOverlay();
 
     useEffect(
       () {
@@ -212,10 +213,10 @@ class HomePage extends HookConsumerWidget {
         processing.value = true;
         selectionEnabledHook.value = false;
         try {
-          ref.read(manualUploadProvider.notifier).uploadAssets(
-                context,
-                selection.value.where((a) => a.storage == AssetState.local),
-              );
+            ref.read(manualUploadProvider.notifier).uploadAssets(
+                  context,
+                  selection.value.where((a) => a.storage == AssetState.local),
+                );
         } finally {
           processing.value = false;
         }
@@ -323,16 +324,12 @@ class HomePage extends HookConsumerWidget {
         } else {
           refreshCount.value++;
           // set counter back to 0 if user does not request refresh again
-          Timer(const Duration(seconds: 4), () {
-            refreshCount.value = 0;
-          });
+          Timer(const Duration(seconds: 4), () => refreshCount.value = 0);
         }
       }
 
       buildLoadingIndicator() {
-        Timer(const Duration(seconds: 2), () {
-          tipOneOpacity.value = 1;
-        });
+        Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
 
         return Center(
           child: Column(
@@ -415,7 +412,6 @@ class HomePage extends HookConsumerWidget {
                 selectionAssetState: selectionAssetState.value,
                 onStack: onStack,
               ),
-            if (processing.value) const Center(child: ImmichLoadingIndicator()),
           ],
         ),
       );

+ 12 - 5
mobile/lib/modules/login/ui/login_form.dart

@@ -48,7 +48,7 @@ class LoginForm extends HookConsumerWidget {
     /// Fetch the server login credential and enables oAuth login if necessary
     /// Returns true if successful, false otherwise
     Future<bool> getServerLoginCredential() async {
-      final serverUrl = serverEndpointController.text.trim();
+      final serverUrl = sanitizeUrl(serverEndpointController.text);
 
       // Guard empty URL
       if (serverUrl.isEmpty) {
@@ -127,6 +127,12 @@ class LoginForm extends HookConsumerWidget {
     );
 
     populateTestLoginInfo() {
+      usernameController.text = 'demo@immich.app';
+      passwordController.text = 'demo';
+      serverEndpointController.text = 'https://demo.immich.app';
+    }
+
+    populateTestLoginInfo1() {
       usernameController.text = 'testuser@email.com';
       passwordController.text = 'password';
       serverEndpointController.text = 'http://10.1.15.216:2283/api';
@@ -144,7 +150,7 @@ class LoginForm extends HookConsumerWidget {
             await ref.read(authenticationProvider.notifier).login(
                   usernameController.text,
                   passwordController.text,
-                  serverEndpointController.text.trim(),
+                  sanitizeUrl(serverEndpointController.text),
                 );
         if (isAuthenticated) {
           // Resume backup (if enable) then navigate
@@ -181,7 +187,7 @@ class LoginForm extends HookConsumerWidget {
 
       try {
         oAuthServerConfig = await oAuthService
-            .getOAuthServerConfig(serverEndpointController.text);
+            .getOAuthServerConfig(sanitizeUrl(serverEndpointController.text));
 
         isLoading.value = true;
       } catch (e) {
@@ -203,7 +209,7 @@ class LoginForm extends HookConsumerWidget {
               .watch(authenticationProvider.notifier)
               .setSuccessLoginInfo(
                 accessToken: loginResponseDto.accessToken,
-                serverUrl: serverEndpointController.text,
+                serverUrl: sanitizeUrl(serverEndpointController.text),
               );
 
           if (isSuccess) {
@@ -299,7 +305,7 @@ class LoginForm extends HookConsumerWidget {
           crossAxisAlignment: CrossAxisAlignment.stretch,
           children: [
             Text(
-              serverEndpointController.text,
+              sanitizeUrl(serverEndpointController.text),
               style: context.textTheme.displaySmall,
               textAlign: TextAlign.center,
             ),
@@ -387,6 +393,7 @@ class LoginForm extends HookConsumerWidget {
                     children: [
                       GestureDetector(
                         onDoubleTap: () => populateTestLoginInfo(),
+                        onLongPress: () => populateTestLoginInfo1(),
                         child: RotationTransition(
                           turns: logoAnimationController,
                           child: const ImmichLogo(

+ 1 - 1
mobile/lib/modules/memories/ui/memory_lane.dart

@@ -17,7 +17,7 @@ class MemoryLane extends HookConsumerWidget {
         .whenData(
           (memories) => memories != null
               ? Container(
-                  margin: const EdgeInsets.only(top: 10),
+                  margin: const EdgeInsets.only(top: 10, left: 10),
                   height: 200,
                   child: ListView.builder(
                     scrollDirection: Axis.horizontal,

+ 3 - 5
mobile/lib/modules/partner/views/partner_detail_page.dart

@@ -1,11 +1,11 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 
 class PartnerDetailPage extends HookConsumerWidget {
@@ -71,8 +71,8 @@ class PartnerDetailPage extends HookConsumerWidget {
           ),
         ],
       ),
-      body: assets.when(
-        data: (renderList) => renderList.isEmpty
+      body: assets.widgetWhen(
+        onData: (renderList) => renderList.isEmpty
             ? Padding(
                 padding: const EdgeInsets.all(16),
                 child: Text(
@@ -84,8 +84,6 @@ class PartnerDetailPage extends HookConsumerWidget {
                 onRefresh: () =>
                     ref.read(assetProvider.notifier).getPartnerAssets(partner),
               ),
-        error: (e, _) => Text("Error loading partners:\n$e"),
-        loading: () => const Center(child: ImmichLoadingIndicator()),
       ),
     );
   }

+ 12 - 8
mobile/lib/modules/partner/views/partner_page.dart

@@ -1,6 +1,7 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
 import 'package:immich_mobile/modules/partner/services/partner.service.dart';
 import 'package:immich_mobile/shared/models/user.dart';
@@ -34,7 +35,7 @@ class PartnerPage extends HookConsumerWidget {
             children: [
               for (User u in users)
                 SimpleDialogOption(
-                  onPressed: () => Navigator.pop(context, u),
+                  onPressed: () => context.pop(u),
                   child: Row(
                     children: [
                       Padding(
@@ -70,8 +71,7 @@ class PartnerPage extends HookConsumerWidget {
         builder: (BuildContext context) {
           return ConfirmDialog(
             title: "partner_page_stop_sharing_title",
-            content:
-                "partner_page_stop_sharing_content".tr(args: [u.name]),
+            content: "partner_page_stop_sharing_content".tr(args: [u.name]),
             onOk: () => ref.read(partnerServiceProvider).removePartner(u),
           );
         },
@@ -118,6 +118,7 @@ class PartnerPage extends HookConsumerWidget {
             Padding(
               padding: const EdgeInsets.symmetric(horizontal: 16.0),
               child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
                   Padding(
                     padding: const EdgeInsets.symmetric(vertical: 8),
@@ -126,12 +127,15 @@ class PartnerPage extends HookConsumerWidget {
                       style: TextStyle(fontSize: 14),
                     ).tr(),
                   ),
-                  ElevatedButton.icon(
-                    onPressed: availableUsers.whenOrNull(
-                      data: (data) => addNewUsersHandler,
+                  Align(
+                    alignment: Alignment.center,
+                    child: ElevatedButton.icon(
+                      onPressed: availableUsers.whenOrNull(
+                        data: (data) => addNewUsersHandler,
+                      ),
+                      icon: const Icon(Icons.person_add),
+                      label: const Text("partner_page_add_partner").tr(),
                     ),
-                    icon: const Icon(Icons.person_add),
-                    label: const Text("partner_page_add_partner").tr(),
                   ),
                 ],
               ),

+ 3 - 7
mobile/lib/modules/search/views/all_motion_videos_page.dart

@@ -1,10 +1,10 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/search/providers/all_motion_photos.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class AllMotionPhotosPage extends HookConsumerWidget {
   const AllMotionPhotosPage({super.key});
@@ -21,14 +21,10 @@ class AllMotionPhotosPage extends HookConsumerWidget {
           icon: const Icon(Icons.arrow_back_ios_rounded),
         ),
       ),
-      body: motionPhotos.when(
-        data: (assets) => ImmichAssetGrid(
+      body: motionPhotos.widgetWhen(
+        onData: (assets) => ImmichAssetGrid(
           assets: assets,
         ),
-        error: (e, s) => Text(e.toString()),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 3 - 7
mobile/lib/modules/search/views/all_people_page.dart

@@ -1,10 +1,10 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class AllPeoplePage extends HookConsumerWidget {
   const AllPeoplePage({super.key});
@@ -23,12 +23,8 @@ class AllPeoplePage extends HookConsumerWidget {
           icon: const Icon(Icons.arrow_back_ios_rounded),
         ),
       ),
-      body: curatedPeople.when(
-        loading: () => const Center(child: ImmichLoadingIndicator()),
-        error: (err, stack) => Center(
-          child: Text('Error: $err'),
-        ),
-        data: (people) => ExploreGrid(
+      body: curatedPeople.widgetWhen(
+        onData: (people) => ExploreGrid(
           isPeople: true,
           curatedContent: people,
         ),

+ 3 - 7
mobile/lib/modules/search/views/all_videos_page.dart

@@ -1,10 +1,10 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/search/providers/all_video_assets.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class AllVideosPage extends HookConsumerWidget {
   const AllVideosPage({super.key});
@@ -21,14 +21,10 @@ class AllVideosPage extends HookConsumerWidget {
           icon: const Icon(Icons.arrow_back_ios_rounded),
         ),
       ),
-      body: videos.when(
-        data: (assets) => ImmichAssetGrid(
+      body: videos.widgetWhen(
+        onData: (assets) => ImmichAssetGrid(
           assets: assets,
         ),
-        error: (e, s) => Text(e.toString()),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 3 - 7
mobile/lib/modules/search/views/curated_location_page.dart

@@ -1,11 +1,11 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/search/models/curated_content.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:openapi/api.dart';
 
 class CuratedLocationPage extends HookConsumerWidget {
@@ -26,12 +26,8 @@ class CuratedLocationPage extends HookConsumerWidget {
           icon: const Icon(Icons.arrow_back_ios_rounded),
         ),
       ),
-      body: curatedLocation.when(
-        loading: () => const Center(child: ImmichLoadingIndicator()),
-        error: (err, stack) => Center(
-          child: Text('Error: $err'),
-        ),
-        data: (curatedLocations) => ExploreGrid(
+      body: curatedLocation.widgetWhen(
+        onData: (curatedLocations) => ExploreGrid(
           curatedContent: curatedLocations
               .map(
                 (l) => CuratedContent(

+ 1 - 3
mobile/lib/modules/search/views/person_result_page.dart

@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'
 import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
 import 'package:immich_mobile/shared/models/store.dart' as isar_store;
-import 'package:immich_mobile/shared/ui/scaffold_error_body.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 
 class PersonResultPage extends HookConsumerWidget {
@@ -112,7 +111,7 @@ class PersonResultPage extends HookConsumerWidget {
           ),
         ],
       ),
-      body: ref.watch(personAssetsProvider(personId)).scaffoldBodyWhen(
+      body: ref.watch(personAssetsProvider(personId)).widgetWhen(
             onData: (renderList) => ImmichAssetGrid(
               renderList: renderList,
               topWidget: Padding(
@@ -137,7 +136,6 @@ class PersonResultPage extends HookConsumerWidget {
                 ),
               ),
             ),
-            onError: const ScaffoldErrorBody(icon: Icons.person_off_outlined),
           ),
     );
   }

+ 3 - 7
mobile/lib/modules/search/views/recently_added_page.dart

@@ -1,10 +1,10 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/search/providers/recently_added.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class RecentlyAddedPage extends HookConsumerWidget {
   const RecentlyAddedPage({super.key});
@@ -21,14 +21,10 @@ class RecentlyAddedPage extends HookConsumerWidget {
           icon: const Icon(Icons.arrow_back_ios_rounded),
         ),
       ),
-      body: recents.when(
-        data: (searchResponse) => ImmichAssetGrid(
+      body: recents.widgetWhen(
+        onData: (searchResponse) => ImmichAssetGrid(
           assets: searchResponse,
         ),
-        error: (e, s) => Text(e.toString()),
-        loading: () => const Center(
-          child: ImmichLoadingIndicator(),
-        ),
       ),
     );
   }

+ 8 - 9
mobile/lib/modules/search/views/search_page.dart

@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/search/models/curated_content.dart';
 import 'package:immich_mobile/modules/search/providers/people.provider.dart';
@@ -15,7 +16,7 @@ import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/scaffold_error_body.dart';
 
 // ignore: must_be_immutable
 class SearchPage extends HookConsumerWidget {
@@ -73,10 +74,9 @@ class SearchPage extends HookConsumerWidget {
     buildPeople() {
       return SizedBox(
         height: imageSize,
-        child: curatedPeople.when(
-          loading: () => const Center(child: ImmichLoadingIndicator()),
-          error: (err, stack) => Center(child: Text('Error: $err')),
-          data: (people) => CuratedPeopleRow(
+        child: curatedPeople.widgetWhen(
+          onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
+          onData: (people) => CuratedPeopleRow(
             content: people.take(12).toList(),
             onTap: (content, index) {
               context.autoPush(
@@ -97,10 +97,9 @@ class SearchPage extends HookConsumerWidget {
     buildPlaces() {
       return SizedBox(
         height: imageSize,
-        child: curatedLocation.when(
-          loading: () => const Center(child: ImmichLoadingIndicator()),
-          error: (err, stack) => Center(child: Text('Error: $err')),
-          data: (locations) => CuratedPlacesRow(
+        child: curatedLocation.widgetWhen(
+          onError: (error, stack) => const ScaffoldErrorBody(withIcon: false),
+          onData: (locations) => CuratedPlacesRow(
             isMapEnabled: isMapEnabled,
             content: locations
                 .map(

+ 2 - 3
mobile/lib/modules/settings/views/settings_page.dart

@@ -1,6 +1,7 @@
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/settings/ui/advanced_settings/advanced_settings.dart';
 import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
 import 'package:immich_mobile/modules/settings/ui/local_storage_settings/local_storage_settings.dart';
@@ -18,9 +19,7 @@ class SettingsPage extends HookConsumerWidget {
         leading: IconButton(
           iconSize: 20,
           splashRadius: 24,
-          onPressed: () {
-            Navigator.pop(context);
-          },
+          onPressed: () => context.pop(),
           icon: const Icon(Icons.arrow_back_ios_new_rounded),
         ),
         automaticallyImplyLeading: false,

+ 16 - 6
mobile/lib/modules/shared_link/ui/shared_link_item.dart

@@ -46,9 +46,11 @@ class SharedLinkItem extends ConsumerWidget {
       } else if (difference.inHours > 0) {
         expiresText = "shared_link_expires_hours".plural(difference.inHours);
       } else if (difference.inMinutes > 0) {
-        expiresText = "shared_link_expires_minutes".plural(difference.inMinutes);
+        expiresText =
+            "shared_link_expires_minutes".plural(difference.inMinutes);
       } else if (difference.inSeconds > 0) {
-        expiresText = "shared_link_expires_seconds".plural(difference.inSeconds);
+        expiresText =
+            "shared_link_expires_seconds".plural(difference.inSeconds);
       }
     }
     return Text(
@@ -85,7 +87,12 @@ class SharedLinkItem extends ConsumerWidget {
       ).then((_) {
         ScaffoldMessenger.of(context).showSnackBar(
           SnackBar(
-            content: const Text("shared_link_clipboard_copied_massage").tr(),
+            content: Text(
+              "shared_link_clipboard_copied_massage",
+              style: context.textTheme.bodyLarge?.copyWith(
+                color: context.primaryColor,
+              ),
+            ).tr(),
             duration: const Duration(seconds: 2),
           ),
         );
@@ -162,9 +169,12 @@ class SharedLinkItem extends ConsumerWidget {
     Widget buildBottomInfo() {
       return Row(
         children: [
-          if (sharedLink.allowUpload) buildInfoChip("shared_link_info_chip_upload".tr()),
-          if (sharedLink.allowDownload) buildInfoChip("shared_link_info_chip_download".tr()),
-          if (sharedLink.showMetadata) buildInfoChip("shared_link_info_chip_metadata".tr()),
+          if (sharedLink.allowUpload)
+            buildInfoChip("shared_link_info_chip_upload".tr()),
+          if (sharedLink.allowDownload)
+            buildInfoChip("shared_link_info_chip_download".tr()),
+          if (sharedLink.showMetadata)
+            buildInfoChip("shared_link_info_chip_metadata".tr()),
         ],
       );
     }

+ 6 - 1
mobile/lib/modules/shared_link/views/shared_link_edit_page.dart

@@ -275,7 +275,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
       ).then((_) {
         ScaffoldMessenger.of(context).showSnackBar(
           SnackBar(
-            content: const Text("shared_link_clipboard_copied_massage").tr(),
+            content: Text(
+              "shared_link_clipboard_copied_massage",
+              style: context.textTheme.bodyLarge?.copyWith(
+                color: context.primaryColor,
+              ),
+            ).tr(),
             duration: const Duration(seconds: 2),
           ),
         );

+ 8 - 6
mobile/lib/modules/shared_link/views/shared_link_page.dart

@@ -2,11 +2,11 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
 import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
 import 'package:immich_mobile/modules/shared_link/ui/shared_link_item.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
 class SharedLinkPage extends HookConsumerWidget {
   const SharedLinkPage({Key? key}) : super(key: key);
@@ -18,7 +18,10 @@ class SharedLinkPage extends HookConsumerWidget {
     useEffect(
       () {
         ref.read(sharedLinksStateProvider.notifier).fetchLinks();
-        return () => ref.invalidate(sharedLinksStateProvider);
+        return () {
+          if (!context.mounted) return;
+          ref.invalidate(sharedLinksStateProvider);
+        };
       },
       [],
     );
@@ -113,11 +116,10 @@ class SharedLinkPage extends HookConsumerWidget {
         centerTitle: false,
       ),
       body: SafeArea(
-        child: sharedLinks.when(
-          data: (links) =>
+        child: sharedLinks.widgetWhen(
+          onError: (error, stackTrace) => buildNoShares(),
+          onData: (links) =>
               links.isNotEmpty ? buildSharesList(links) : buildNoShares(),
-          error: (error, stackTrace) => buildNoShares(),
-          loading: () => const Center(child: ImmichLoadingIndicator()),
         ),
       ),
     );

+ 12 - 20
mobile/lib/modules/trash/views/trash_page.dart

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
@@ -11,8 +12,8 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 
 class TrashPage extends HookConsumerWidget {
   const TrashPage({super.key});
@@ -24,7 +25,7 @@ class TrashPage extends HookConsumerWidget {
         ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
     final selectionEnabledHook = useState(false);
     final selection = useState(<Asset>{});
-    final processing = useState(false);
+    final processing = useProcessingOverlay();
 
     void selectionListener(
       bool multiselect,
@@ -229,18 +230,13 @@ class TrashPage extends HookConsumerWidget {
       );
     }
 
-    return trashedAssets.when(
-      loading: () => Scaffold(
-        appBar: buildAppBar("?"),
-        body: const Center(child: CircularProgressIndicator()),
+    return Scaffold(
+      appBar: trashedAssets.maybeWhen(
+        orElse: () => buildAppBar("?"),
+        data: (data) => buildAppBar(data.totalAssets.toString()),
       ),
-      error: (error, stackTrace) => Scaffold(
-        appBar: buildAppBar("!"),
-        body: Center(child: Text(error.toString())),
-      ),
-      data: (data) => Scaffold(
-        appBar: buildAppBar(data.totalAssets.toString()),
-        body: data.isEmpty
+      body: trashedAssets.widgetWhen(
+        onData: (data) => data.isEmpty
             ? Center(
                 child: Text('trash_page_no_assets'.tr()),
               )
@@ -254,11 +250,9 @@ class TrashPage extends HookConsumerWidget {
                       showMultiSelectIndicator: false,
                       showStack: true,
                       topWidget: Padding(
-                        padding: const EdgeInsets.only(
-                          top: 24,
-                          bottom: 24,
-                          left: 12,
-                          right: 12,
+                        padding: const EdgeInsets.symmetric(
+                          horizontal: 12,
+                          vertical: 24,
                         ),
                         child: const Text(
                           "trash_page_info",
@@ -267,8 +261,6 @@ class TrashPage extends HookConsumerWidget {
                     ),
                   ),
                   if (selectionEnabledHook.value) buildBottomBar(),
-                  if (processing.value)
-                    const Center(child: ImmichLoadingIndicator()),
                 ],
               ),
       ),

+ 1 - 1
mobile/lib/shared/ui/immich_loading_indicator.dart

@@ -21,7 +21,7 @@ class ImmichLoadingIndicator extends StatelessWidget {
       padding: const EdgeInsets.all(15),
       child: const CircularProgressIndicator(
         color: Colors.white,
-        strokeWidth: 2,
+        strokeWidth: 3,
       ),
     );
   }

+ 14 - 11
mobile/lib/shared/ui/scaffold_error_body.dart

@@ -4,9 +4,9 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
 
 // Error widget to be used in Scaffold when an AsyncError is received
 class ScaffoldErrorBody extends StatelessWidget {
-  final IconData icon;
+  final bool withIcon;
 
-  const ScaffoldErrorBody({this.icon = Icons.error_outline, super.key});
+  const ScaffoldErrorBody({super.key, this.withIcon = true});
 
   @override
   Widget build(BuildContext context) {
@@ -14,19 +14,22 @@ class ScaffoldErrorBody extends StatelessWidget {
       crossAxisAlignment: CrossAxisAlignment.center,
       mainAxisAlignment: MainAxisAlignment.center,
       children: [
-        const Text(
+        Text(
           "scaffold_body_error_occured",
-          style:
-              TextStyle(fontSize: 14, fontWeight: FontWeight.bold, height: 3),
+          style: context.textTheme.displayMedium,
           textAlign: TextAlign.center,
         ).tr(),
-        Center(
-          child: Icon(
-            icon,
-            size: 100,
-            color: context.themeData.iconTheme.color?.withOpacity(0.5),
+        if (withIcon)
+          Center(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 15),
+              child: Icon(
+                Icons.error_outline,
+                size: 100,
+                color: context.themeData.iconTheme.color?.withOpacity(0.5),
+              ),
+            ),
           ),
-        ),
       ],
     );
   }

+ 16 - 2
mobile/lib/shared/views/app_log_detail_page.dart

@@ -39,7 +39,14 @@ class AppLogDetailPage extends HookConsumerWidget {
                     Clipboard.setData(ClipboardData(text: stackTrace))
                         .then((_) {
                       ScaffoldMessenger.of(context).showSnackBar(
-                        const SnackBar(content: Text("Copied to clipboard")),
+                        SnackBar(
+                          content: Text(
+                            "Copied to clipboard",
+                            style: context.textTheme.bodyLarge?.copyWith(
+                              color: context.primaryColor,
+                            ),
+                          ),
+                        ),
                       );
                     });
                   },
@@ -98,7 +105,14 @@ class AppLogDetailPage extends HookConsumerWidget {
                   onPressed: () {
                     Clipboard.setData(ClipboardData(text: message)).then((_) {
                       ScaffoldMessenger.of(context).showSnackBar(
-                        const SnackBar(content: Text("Copied to clipboard")),
+                        SnackBar(
+                          content: Text(
+                            "Copied to clipboard",
+                            style: context.textTheme.bodyLarge?.copyWith(
+                              color: context.primaryColor,
+                            ),
+                          ),
+                        ),
                       );
                     });
                   },

+ 52 - 29
mobile/lib/shared/views/immich_loading_overlay.dart

@@ -1,41 +1,64 @@
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 
-class ImmichLoadingOverlay extends StatelessWidget {
-  const ImmichLoadingOverlay({
-    Key? key,
-  }) : super(key: key);
+final _loadingEntry = OverlayEntry(
+  builder: (context) => SizedBox.square(
+    dimension: double.infinity,
+    child: DecoratedBox(
+      decoration:
+          BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
+      child: const Center(child: ImmichLoadingIndicator()),
+    ),
+  ),
+);
+
+ValueNotifier<bool> useProcessingOverlay() {
+  return use(const _LoadingOverlay());
+}
+
+class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
+  const _LoadingOverlay();
 
   @override
-  Widget build(BuildContext context) {
-    return ValueListenableBuilder<bool>(
-      valueListenable:
-          ImmichLoadingOverlayController.appLoader.loaderShowingNotifier,
-      builder: (context, shouldShow, child) {
-        return shouldShow
-            ? const Scaffold(
-                backgroundColor: Colors.black54,
-                body: Center(
-                  child: ImmichLoadingIndicator(),
-                ),
-              )
-            : const SizedBox();
-      },
-    );
-  }
+  _LoadingOverlayState createState() => _LoadingOverlayState();
 }
 
-class ImmichLoadingOverlayController {
-  static final ImmichLoadingOverlayController appLoader =
-      ImmichLoadingOverlayController();
-  ValueNotifier<bool> loaderShowingNotifier = ValueNotifier(false);
-  ValueNotifier<String> loaderTextNotifier = ValueNotifier('error message');
+class _LoadingOverlayState
+    extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
+  late final _isProcessing = ValueNotifier(false)..addListener(_listener);
+  OverlayEntry? overlayEntry;
+
+  void _listener() {
+    setState(() {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        if (_isProcessing.value) {
+          overlayEntry?.remove();
+          overlayEntry = _loadingEntry;
+          Overlay.of(context).insert(_loadingEntry);
+        } else {
+          overlayEntry?.remove();
+          overlayEntry = null;
+        }
+      });
+    });
+  }
 
-  void show() {
-    loaderShowingNotifier.value = true;
+  @override
+  ValueNotifier<bool> build(BuildContext context) {
+    return _isProcessing;
   }
 
-  void hide() {
-    loaderShowingNotifier.value = false;
+  @override
+  void dispose() {
+    _isProcessing.dispose();
+    super.dispose();
   }
+
+  @override
+  Object? get debugValue => _isProcessing.value;
+
+  @override
+  String get debugLabel => 'useProcessingOverlay<>';
 }

+ 18 - 18
mobile/lib/utils/immich_app_theme.dart

@@ -48,8 +48,8 @@ ThemeData immichLightTheme = ThemeData(
     ),
     backgroundColor: Colors.white,
   ),
-  appBarTheme: AppBarTheme(
-    titleTextStyle: const TextStyle(
+  appBarTheme: const AppBarTheme(
+    titleTextStyle: TextStyle(
       fontFamily: 'Overpass',
       color: Colors.indigo,
       fontWeight: FontWeight.bold,
@@ -61,7 +61,7 @@ ThemeData immichLightTheme = ThemeData(
     scrolledUnderElevation: 0,
     centerTitle: true,
   ),
-  bottomNavigationBarTheme: BottomNavigationBarThemeData(
+  bottomNavigationBarTheme: const BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
     backgroundColor: immichBackgroundColor,
     selectedItemColor: Colors.indigo,
@@ -69,7 +69,7 @@ ThemeData immichLightTheme = ThemeData(
   cardTheme: const CardTheme(
     surfaceTintColor: Colors.transparent,
   ),
-  drawerTheme: DrawerThemeData(
+  drawerTheme: const DrawerThemeData(
     backgroundColor: immichBackgroundColor,
   ),
   textTheme: const TextTheme(
@@ -162,7 +162,7 @@ ThemeData immichDarkTheme = ThemeData(
   hintColor: Colors.grey[600],
   fontFamily: 'Overpass',
   snackBarTheme: SnackBarThemeData(
-    contentTextStyle: TextStyle(
+    contentTextStyle: const TextStyle(
       fontFamily: 'Overpass',
       color: immichDarkThemePrimaryColor,
       fontWeight: FontWeight.bold,
@@ -174,35 +174,35 @@ ThemeData immichDarkTheme = ThemeData(
       foregroundColor: immichDarkThemePrimaryColor,
     ),
   ),
-  appBarTheme: AppBarTheme(
+  appBarTheme: const AppBarTheme(
     titleTextStyle: TextStyle(
       fontFamily: 'Overpass',
       color: immichDarkThemePrimaryColor,
       fontWeight: FontWeight.bold,
       fontSize: 18,
     ),
-    backgroundColor: const Color.fromARGB(255, 32, 33, 35),
+    backgroundColor: Color.fromARGB(255, 32, 33, 35),
     foregroundColor: immichDarkThemePrimaryColor,
     elevation: 0,
     scrolledUnderElevation: 0,
     centerTitle: true,
   ),
-  bottomNavigationBarTheme: BottomNavigationBarThemeData(
+  bottomNavigationBarTheme: const BottomNavigationBarThemeData(
     type: BottomNavigationBarType.fixed,
-    backgroundColor: const Color.fromARGB(255, 35, 36, 37),
+    backgroundColor: Color.fromARGB(255, 35, 36, 37),
     selectedItemColor: immichDarkThemePrimaryColor,
   ),
   drawerTheme: DrawerThemeData(
     backgroundColor: immichDarkBackgroundColor,
     scrimColor: Colors.white.withOpacity(0.1),
   ),
-  textTheme: TextTheme(
-    displayLarge: const TextStyle(
+  textTheme: const TextTheme(
+    displayLarge: TextStyle(
       fontSize: 26,
       fontWeight: FontWeight.bold,
       color: Color.fromARGB(255, 255, 255, 255),
     ),
-    displayMedium: const TextStyle(
+    displayMedium: TextStyle(
       fontSize: 14,
       fontWeight: FontWeight.bold,
       color: Color.fromARGB(255, 255, 255, 255),
@@ -212,15 +212,15 @@ ThemeData immichDarkTheme = ThemeData(
       fontWeight: FontWeight.bold,
       color: immichDarkThemePrimaryColor,
     ),
-    titleSmall: const TextStyle(
+    titleSmall: TextStyle(
       fontSize: 16.0,
       fontWeight: FontWeight.bold,
     ),
-    titleMedium: const TextStyle(
+    titleMedium: TextStyle(
       fontSize: 18.0,
       fontWeight: FontWeight.bold,
     ),
-    titleLarge: const TextStyle(
+    titleLarge: TextStyle(
       fontSize: 26.0,
       fontWeight: FontWeight.bold,
     ),
@@ -258,7 +258,7 @@ ThemeData immichDarkTheme = ThemeData(
   dialogTheme: const DialogTheme(
     surfaceTintColor: Colors.transparent,
   ),
-  inputDecorationTheme: InputDecorationTheme(
+  inputDecorationTheme: const InputDecorationTheme(
     focusedBorder: OutlineInputBorder(
       borderSide: BorderSide(
         color: immichDarkThemePrimaryColor,
@@ -267,12 +267,12 @@ ThemeData immichDarkTheme = ThemeData(
     labelStyle: TextStyle(
       color: immichDarkThemePrimaryColor,
     ),
-    hintStyle: const TextStyle(
+    hintStyle: TextStyle(
       fontSize: 14.0,
       fontWeight: FontWeight.normal,
     ),
   ),
-  textSelectionTheme: TextSelectionThemeData(
+  textSelectionTheme: const TextSelectionThemeData(
     cursorColor: immichDarkThemePrimaryColor,
   ),
 );

+ 2 - 2
mobile/lib/utils/url_helper.dart

@@ -3,10 +3,10 @@ import 'package:immich_mobile/shared/models/store.dart';
 String sanitizeUrl(String url) {
   // Add schema if none is set
   final urlWithSchema =
-      url.startsWith(RegExp(r"https?://")) ? url : "https://$url";
+      url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
 
   // Remove trailing slash(es)
-  return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
+  return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
 }
 
 String? getServerUrl() {

+ 20 - 8
renovate.json

@@ -1,13 +1,19 @@
 {
   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
-  "extends": ["config:base"],
-  "minimumReleaseAge": "5",
+  "extends": ["config:base", "docker:pinDigests"],
+  "minimumReleaseAge": "5 days",
   "packageRules": [
     {
       "matchFileNames": ["cli/**"],
       "groupName": "@immich/cli",
       "matchUpdateTypes": ["minor", "patch"],
-      "schedule": "on monday"
+      "schedule": "on tuesday"
+    },
+    {
+      "matchFileNames": ["docs/**"],
+      "groupName": "docs",
+      "matchUpdateTypes": ["minor", "patch"],
+      "schedule": "on tuesday"
     },
     {
       "matchFileNames": ["mobile/**"],
@@ -20,33 +26,39 @@
       "groupName": "server",
       "matchUpdateTypes": ["minor", "patch"],
       "excludePackagePrefixes": ["exiftool"],
-      "schedule": "on wednesday"
+      "schedule": "on tuesday"
     },
     {
       "groupName": "exiftool",
       "matchPackagePrefixes": ["exiftool"],
-      "schedule": "on monday"
+      "schedule": "on tuesday"
     },
     {
       "matchFileNames": ["web/**"],
       "groupName": "web",
       "matchUpdateTypes": ["minor", "patch"],
-      "schedule": "on thursday"
+      "schedule": "on tuesday"
     },
     {
       "matchFileNames": ["machine-learning/**"],
       "groupName": "machine-learning",
       "matchUpdateTypes": ["minor", "patch"],
-      "schedule": "on friday"
+      "schedule": "on tuesday"
     },
     {
       "matchFileNames": [".github/**"],
       "groupName": "github-actions",
-      "schedule": "on friday"
+      "schedule": "on tuesday"
     },
     {
       "groupName": "base-image",
       "matchPackagePrefixes": ["ghcr.io/immich-app/base-server"]
+    },
+    {
+      "matchDatasources": ["docker"],
+      "matchPackageNames": ["node"],
+      "versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
+      "versioning": "node"
     }
   ],
   "ignoreDeps": [

+ 1 - 0
server/.eslintrc.js

@@ -19,6 +19,7 @@ module.exports = {
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-floating-promises': 'error',
+    curly: 2,
     'prettier/prettier': 0,
   },
 };

+ 3 - 3
server/Dockerfile

@@ -1,5 +1,5 @@
 # dev build
-FROM ghcr.io/immich-app/base-server-dev:20231125 as dev
+FROM ghcr.io/immich-app/base-server-dev:20231125@sha256:f33b6eaf384e76ef3705a6e2cc76d276144ad6d3366b82f9b45b07d6a19285e2 as dev
 
 WORKDIR /usr/src/app
 COPY server/package.json server/package-lock.json ./
@@ -13,7 +13,7 @@ RUN npm run build
 RUN npm prune --omit=dev --omit=optional
 
 # web build
-FROM node:20.10-alpine3.18 as web
+FROM node:iron-alpine3.18 as web
 
 WORKDIR /usr/src/app
 COPY web/package.json web/package-lock.json ./
@@ -23,7 +23,7 @@ RUN npm run build
 
 
 # prod build
-FROM ghcr.io/immich-app/base-server-prod:20231125
+FROM ghcr.io/immich-app/base-server-prod:20231125@sha256:a0e15f5bf87a97a79a399a5adffb5fe5befc18fb212e8341e744d958fe41e32a
 
 WORKDIR /usr/src/app
 ENV NODE_ENV=production

+ 90 - 90
server/package-lock.json

@@ -3256,16 +3256,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz",
-      "integrity": "sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz",
+      "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.5.1",
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/type-utils": "6.12.0",
-        "@typescript-eslint/utils": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/type-utils": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
         "ignore": "^5.2.4",
@@ -3291,15 +3291,15 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz",
-      "integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz",
+      "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/typescript-estree": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -3319,13 +3319,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz",
-      "integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0"
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
       },
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -3336,13 +3336,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.12.0.tgz",
-      "integrity": "sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz",
+      "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "6.12.0",
-        "@typescript-eslint/utils": "6.12.0",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.0.1"
       },
@@ -3363,9 +3363,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz",
-      "integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
       "dev": true,
       "engines": {
         "node": "^16.0.0 || >=18.0.0"
@@ -3376,13 +3376,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz",
-      "integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -3403,17 +3403,17 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.12.0.tgz",
-      "integrity": "sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+      "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.4.0",
         "@types/json-schema": "^7.0.12",
         "@types/semver": "^7.5.0",
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/typescript-estree": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
         "semver": "^7.5.4"
       },
       "engines": {
@@ -3428,12 +3428,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz",
-      "integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "6.12.0",
+        "@typescript-eslint/types": "6.13.1",
         "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
@@ -4347,9 +4347,9 @@
       }
     },
     "node_modules/bullmq": {
-      "version": "4.14.2",
-      "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.2.tgz",
-      "integrity": "sha512-lzK4F6H61oH5S3Mg4JP4rnSxpQx00Qq7KQKt1oWjcQarka7TdN50CDsZGXg9z6kzvu26Pd3aiwTxwr4YvcEFgw==",
+      "version": "4.14.4",
+      "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.4.tgz",
+      "integrity": "sha512-8tD3Zq4CP+2q+zZ1JSkKov5ra18Jhth8zdr2X1/V2MMOVpa8vfVCsQaRnKgDpiMpaF6AH9sucXUNtk4xamTEKw==",
       "dependencies": {
         "cron-parser": "^4.6.0",
         "glob": "^8.0.3",
@@ -15141,16 +15141,16 @@
       "dev": true
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz",
-      "integrity": "sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz",
+      "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==",
       "dev": true,
       "requires": {
         "@eslint-community/regexpp": "^4.5.1",
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/type-utils": "6.12.0",
-        "@typescript-eslint/utils": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/type-utils": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
         "ignore": "^5.2.4",
@@ -15160,54 +15160,54 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz",
-      "integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz",
+      "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/typescript-estree": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz",
-      "integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0"
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.12.0.tgz",
-      "integrity": "sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz",
+      "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/typescript-estree": "6.12.0",
-        "@typescript-eslint/utils": "6.12.0",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.0.1"
       }
     },
     "@typescript-eslint/types": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz",
-      "integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz",
-      "integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/visitor-keys": "6.12.0",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -15216,27 +15216,27 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.12.0.tgz",
-      "integrity": "sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+      "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
       "dev": true,
       "requires": {
         "@eslint-community/eslint-utils": "^4.4.0",
         "@types/json-schema": "^7.0.12",
         "@types/semver": "^7.5.0",
-        "@typescript-eslint/scope-manager": "6.12.0",
-        "@typescript-eslint/types": "6.12.0",
-        "@typescript-eslint/typescript-estree": "6.12.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
         "semver": "^7.5.4"
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "6.12.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz",
-      "integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "6.12.0",
+        "@typescript-eslint/types": "6.13.1",
         "eslint-visitor-keys": "^3.4.1"
       }
     },
@@ -15956,9 +15956,9 @@
       "optional": true
     },
     "bullmq": {
-      "version": "4.14.2",
-      "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.2.tgz",
-      "integrity": "sha512-lzK4F6H61oH5S3Mg4JP4rnSxpQx00Qq7KQKt1oWjcQarka7TdN50CDsZGXg9z6kzvu26Pd3aiwTxwr4YvcEFgw==",
+      "version": "4.14.4",
+      "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.14.4.tgz",
+      "integrity": "sha512-8tD3Zq4CP+2q+zZ1JSkKov5ra18Jhth8zdr2X1/V2MMOVpa8vfVCsQaRnKgDpiMpaF6AH9sucXUNtk4xamTEKw==",
       "requires": {
         "cron-parser": "^4.6.0",
         "glob": "^8.0.3",

+ 3 - 1
server/src/domain/metadata/metadata.service.ts

@@ -252,7 +252,9 @@ export class MetadataService {
 
     try {
       const reverseGeocode = await this.repository.reverseGeocode({ latitude, longitude });
-      if (!reverseGeocode) return;
+      if (!reverseGeocode) {
+        return;
+      }
       Object.assign(exifData, reverseGeocode);
     } catch (error: Error | any) {
       this.logger.warn(

+ 3 - 1
server/src/infra/repositories/system-metadata.repository.ts

@@ -10,7 +10,9 @@ export class SystemMetadataRepository implements ISystemMetadataRepository {
   ) {}
   async get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null> {
     const metadata = await this.repository.findOne({ where: { key } });
-    if (!metadata) return null;
+    if (!metadata) {
+      return null;
+    }
     return metadata.value as SystemMetadata[T];
   }
 

+ 1 - 0
web/.eslintrc.cjs

@@ -35,5 +35,6 @@ module.exports = {
         varsIgnorePattern: '^_$',
       },
     ],
+    curly: 2,
   },
 };

+ 2 - 1
web/Dockerfile

@@ -1,5 +1,6 @@
-FROM node:20.10-alpine3.18
+FROM node:iron-alpine3.18
 
+USER node
 WORKDIR /usr/src/app
 COPY --chown=node:node package*.json ./
 RUN npm ci

+ 94 - 131
web/package-lock.json

@@ -36,13 +36,12 @@
         "@sveltejs/kit": "^1.20.4",
         "@testing-library/jest-dom": "^6.0.0",
         "@testing-library/svelte": "^4.0.3",
-        "@types/cookie": "^0.5.1",
         "@types/dom-to-image": "^2.6.4",
         "@types/justified-layout": "^4.1.0",
         "@types/lodash-es": "^4.17.6",
         "@types/luxon": "^3.2.0",
-        "@typescript-eslint/eslint-plugin": "^5.53.0",
-        "@typescript-eslint/parser": "^5.53.0",
+        "@typescript-eslint/eslint-plugin": "^6.0.0",
+        "@typescript-eslint/parser": "^6.0.0",
         "autoprefixer": "^10.4.13",
         "babel-jest": "^29.4.3",
         "eslint": "^8.34.0",
@@ -3075,6 +3074,12 @@
         "vite": "^4.0.0"
       }
     },
+    "node_modules/@sveltejs/kit/node_modules/@types/cookie": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz",
+      "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==",
+      "dev": true
+    },
     "node_modules/@sveltejs/kit/node_modules/magic-string": {
       "version": "0.30.5",
       "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
@@ -3415,12 +3420,6 @@
         "@babel/types": "^7.20.7"
       }
     },
-    "node_modules/@types/cookie": {
-      "version": "0.5.4",
-      "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz",
-      "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==",
-      "dev": true
-    },
     "node_modules/@types/dom-to-image": {
       "version": "2.6.7",
       "resolved": "https://registry.npmjs.org/@types/dom-to-image/-/dom-to-image-2.6.7.tgz",
@@ -3532,9 +3531,9 @@
       }
     },
     "node_modules/@types/json-schema": {
-      "version": "7.0.13",
-      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz",
-      "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==",
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
       "dev": true
     },
     "node_modules/@types/justified-layout": {
@@ -3597,9 +3596,9 @@
       "dev": true
     },
     "node_modules/@types/semver": {
-      "version": "7.5.3",
-      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.3.tgz",
-      "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==",
+      "version": "7.5.6",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
+      "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
       "dev": true
     },
     "node_modules/@types/stack-utils": {
@@ -3643,32 +3642,33 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
-      "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.1.tgz",
+      "integrity": "sha512-5bQDGkXaxD46bPvQt08BUz9YSaO4S0fB1LB5JHQuXTfkGPI3+UUeS387C/e9jRie5GqT8u5kFTrMvAjtX4O5kA==",
       "dev": true,
       "dependencies": {
-        "@eslint-community/regexpp": "^4.4.0",
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/type-utils": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@eslint-community/regexpp": "^4.5.1",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/type-utils": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "graphemer": "^1.4.0",
-        "ignore": "^5.2.0",
-        "natural-compare-lite": "^1.4.0",
-        "semver": "^7.3.7",
-        "tsutils": "^3.21.0"
+        "ignore": "^5.2.4",
+        "natural-compare": "^1.4.0",
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "@typescript-eslint/parser": "^5.0.0",
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
+        "eslint": "^7.0.0 || ^8.0.0"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -3710,25 +3710,26 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
-      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz",
+      "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/typescript-estree": "5.62.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "eslint": "^7.0.0 || ^8.0.0"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -3737,16 +3738,16 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
-      "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.1.tgz",
+      "integrity": "sha512-BW0kJ7ceiKi56GbT2KKzZzN+nDxzQK2DS6x0PiSMPjciPgd/JRQGMibyaN2cPt2cAvuoH0oNvn2fwonHI+4QUQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/visitor-keys": "5.62.0"
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -3754,25 +3755,25 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
-      "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.1.tgz",
+      "integrity": "sha512-A2qPlgpxx2v//3meMqQyB1qqTg1h1dJvzca7TugM3Yc2USDY+fsRBiojAEo92HO7f5hW5mjAUF6qobOPzlBCBQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "5.62.0",
-        "@typescript-eslint/utils": "5.62.0",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "@typescript-eslint/utils": "6.13.1",
         "debug": "^4.3.4",
-        "tsutils": "^3.21.0"
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "*"
+        "eslint": "^7.0.0 || ^8.0.0"
       },
       "peerDependenciesMeta": {
         "typescript": {
@@ -3781,12 +3782,12 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
-      "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.1.tgz",
+      "integrity": "sha512-gjeEskSmiEKKFIbnhDXUyiqVma1gRCQNbVZ1C8q7Zjcxh3WZMbzWVfGE9rHfWd1msQtPS0BVD9Jz9jded44eKg==",
       "dev": true,
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -3794,21 +3795,21 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
-      "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.1.tgz",
+      "integrity": "sha512-sBLQsvOC0Q7LGcUHO5qpG1HxRgePbT6wwqOiGLpR8uOJvPJbfs0mW3jPA3ujsDvfiVwVlWUDESNXv44KtINkUQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/visitor-keys": "5.62.0",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/visitor-keys": "6.13.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
-        "semver": "^7.3.7",
-        "tsutils": "^3.21.0"
+        "semver": "^7.5.4",
+        "ts-api-utils": "^1.0.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -3854,29 +3855,28 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
-      "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
+      "integrity": "sha512-ouPn/zVoan92JgAegesTXDB/oUp6BP1v8WpfYcqh649ejNc9Qv+B4FF2Ff626kO1xg0wWwwG48lAJ4JuesgdOw==",
       "dev": true,
       "dependencies": {
-        "@eslint-community/eslint-utils": "^4.2.0",
-        "@types/json-schema": "^7.0.9",
-        "@types/semver": "^7.3.12",
-        "@typescript-eslint/scope-manager": "5.62.0",
-        "@typescript-eslint/types": "5.62.0",
-        "@typescript-eslint/typescript-estree": "5.62.0",
-        "eslint-scope": "^5.1.1",
-        "semver": "^7.3.7"
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "@types/json-schema": "^7.0.12",
+        "@types/semver": "^7.5.0",
+        "@typescript-eslint/scope-manager": "6.13.1",
+        "@typescript-eslint/types": "6.13.1",
+        "@typescript-eslint/typescript-estree": "6.13.1",
+        "semver": "^7.5.4"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/typescript-eslint"
       },
       "peerDependencies": {
-        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+        "eslint": "^7.0.0 || ^8.0.0"
       }
     },
     "node_modules/@typescript-eslint/utils/node_modules/lru-cache": {
@@ -3913,16 +3913,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.62.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
-      "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
+      "version": "6.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.1.tgz",
+      "integrity": "sha512-NDhQUy2tg6XGNBGDRm1XybOHSia8mcXmlbKWoQP+nm1BIIMxa55shyJfZkHpEBN62KNPLrocSM2PdPcaLgDKMQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.62.0",
-        "eslint-visitor-keys": "^3.3.0"
+        "@typescript-eslint/types": "6.13.1",
+        "eslint-visitor-keys": "^3.4.1"
       },
       "engines": {
-        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+        "node": "^16.0.0 || >=18.0.0"
       },
       "funding": {
         "type": "opencollective",
@@ -5614,19 +5614,6 @@
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
       "dev": true
     },
-    "node_modules/eslint-scope": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
-      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
-      "dev": true,
-      "dependencies": {
-        "esrecurse": "^4.3.0",
-        "estraverse": "^4.1.1"
-      },
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
     "node_modules/eslint-visitor-keys": {
       "version": "3.4.3",
       "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
@@ -5915,15 +5902,6 @@
         "node": ">=4.0"
       }
     },
-    "node_modules/estraverse": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
-      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
-      "dev": true,
-      "engines": {
-        "node": ">=4.0"
-      }
-    },
     "node_modules/estree-walker": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -9665,12 +9643,6 @@
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "dev": true
     },
-    "node_modules/natural-compare-lite": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
-      "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
-      "dev": true
-    },
     "node_modules/neo-async": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -11654,6 +11626,18 @@
         "node": ">=12"
       }
     },
+    "node_modules/ts-api-utils": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
+      "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==",
+      "dev": true,
+      "engines": {
+        "node": ">=16.13.0"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.2.0"
+      }
+    },
     "node_modules/ts-interface-checker": {
       "version": "0.1.13",
       "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -11666,27 +11650,6 @@
       "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
       "dev": true
     },
-    "node_modules/tsutils": {
-      "version": "3.21.0",
-      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
-      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
-      "dev": true,
-      "dependencies": {
-        "tslib": "^1.8.1"
-      },
-      "engines": {
-        "node": ">= 6"
-      },
-      "peerDependencies": {
-        "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
-      }
-    },
-    "node_modules/tsutils/node_modules/tslib": {
-      "version": "1.14.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
-      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
-      "dev": true
-    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

+ 2 - 3
web/package.json

@@ -28,13 +28,12 @@
     "@sveltejs/kit": "^1.20.4",
     "@testing-library/jest-dom": "^6.0.0",
     "@testing-library/svelte": "^4.0.3",
-    "@types/cookie": "^0.5.1",
     "@types/dom-to-image": "^2.6.4",
     "@types/justified-layout": "^4.1.0",
     "@types/lodash-es": "^4.17.6",
     "@types/luxon": "^3.2.0",
-    "@typescript-eslint/eslint-plugin": "^5.53.0",
-    "@typescript-eslint/parser": "^5.53.0",
+    "@typescript-eslint/eslint-plugin": "^6.0.0",
+    "@typescript-eslint/parser": "^6.0.0",
     "autoprefixer": "^10.4.13",
     "babel-jest": "^29.4.3",
     "eslint": "^8.34.0",

+ 5 - 2
web/src/lib/components/admin-page/restore-dialoge.svelte

@@ -9,8 +9,11 @@
 
   const restoreUser = async () => {
     const restoredUser = await api.userApi.restoreUser({ id: user.id });
-    if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
-    else dispatch('user-restore-fail');
+    if (restoredUser.data.deletedAt == null) {
+      dispatch('user-restore-success');
+    } else {
+      dispatch('user-restore-fail');
+    }
   };
 </script>
 

+ 2 - 1
web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte

@@ -21,7 +21,8 @@
     return '0'.repeat(zeroLength);
   };
 
-  $: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, 0);
+  const TiB = 1024 ** 4;
+  $: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0);
 </script>
 
 <div class="flex flex-col gap-5">

+ 12 - 2
web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte

@@ -16,8 +16,18 @@
   let savedConfig: SystemConfigJobDto;
   let defaultConfig: SystemConfigJobDto;
 
-  const ignoredJobs = [JobName.BackgroundTask, JobName.Search] as JobName[];
-  const jobNames = Object.values(JobName).filter((jobName) => !ignoredJobs.includes(jobName as JobName));
+  const jobNames = [
+    JobName.ThumbnailGeneration,
+    JobName.MetadataExtraction,
+    JobName.Library,
+    JobName.Sidecar,
+    JobName.ObjectTagging,
+    JobName.ClipEncoding,
+    JobName.RecognizeFaces,
+    JobName.VideoConversion,
+    JobName.StorageTemplateMigration,
+    JobName.Migration,
+  ];
 
   async function getConfigs() {
     [savedConfig, defaultConfig] = await Promise.all([

+ 2 - 0
web/src/lib/components/assets/thumbnail/image-thumbnail.svelte

@@ -17,12 +17,14 @@
   export let circle = false;
   export let hidden = false;
   export let border = false;
+  export let preload = true;
   let complete = false;
 
   export let eyeColor: 'black' | 'white' = 'white';
 </script>
 
 <img
+  loading={preload ? 'eager' : 'lazy'}
   style:width={widthStyle}
   style:height={heightStyle}
   style:filter={hidden ? 'grayscale(50%)' : 'none'}

+ 2 - 0
web/src/lib/components/faces-page/people-card.svelte

@@ -12,6 +12,7 @@
   import Icon from '$lib/components/elements/icon.svelte';
 
   export let person: PersonResponseDto;
+  export let preload = false;
 
   type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-faces' | 'hide-face';
   let dispatch = createEventDispatcher<{
@@ -48,6 +49,7 @@
     <div class="h-48 w-48 rounded-xl brightness-95 filter">
       <ImageThumbnail
         shadow
+        {preload}
         url={api.getPeopleThumbnailUrl(person.id)}
         altText={person.name}
         title={person.name}

+ 1 - 1
web/src/lib/utils/byte-units.ts

@@ -9,7 +9,7 @@
  * @returns size (number) and unit (string)
  */
 export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, string] {
-  const units = ['B', 'KiB', 'MiB', 'GiB'];
+  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
 
   let magnitude = 0;
   let remainder = bytes;

+ 20 - 6
web/src/lib/utils/thumbnail-util.ts

@@ -7,12 +7,26 @@
 export function getThumbnailSize(assetCount: number, viewWidth: number): number {
   if (assetCount < 6) {
     return Math.min(320, Math.floor(viewWidth / assetCount - assetCount));
-  } else {
-    if (viewWidth > 600) return viewWidth / 7 - 7;
-    else if (viewWidth > 400) return viewWidth / 4 - 6;
-    else if (viewWidth > 300) return viewWidth / 2 - 6;
-    else if (viewWidth > 200) return viewWidth / 2 - 6;
-    else if (viewWidth > 100) return viewWidth / 1 - 6;
+  }
+
+  if (viewWidth > 600) {
+    return viewWidth / 7 - 7;
+  }
+
+  if (viewWidth > 400) {
+    return viewWidth / 4 - 6;
+  }
+
+  if (viewWidth > 300) {
+    return viewWidth / 2 - 6;
+  }
+
+  if (viewWidth > 200) {
+    return viewWidth / 2 - 6;
+  }
+
+  if (viewWidth > 100) {
+    return viewWidth / 1 - 6;
   }
 
   return 300;

+ 4 - 2
web/src/routes/(user)/people/+page.svelte

@@ -372,10 +372,11 @@
   {#if countVisiblePeople > 0}
     <div class="pl-4">
       <div class="flex flex-row flex-wrap gap-1">
-        {#each people as person (person.id)}
+        {#each people as person, idx (person.id)}
           {#if !person.isHidden}
             <PeopleCard
               {person}
+              preload={idx < 20}
               on:change-name={() => handleChangeName(person)}
               on:set-birth-date={() => handleSetBirthDate(person)}
               on:merge-faces={() => handleMergeFaces(person)}
@@ -444,7 +445,7 @@
     bind:showLoadingSpinner
     bind:toggleVisibility
   >
-    {#each people as person (person.id)}
+    {#each people as person, idx (person.id)}
       <button
         class="relative h-36 w-36 md:h-48 md:w-48"
         on:click={() => (person.isHidden = !person.isHidden)}
@@ -452,6 +453,7 @@
         on:mouseleave={() => (eyeColorMap[person.id] = 'white')}
       >
         <ImageThumbnail
+          preload={idx < 20}
           bind:hidden={person.isHidden}
           shadow
           url={api.getPeopleThumbnailUrl(person.id)}