diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000000..e83165a7af
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,2 @@
+ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22
+FROM ${BASEIMAGE}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000000..b297f9a2d8
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,20 @@
+{
+	"name": "Immich devcontainers",
+	"build": {
+		"dockerfile": "Dockerfile",
+		"args": {
+			"BASEIMAGE": "mcr.microsoft.com/devcontainers/typescript-node:22"
+		}
+	},
+	"customizations": {
+		"vscode": {
+			"extensions": [
+				"svelte.svelte-vscode"
+			]
+		}
+	},
+	"forwardPorts": [],
+	"postCreateCommand": "make install-all",
+	"remoteUser": "node"
+}
+
diff --git a/.github/workflows/pr-require-conventional-commit.yml b/.github/workflows/pr-require-conventional-commit.yml
index 4899031249..d4bd44ec43 100644
--- a/.github/workflows/pr-require-conventional-commit.yml
+++ b/.github/workflows/pr-require-conventional-commit.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: PR Conventional Commit Validation
-        uses:  ytanikin/PRConventionalCommits@1.2.0
+        uses:  ytanikin/PRConventionalCommits@1.3.0
         with:
           task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
           add_label: 'false'
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a8661326a0..49dbf3944c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -41,4 +41,4 @@
   "explorer.fileNesting.patterns": {
     "*.ts": "${capture}.spec.ts,${capture}.mock.ts"
   }
-}
+}
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 2096cf86df..0899d82d24 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,7 @@ attach-server:
 renovate:
   LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
 
-MODULES = e2e server web cli sdk
+MODULES = e2e server web cli sdk docs
 
 audit-%:
 	npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
@@ -48,11 +48,9 @@ install-%:
 build-cli: build-sdk
 build-web: build-sdk
 build-%: install-%
-	npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'build' >/dev/null \
-		&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build || true
+	npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
 format-%:
-	npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
-		&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
+	npm --prefix $* run format:fix
 lint-%:
 	npm --prefix $* run lint:fix
 check-%:
@@ -79,14 +77,14 @@ test-medium:
 test-medium-dev:
 	docker exec -it immich_server /bin/sh -c "npm run test:medium"
 
-build-all: $(foreach M,$(MODULES),build-$M) ;
+build-all: $(foreach M,$(filter-out e2e,$(MODULES)),build-$M) ;
 install-all: $(foreach M,$(MODULES),install-$M) ;
-check-all: $(foreach M,$(MODULES),check-$M) ;
-lint-all: $(foreach M,$(MODULES),lint-$M) ;
-format-all: $(foreach M,$(MODULES),format-$M) ;
+check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
+lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
+format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
 audit-all:  $(foreach M,$(MODULES),audit-$M) ;
 hygiene-all: lint-all format-all check-all sql audit-all;
-test-all: $(foreach M,$(MODULES),test-$M) ;
+test-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),test-$M) ;
 
 clean:
 	find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
diff --git a/cli/Dockerfile b/cli/Dockerfile
index c4b99869c6..bc7a074f5f 100644
--- a/cli/Dockerfile
+++ b/cli/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
+FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS core
 
 WORKDIR /usr/src/open-api/typescript-sdk
 COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
diff --git a/cli/package-lock.json b/cli/package-lock.json
index f6e514644c..3993009171 100644
--- a/cli/package-lock.json
+++ b/cli/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.30",
+  "version": "2.2.31",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/cli",
-      "version": "2.2.30",
+      "version": "2.2.31",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "fast-glob": "^3.3.2",
@@ -24,7 +24,7 @@
         "@types/cli-progress": "^3.11.0",
         "@types/lodash-es": "^4.17.12",
         "@types/mock-fs": "^4.13.1",
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "@typescript-eslint/eslint-plugin": "^8.0.0",
         "@typescript-eslint/parser": "^8.0.0",
         "@vitest/coverage-v8": "^2.0.5",
@@ -52,14 +52,14 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
       },
       "devDependencies": {
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "typescript": "^5.3.3"
       }
     },
diff --git a/cli/package.json b/cli/package.json
index 5caa25778e..4c668d99d8 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/cli",
-  "version": "2.2.30",
+  "version": "2.2.31",
   "description": "Command Line Interface (CLI) for Immich",
   "type": "module",
   "exports": "./dist/index.js",
@@ -20,7 +20,7 @@
     "@types/cli-progress": "^3.11.0",
     "@types/lodash-es": "^4.17.12",
     "@types/mock-fs": "^4.13.1",
-    "@types/node": "^22.8.6",
+    "@types/node": "^22.9.0",
     "@typescript-eslint/eslint-plugin": "^8.0.0",
     "@typescript-eslint/parser": "^8.0.0",
     "@vitest/coverage-v8": "^2.0.5",
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 26e58f18d7..1487f8adbe 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -103,7 +103,7 @@ services:
 
   redis:
     container_name: immich_redis
-    image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
+    image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
     healthcheck:
       test: redis-cli ping || exit 1
 
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index 48d4328c85..fa7d110f94 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -47,7 +47,7 @@ services:
 
   redis:
     container_name: immich_redis
-    image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
+    image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
     healthcheck:
       test: redis-cli ping || exit 1
     restart: always
@@ -94,7 +94,7 @@ services:
     container_name: immich_prometheus
     ports:
       - 9090:9090
-    image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
+    image: prom/prometheus@sha256:2659f4c2ebb718e7695cb9b25ffa7d6be64db013daba13e05c875451cf51b0d3
     volumes:
       - ./prometheus.yml:/etc/prometheus/prometheus.yml
       - prometheus-data:/prometheus
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 979343364c..86ec637cbb 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -48,7 +48,7 @@ services:
 
   redis:
     container_name: immich_redis
-    image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
+    image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
     healthcheck:
       test: redis-cli ping || exit 1
     restart: always
diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md
index 8e09d6339c..9ae4e3e51f 100644
--- a/docs/docs/administration/backup-and-restore.md
+++ b/docs/docs/administration/backup-and-restore.md
@@ -58,7 +58,7 @@ docker compose up -d            # Start remainder of Immich apps
   <TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
 
 ```powershell title='Backup'
-docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql"
+[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
 ```
 
 ```powershell title='Restore'
diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md
index d2e7fbee40..6015694976 100644
--- a/docs/docs/developer/pr-checklist.md
+++ b/docs/docs/developer/pr-checklist.md
@@ -1,5 +1,9 @@
 # PR Checklist
 
+A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
+:::warning
+The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
+:::
 When contributing code through a pull request, please check the following:
 
 ## Web Checks
diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md
index 32e79849ef..e7bde2178b 100644
--- a/docs/docs/developer/setup.md
+++ b/docs/docs/developer/setup.md
@@ -76,7 +76,7 @@ Setting these in the IDE give a better developer experience, auto-formatting cod
 
 ### Dart Code Metrics
 
-The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/getting-started/#installation) page for more information on setting up DCM
+The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/) page for more information on setting up DCM
 
 Note: Activating the license is not required.
 
diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md
index 4f059281f3..a561bafa80 100644
--- a/docs/docs/features/hardware-transcoding.md
+++ b/docs/docs/features/hardware-transcoding.md
@@ -1,7 +1,7 @@
 # Hardware Transcoding [Experimental]
 
 This feature allows you to use a GPU to accelerate transcoding and reduce CPU load.
-Note that hardware transcoding is much less efficient for file sizes.
+Note that hardware transcoding produces significantly larger videos than software transcoding with similar settings, typically with lower quality. Using slow presets and preferring more efficient codecs can narrow this gap.
 As this is a new feature, it is still experimental and may not work on all systems.
 
 :::info
diff --git a/docs/docs/guides/custom-locations.md b/docs/docs/guides/custom-locations.md
index 52cd0ccb25..514008611d 100644
--- a/docs/docs/guides/custom-locations.md
+++ b/docs/docs/guides/custom-locations.md
@@ -17,7 +17,7 @@ In our `.env` file, we will define variables that will help us in the future whe
 + THUMB_LOCATION=/custom/path/immich/thumbs
 + ENCODED_VIDEO_LOCATION=/custom/path/immich/encoded-video
 + PROFILE_LOCATION=/custom/path/immich/profile
-+ BACKUP_LOCATION=/custom/path/immich/backup
++ BACKUP_LOCATION=/custom/path/immich/backups
 ...
 ```
 
@@ -31,7 +31,7 @@ services:
 +     - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
 +     - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
 +     - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
-+     - ${BACKUP_LOCATION}:/usr/src/app/upload/backup
++     - ${BACKUP_LOCATION}:/usr/src/app/upload/backups
       - /etc/localtime:/etc/localtime:ro
 ```
 
diff --git a/docs/docs/install/img/truenas01.png b/docs/docs/install/img/truenas01.png
index 81b0430a75..e648ab3734 100644
Binary files a/docs/docs/install/img/truenas01.png and b/docs/docs/install/img/truenas01.png differ
diff --git a/docs/docs/install/img/truenas02.png b/docs/docs/install/img/truenas02.png
index ae7d41e624..66f0dec7fa 100644
Binary files a/docs/docs/install/img/truenas02.png and b/docs/docs/install/img/truenas02.png differ
diff --git a/docs/docs/install/img/truenas03.png b/docs/docs/install/img/truenas03.png
index 90ff25b7ac..d9970f5aeb 100644
Binary files a/docs/docs/install/img/truenas03.png and b/docs/docs/install/img/truenas03.png differ
diff --git a/docs/docs/install/img/truenas04.png b/docs/docs/install/img/truenas04.png
index 281d02350a..45fa87e5e5 100644
Binary files a/docs/docs/install/img/truenas04.png and b/docs/docs/install/img/truenas04.png differ
diff --git a/docs/docs/install/img/truenas05.png b/docs/docs/install/img/truenas05.png
index 919b008030..0f9d6a835a 100644
Binary files a/docs/docs/install/img/truenas05.png and b/docs/docs/install/img/truenas05.png differ
diff --git a/docs/docs/install/img/truenas06.png b/docs/docs/install/img/truenas06.png
index 26cf06738a..3daf250e36 100644
Binary files a/docs/docs/install/img/truenas06.png and b/docs/docs/install/img/truenas06.png differ
diff --git a/docs/docs/install/img/truenas07.png b/docs/docs/install/img/truenas07.png
index 17943e5c81..946c1401ac 100644
Binary files a/docs/docs/install/img/truenas07.png and b/docs/docs/install/img/truenas07.png differ
diff --git a/docs/docs/install/img/truenas08.png b/docs/docs/install/img/truenas08.png
index 4c5a90be6b..4ace8b49ca 100644
Binary files a/docs/docs/install/img/truenas08.png and b/docs/docs/install/img/truenas08.png differ
diff --git a/docs/docs/install/img/truenas09.png b/docs/docs/install/img/truenas09.png
index 647c7295b4..41830fe9e6 100644
Binary files a/docs/docs/install/img/truenas09.png and b/docs/docs/install/img/truenas09.png differ
diff --git a/docs/docs/install/img/truenas10.png b/docs/docs/install/img/truenas10.png
new file mode 100644
index 0000000000..730685c309
Binary files /dev/null and b/docs/docs/install/img/truenas10.png differ
diff --git a/docs/docs/install/img/truenas11.png b/docs/docs/install/img/truenas11.png
new file mode 100644
index 0000000000..88c166aed3
Binary files /dev/null and b/docs/docs/install/img/truenas11.png differ
diff --git a/docs/docs/install/img/truenas12.png b/docs/docs/install/img/truenas12.png
new file mode 100644
index 0000000000..a107a85f24
Binary files /dev/null and b/docs/docs/install/img/truenas12.png differ
diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md
index ffb559ed12..f35e9aa37a 100644
--- a/docs/docs/install/truenas.md
+++ b/docs/docs/install/truenas.md
@@ -7,7 +7,9 @@ sidebar_position: 80
 :::note
 This is a community contribution and not officially supported by the Immich team, but included here for convenience.
 
-**Please report issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
+Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
+
+**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
 :::
 
 Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
@@ -20,18 +22,26 @@ TrueNAS SCALE makes installing and updating Immich easy, but you must use the Im
 The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
 When updates become available, SCALE alerts and provides easy updates.
 
-Before installing the Immich app in SCALE, review the [Environment Variables](/docs/install/environment-variables.md) documentation to see if you want to configure any during installation.
-You can configure environment variables at any time after deploying the application.
+Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
+You may also configure environment variables at any time after deploying the application.
 
-You can allow SCALE to create the datasets Immich requires automatically during app installation.
-Or before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
-Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
-You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
+### Setting up Storage Datasets
+
+Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
+Immich requires seven datasets: `library`, `upload`, `thumbs`, `profile`, `video`, `backups`, and `pgData`.
+You can organize these as one parent with seven child datasets, for example `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, and so on.
+
+<img
+src={require('./img/truenas12.png').default}
+width="30%"
+alt="Immich App Widget"
+className="border rounded-xl"
+/>
 
 :::info Permissions
 The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
 
-The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
+If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
 :::
 
 ## Installing the Immich Application
@@ -47,6 +57,8 @@ className="border rounded-xl"
 
 Click on the widget to open the **Immich** application details screen.
 
+<br/><br/>
+
 <img
 src={require('./img/truenas02.png').default}
 width="100%"
@@ -56,9 +68,13 @@ className="border rounded-xl"
 
 Click **Install** to open the Immich application configuration screen.
 
+<br/><br/>
+
 Application configuration settings are presented in several sections, each explained below.
 To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
 
+### Application Name and Version
+
 <img
 src={require('./img/truenas03.png').default}
 width="100%"
@@ -66,21 +82,123 @@ alt="Install Immich Screen"
 className="border rounded-xl"
 />
 
-Accept the default values in **Application Name** and **Version**.
+Accept the default value or enter a name in **Application Name** field.
+In most cases use the default name, but if adding a second deployment of the application you must change this name.
+
+Accept the default version number in **Version**.
+When a new version becomes available, the application has an update badge.
+The **Installed Applications** screen shows the option to update applications.
+
+### Immich Configuration
+
+<img
+src={require('./img/truenas05.png').default}
+width="40%"
+alt="Configuration Settings"
+className="border rounded-xl"
+/>
 
 Accept the default value in **Timezone** or change to match your local timezone.
 **Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
 
-Accept the default port in **Web Port**.
+Untick **Enable Machine Learning** if you will not use face recognition, image search, and smart duplicate detection.
+
+Accept the default option or select the **Machine Learning Image Type** for your hardware based on the [Hardware-Accelerated Machine Learning Supported Backends](/docs/features/ml-hardware-acceleration.md#supported-backends).
+
+Immich's default is `postgres` but you should consider setting the **Database Password** to a custom value using only the characters `A-Za-z0-9`.
+
+The **Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`.
+
+Accept the **Log Level** default of **Log**.
+
+Leave **Hugging Face Endpoint** blank. (This is for downloading ML models from a different source.)
+
+Leave **Additional Environment Variables** blank or see [Environment Variables](#environment-variables) to set before installing.
+
+### Network Configuration
+
+<img
+src={require('./img/truenas06.png').default}
+width="40%"
+alt="Networking Settings"
+className="border rounded-xl"
+/>
+
+Accept the default port `30041` in **WebUI Port** or enter a custom port number.
+:::info Allowed Port Numbers
+Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel.
+
+Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/).
+:::
+
+### Storage Configuration
 
 Immich requires seven storage datasets.
-You can allow SCALE to create them for you, or use the dataset(s) created in [First Steps](#first-steps).
-Select the storage options you want to use for **Immich Uploads Storage**, **Immich Library Storage**, **Immich Thumbs Storage**, **Immich Profile Storage**, **Immich Video Storage**, **Immich Postgres Data Storage**, **Immich Postgres Backup Storage**.
-Select **ixVolume (dataset created automatically by the system)** in **Type** to let SCALE create the dataset or select **Host Path** to use the existing datasets created on the system.
 
-Accept the defaults in Resources or change the CPU and memory limits to suit your use case.
+<img
+src={require('./img/truenas07.png').default}
+width="20%"
+alt="Configure Storage ixVolumes"
+className="border rounded-xl"
+/>
 
-Click **Install**.
+:::note Default Setting (Not recommended)
+The default setting for datasets is **ixVolume (dataset created automatically by the system)** but this results in your data being harder to access manually and can result in data loss if you delete the immich app. (Not recommended)
+:::
+
+For each Storage option select **Host Path (Path that already exists on the system)** and then select the matching dataset [created before installing the app](#setting-up-storage-datasets): **Immich Library Storage**: `library`, **Immich Uploads Storage**: `upload`, **Immich Thumbs Storage**: `thumbs`, **Immich Profile Storage**: `profile`, **Immich Video Storage**: `video`, **Immich Backups Storage**: `backups`, **Postgres Data Storage**: `pgData`.
+
+<img
+src={require('./img/truenas08.png').default}
+width="40%"
+alt="Configure Storage Host Paths"
+className="border rounded-xl"
+/>
+The image above has example values.
+
+<br/>
+
+### Additional Storage [(External Libraries)](/docs/features/libraries)
+
+<img
+src={require('./img/truenas10.png').default}
+width="40%"
+alt="Configure Storage Host Paths"
+className="border rounded-xl"
+/>
+
+You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
+The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
+The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
+
+<!-- A section for Labels would go here but I don't know what they do. -->
+
+### Resources Configuration
+
+<img
+src={require('./img/truenas09.png').default}
+width="40%"
+alt="Resource Limits"
+className="border rounded-xl"
+/>
+
+Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
+
+Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB.
+
+:::info Older SCALE Versions
+Before TrueNAS SCALE version 24.10 Electric Eel:
+
+The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
+
+The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
+:::
+
+Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
+
+### Install
+
+Finally, click **Install**.
 The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
 When the installation completes it changes to **Running**.
 
@@ -97,102 +215,41 @@ Click **Web Portal** on the **Application Info** widget to open the Immich web i
 For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
 :::
 
-## Editing Environment Variables
+## Edit App Settings
 
-Go to the **Installed Applications** screen and select Immich from the list of installed applications.
-Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
-The settings on the edit screen are the same as on the install screen.
-You cannot edit **Storage Configuration** paths after the initial app install.
+- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
+- Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
+- Change any settings you would like to change.
+  - The settings on the edit screen are the same as on the install screen.
+- Click **Update** at the very bottom of the page to save changes.
+  - TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings.
 
-Click **Update** to save changes.
-TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
+## Environment Variables
+
+You can set [Environment Variables](/docs/install/environment-variables) by clicking **Add** on the **Additional Environment Variables** option and filling in the **Name** and **Value**.
+
+<img
+src={require('./img/truenas11.png').default}
+width="40%"
+alt="Environment Variables"
+className="border rounded-xl"
+/>
+
+:::info
+Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
+
+Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
+:::
 
 ## Updating the App
 
 When updates become available, SCALE alerts and provides easy updates.
-To update the app to the latest version, click **Update** on the **Application Info** widget from the **Installed Applications** screen.
+To update the app to the latest version:
 
-Update opens an update window for the application that includes two selectable options, Images (to be updated) and Changelog. Click on the down arrow to see the options available for each.
-
-Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
-
-## Understanding Immich Settings in TrueNAS SCALE
-
-Accept the default value or enter a name in **Application Name** field.
-In most cases use the default name, but if adding a second deployment of the application you must change this name.
-
-Accept the default version number in **Version**.
-When a new version becomes available, the application has an update badge.
-The **Installed Applications** screen shows the option to update applications.
-
-### Immich Configuration Settings
-
-You can accept the defaults in the **Immich Configuration** settings, or enter the settings you want to use.
-
-<img
-src={require('./img/truenas05.png').default}
-width="100%"
-alt="Configuration Settings"
-className="border rounded-xl"
-/>
-
-Accept the default setting in **Timezone** or change to match your local timezone.
-**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
-
-You can enter a **Public Login Message** to display on the login page, or leave it blank.
-
-### Networking Settings
-
-Accept the default port numbers in **Web Port**.
-The SCALE Immich app listens on port **30041**.
-
-Refer to the TrueNAS [default port list](https://www.truenas.com/docs/references/defaultports/) for a list of assigned port numbers.
-To change the port numbers, enter a number within the range 9000-65535.
-
-<img
-src={require('./img/truenas06.png').default}
-width="100%"
-alt="Networking Settings"
-className="border rounded-xl"
-/>
-
-### Storage Settings
-
-You can install Immich using the default setting **ixVolume (dataset created automatically by the system)** or use the host path option with datasets [created before installing the app](#first-steps).
-
-<img
-src={require('./img/truenas07.png').default}
-width="100%"
-alt="Configure Storage ixVolumes"
-className="border rounded-xl"
-/>
-
-Select **Host Path (Path that already exists on the system)** to browse to and select the datasets.
-
-<img
-src={require('./img/truenas08.png').default}
-width="100%"
-alt="Configure Storage Host Paths"
-className="border rounded-xl"
-/>
-
-### Resource Configuration Settings
-
-Accept the default values in **Resources Configuration** or enter new CPU and memory values
-By default, this application is limited to use no more than 4 CPU cores and 8 Gigabytes available memory. The application might use considerably less system resources.
-
-<img
-src={require('./img/truenas09.png').default}
-width="100%"
-alt="Resource Limits"
-className="border rounded-xl"
-/>
-
-To customize the CPU and memory allocated to the container Immich uses, enter new CPU values as a plain integer value followed by the suffix m (milli).
-Default is 4000m.
-
-Accept the default value 8Gi allocated memory or enter a new limit in bytes.
-Enter a plain integer followed by the measurement suffix, for example 129M or 123Mi.
-
-Systems with compatible GPU(s) display devices in **GPU Configuration**.
-See [Managing GPUs](https://www.truenas.com/docs/scale/scaletutorials/systemsettings/advanced/managegpuscale/) for more information about allocating isolated GPU devices in TrueNAS SCALE.
+- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
+- Click **Update** on the **Application Info** widget from the **Installed Applications** screen.
+- This opens an update window with some options
+  - You may select an Image update too.
+  - You may view the Changelog.
+- Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress.
+  - When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json
index 52ce0d57e5..bcd908c151 100644
--- a/docs/static/archived-versions.json
+++ b/docs/static/archived-versions.json
@@ -1,4 +1,8 @@
 [
+  {
+    "label": "v1.120.2",
+    "url": "https://v1.120.2.archive.immich.app"
+  },
   {
     "label": "v1.120.1",
     "url": "https://v1.120.1.archive.immich.app"
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index f8f41eac46..d9117b1b4a 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -34,7 +34,7 @@ services:
       - 2285:2285
 
   redis:
-    image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
+    image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
 
   database:
     image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 0357d6e507..ba27b69ad8 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-e2e",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-e2e",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "license": "GNU Affero General Public License version 3",
       "devDependencies": {
         "@eslint/eslintrc": "^3.1.0",
@@ -15,7 +15,7 @@
         "@immich/sdk": "file:../open-api/typescript-sdk",
         "@playwright/test": "^1.44.1",
         "@types/luxon": "^3.4.2",
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "@types/oidc-provider": "^8.5.1",
         "@types/pg": "^8.11.0",
         "@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
     },
     "../cli": {
       "name": "@immich/cli",
-      "version": "2.2.30",
+      "version": "2.2.31",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
@@ -64,7 +64,7 @@
         "@types/cli-progress": "^3.11.0",
         "@types/lodash-es": "^4.17.12",
         "@types/mock-fs": "^4.13.1",
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "@typescript-eslint/eslint-plugin": "^8.0.0",
         "@typescript-eslint/parser": "^8.0.0",
         "@vitest/coverage-v8": "^2.0.5",
@@ -92,14 +92,14 @@
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "dev": true,
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
       },
       "devDependencies": {
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "typescript": "^5.3.3"
       }
     },
diff --git a/e2e/package.json b/e2e/package.json
index c1a5227f93..b573d2e730 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-e2e",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "description": "",
   "main": "index.js",
   "type": "module",
@@ -25,7 +25,7 @@
     "@immich/sdk": "file:../open-api/typescript-sdk",
     "@playwright/test": "^1.44.1",
     "@types/luxon": "^3.4.2",
-    "@types/node": "^22.8.6",
+    "@types/node": "^22.9.0",
     "@types/oidc-provider": "^8.5.1",
     "@types/pg": "^8.11.0",
     "@types/pngjs": "^6.0.4",
diff --git a/i18n/en.json b/i18n/en.json
index 72e3e1e1bf..a1c79bbec4 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1283,7 +1283,7 @@
   "variables": "Variables",
   "version": "Version",
   "version_announcement_closing": "Your friend, Alex",
-  "version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
+  "version_announcement_message": "Hi there! A new version of Immich is available. Please take some time to read the <link>release notes</link> to ensure your setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your Immich instance automatically.",
   "version_history": "Version History",
   "version_history_item": "Installed {version} on {date}",
   "video": "Video",
diff --git a/i18n/fil.json b/i18n/fil.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/i18n/fil.json
@@ -0,0 +1 @@
+{}
diff --git a/i18n/nn.json b/i18n/nn.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/i18n/nn.json
@@ -0,0 +1 @@
+{}
diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock
index 32a2b73ffc..de4d03c4f4 100644
--- a/machine-learning/poetry.lock
+++ b/machine-learning/poetry.lock
@@ -747,14 +747,14 @@ files = [
 test = ["pytest (>=6)"]
 
 [[package]]
-name = "fastapi-slim"
+name = "fastapi"
 version = "0.115.4"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "fastapi_slim-0.115.4-py3-none-any.whl", hash = "sha256:8947515618c21665590a1673a0bfe4c721db4267999c149d5301c3c0f7b3d9ce"},
-    {file = "fastapi_slim-0.115.4.tar.gz", hash = "sha256:6d37987e4d1f6adefb8c7119c9b804e59c9b3f1a488be5425994d52308e2f958"},
+    {file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
+    {file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
 ]
 
 [package.dependencies]
@@ -3778,4 +3778,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.10,<4.0"
-content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044"
+content-hash = "b690d5fbd141da3947f4f1dc029aba1b95e7faafd723166f2c4bdc47a66c095e"
diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml
index 2b64a00ebe..8029dcd250 100644
--- a/machine-learning/pyproject.toml
+++ b/machine-learning/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.120.1"
+version = "1.120.2"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
@@ -11,7 +11,7 @@ python = ">=3.10,<4.0"
 insightface = ">=0.7.3,<1.0"
 opencv-python-headless = ">=4.7.0.72,<5.0"
 pillow = ">=9.5.0,<11.0"
-fastapi-slim = ">=0.95.2,<1.0"
+fastapi = ">=0.95.2,<1.0"
 uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
 pydantic = "^2.0.0"
 pydantic-settings = "^2.5.2"
diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile
index 6a3df82f07..59deb9a3be 100644
--- a/mobile/android/fastlane/Fastfile
+++ b/mobile/android/fastlane/Fastfile
@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 166,
-        "android.injected.version.name" => "1.120.1",
+        "android.injected.version.code" => 167,
+        "android.injected.version.name" => "1.120.2",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 6c233a7e29..f01807716e 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -401,7 +401,7 @@
 				CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 183;
+				CURRENT_PROJECT_VERSION = 184;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 183;
+				CURRENT_PROJECT_VERSION = 184;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 183;
+				CURRENT_PROJECT_VERSION = 184;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 2887a17c73..2617c7f96f 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -58,11 +58,11 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>1.120.1</string>
+	<string>1.120.2</string>
 	<key>CFBundleSignature</key>
 	<string>????</string>
 	<key>CFBundleVersion</key>
-	<string>183</string>
+	<string>184</string>
 	<key>FLTEnableImpeller</key>
 	<true/>
 	<key>ITSAppUsesNonExemptEncryption</key>
diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile
index a08ad49208..c4e0f6ca5c 100644
--- a/mobile/ios/fastlane/Fastfile
+++ b/mobile/ios/fastlane/Fastfile
@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Release"
   lane :release do
     increment_version_number(
-      version_number: "1.120.1"
+      version_number: "1.120.2"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,
diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart
index 6f6d1a6a31..d63928b5b8 100644
--- a/mobile/lib/constants/immich_colors.dart
+++ b/mobile/lib/constants/immich_colors.dart
@@ -19,6 +19,8 @@ const String defaultColorPresetName = "indigo";
 
 const Color immichBrandColorLight = Color(0xFF4150AF);
 const Color immichBrandColorDark = Color(0xFFACCBFA);
+const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
+const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
 
 final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
   ImmichColorPreset.indigo: ImmichTheme(
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 48bc936a82..3b582e336c 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -11,6 +11,7 @@ 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:immich_mobile/utils/download.dart';
+import 'package:intl/date_symbol_data_local.dart';
 import 'package:timezone/data/latest.dart';
 import 'package:immich_mobile/constants/locales.dart';
 import 'package:immich_mobile/services/background.service.dart';
@@ -56,6 +57,7 @@ void main() async {
 
 Future<void> initApp() async {
   await EasyLocalization.ensureInitialized();
+  await initializeDateFormatting();
 
   if (kReleaseMode && Platform.isAndroid) {
     try {
diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart
index 1fe7db5d46..60e31d707e 100644
--- a/mobile/lib/providers/authentication.provider.dart
+++ b/mobile/lib/providers/authentication.provider.dart
@@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       _ref;
   final _log = Logger("AuthenticationNotifier");
 
+  static const Duration _timeoutDuration = Duration(seconds: 7);
+
   Future<bool> login(
     String email,
     String password,
@@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
 
       await _apiService.authenticationApi
           .logout()
+          .timeout(_timeoutDuration)
           .then((_) => log.info("Logout was successful for $userEmail"))
           .onError(
             (error, stackTrace) =>
                 log.severe("Logout failed for $userEmail", error, stackTrace),
           );
-
+    } catch (e, stack) {
+      log.severe('Logout failed', e, stack);
+    } finally {
       await Future.wait([
         clearAssetsAndAlbums(_db),
         Store.delete(StoreKey.currentUser),
@@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
         shouldChangePassword: false,
         isAuthenticated: false,
       );
-    } catch (e, stack) {
-      log.severe('Logout failed', e, stack);
     }
   }
 
@@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
     UserPreferencesResponseDto? userPreferences;
     try {
       final responses = await Future.wait([
-        _apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
-        _apiService.usersApi
-            .getMyPreferences()
-            .timeout(const Duration(seconds: 7)),
+        _apiService.usersApi.getMyUser().timeout(_timeoutDuration),
+        _apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
       ]);
       userResponse = responses[0] as UserAdminResponseDto;
       userPreferences = responses[1] as UserPreferencesResponseDto;
diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart
index c0cf60514f..42d338956f 100644
--- a/mobile/lib/utils/immich_app_theme.dart
+++ b/mobile/lib/utils/immich_app_theme.dart
@@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
 import 'package:immich_mobile/services/app_settings.service.dart';
 
 class ImmichTheme {
-  ColorScheme light;
-  ColorScheme dark;
+  final ColorScheme light;
+  final ColorScheme dark;
 
-  ImmichTheme({required this.light, required this.dark});
+  const ImmichTheme({required this.light, required this.dark});
 }
 
 ImmichTheme? _immichDynamicTheme;
@@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
 
   return ThemeData(
     useMaterial3: true,
-    brightness: isDark ? Brightness.dark : Brightness.light,
+    brightness: colorScheme.brightness,
     colorScheme: colorScheme,
     primaryColor: primaryColor,
     hintColor: colorScheme.onSurfaceSecondary,
diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
index f550857b9d..eadaf0bf9f 100644
--- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/immich_colors.dart';
 import 'package:immich_mobile/extensions/build_context_extensions.dart';
 import 'package:immich_mobile/providers/album/album.provider.dart';
 import 'package:immich_mobile/providers/album/current_album.provider.dart';
@@ -327,39 +328,51 @@ class BottomGalleryBar extends ConsumerWidget {
       child: AnimatedOpacity(
         duration: const Duration(milliseconds: 100),
         opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
-        child: Column(
-          children: [
-            Visibility(
-              visible: showVideoPlayerControls,
-              child: const VideoControls(),
+        child: DecoratedBox(
+          decoration: const BoxDecoration(
+            gradient: LinearGradient(
+              begin: Alignment.bottomCenter,
+              end: Alignment.topCenter,
+              colors: [blackOpacity90, Colors.transparent],
             ),
-            BottomNavigationBar(
-              backgroundColor: Colors.black.withOpacity(0.4),
-              unselectedIconTheme: const IconThemeData(color: Colors.white),
-              selectedIconTheme: const IconThemeData(color: Colors.white),
-              unselectedLabelStyle: const TextStyle(
-                color: Colors.white,
-                fontWeight: FontWeight.w500,
-                height: 2.3,
-              ),
-              selectedLabelStyle: const TextStyle(
-                color: Colors.white,
-                fontWeight: FontWeight.w500,
-                height: 2.3,
-              ),
-              unselectedFontSize: 14,
-              selectedFontSize: 14,
-              selectedItemColor: Colors.white,
-              unselectedItemColor: Colors.white,
-              showSelectedLabels: true,
-              showUnselectedLabels: true,
-              items:
-                  albumActions.map((e) => e.keys.first).toList(growable: false),
-              onTap: (index) {
-                albumActions[index].values.first.call(index);
-              },
+          ),
+          position: DecorationPosition.background,
+          child: Padding(
+            padding: EdgeInsets.only(top: 40.0),
+            child: Column(
+              children: [
+                if (showVideoPlayerControls) const VideoControls(),
+                BottomNavigationBar(
+                  elevation: 0.0,
+                  backgroundColor: Colors.transparent,
+                  unselectedIconTheme: const IconThemeData(color: Colors.white),
+                  selectedIconTheme: const IconThemeData(color: Colors.white),
+                  unselectedLabelStyle: const TextStyle(
+                    color: Colors.white,
+                    fontWeight: FontWeight.w500,
+                    height: 2.3,
+                  ),
+                  selectedLabelStyle: const TextStyle(
+                    color: Colors.white,
+                    fontWeight: FontWeight.w500,
+                    height: 2.3,
+                  ),
+                  unselectedFontSize: 14,
+                  selectedFontSize: 14,
+                  selectedItemColor: Colors.white,
+                  unselectedItemColor: Colors.white,
+                  showSelectedLabels: true,
+                  showUnselectedLabels: true,
+                  items: albumActions
+                      .map((e) => e.keys.first)
+                      .toList(growable: false),
+                  onTap: (index) {
+                    albumActions[index].values.first.call(index);
+                  },
+                ),
+              ],
             ),
-          ],
+          ),
         ),
       ),
     );
diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart
new file mode 100644
index 0000000000..a34aab7d12
--- /dev/null
+++ b/mobile/lib/widgets/asset_viewer/formatted_duration.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+
+@pragma('vm:prefer-inline')
+String _formatDuration(Duration position) {
+  final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0");
+  final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0");
+  if (position.inHours == 0) {
+    return "$minutes:$seconds";
+  }
+  final hours = position.inHours.toString().padLeft(2, '0');
+  return "$hours:$minutes:$seconds";
+}
+
+class FormattedDuration extends StatelessWidget {
+  final Duration data;
+  const FormattedDuration(this.data, {super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
+      child: Text(
+        _formatDuration(data),
+        style: const TextStyle(
+          fontSize: 14.0,
+          color: Colors.white,
+          fontWeight: FontWeight.w500,
+        ),
+        textAlign: TextAlign.center,
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart
index a5f5f18ce8..e4d78324c8 100644
--- a/mobile/lib/widgets/asset_viewer/video_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/video_controls.dart
@@ -1,125 +1,20 @@
-import 'dart:math';
-
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
-import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
+import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
 
-/// The video controls for the [videPlayerControlsProvider]
+/// The video controls for the [videoPlayerControlsProvider]
 class VideoControls extends ConsumerWidget {
   const VideoControls({super.key});
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final duration =
-        ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
-    final position =
-        ref.watch(videoPlaybackValueProvider.select((v) => v.position));
-
-    return AnimatedOpacity(
-      opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
-      duration: const Duration(milliseconds: 100),
-      child: OrientationBuilder(
-        builder: (context, orientation) => Container(
-          padding: EdgeInsets.symmetric(
-            horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
-          ),
-          color: Colors.black.withOpacity(0.4),
-          child: Padding(
-            padding: MediaQuery.of(context).orientation == Orientation.portrait
-                ? const EdgeInsets.symmetric(horizontal: 12.0)
-                : const EdgeInsets.symmetric(horizontal: 64.0),
-            child: Row(
-              children: [
-                Text(
-                  _formatDuration(position),
-                  style: TextStyle(
-                    fontSize: 14.0,
-                    color: Colors.white.withOpacity(.75),
-                    fontWeight: FontWeight.normal,
-                  ),
-                ),
-                Expanded(
-                  child: Slider(
-                    value: duration == Duration.zero
-                        ? 0.0
-                        : min(
-                            position.inMicroseconds /
-                                duration.inMicroseconds *
-                                100,
-                            100,
-                          ),
-                    min: 0,
-                    max: 100,
-                    thumbColor: Colors.white,
-                    activeColor: Colors.white,
-                    inactiveColor: Colors.white.withOpacity(0.75),
-                    onChanged: (position) {
-                      ref.read(videoPlayerControlsProvider.notifier).position =
-                          position;
-                    },
-                  ),
-                ),
-                Text(
-                  _formatDuration(duration),
-                  style: TextStyle(
-                    fontSize: 14.0,
-                    color: Colors.white.withOpacity(.75),
-                    fontWeight: FontWeight.normal,
-                  ),
-                ),
-                IconButton(
-                  icon: Icon(
-                    ref.watch(
-                      videoPlayerControlsProvider.select((value) => value.mute),
-                    )
-                        ? Icons.volume_off
-                        : Icons.volume_up,
-                  ),
-                  onPressed: () => ref
-                      .read(videoPlayerControlsProvider.notifier)
-                      .toggleMute(),
-                  color: Colors.white,
-                ),
-              ],
-            ),
-          ),
-        ),
-      ),
-    );
-  }
-
-  String _formatDuration(Duration position) {
-    final ms = position.inMilliseconds;
-
-    int seconds = ms ~/ 1000;
-    final int hours = seconds ~/ 3600;
-    seconds = seconds % 3600;
-    final minutes = seconds ~/ 60;
-    seconds = seconds % 60;
-
-    final hoursString = hours >= 10
-        ? '$hours'
-        : hours == 0
-            ? '00'
-            : '0$hours';
-
-    final minutesString = minutes >= 10
-        ? '$minutes'
-        : minutes == 0
-            ? '00'
-            : '0$minutes';
-
-    final secondsString = seconds >= 10
-        ? '$seconds'
-        : seconds == 0
-            ? '00'
-            : '0$seconds';
-
-    final formattedTime =
-        '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
-
-    return formattedTime;
+    final isPortrait =
+        MediaQuery.orientationOf(context) == Orientation.portrait;
+    return isPortrait
+        ? const VideoPosition()
+        : const Padding(
+            padding: EdgeInsets.symmetric(horizontal: 60.0),
+            child: VideoPosition(),
+          );
   }
 }
diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart
new file mode 100644
index 0000000000..ef309b9c85
--- /dev/null
+++ b/mobile/lib/widgets/asset_viewer/video_position.dart
@@ -0,0 +1,110 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
+import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
+import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
+
+class VideoPosition extends HookConsumerWidget {
+  const VideoPosition({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final (position, duration) = ref.watch(
+      videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
+    );
+    final wasPlaying = useRef<bool>(true);
+    return duration == Duration.zero
+        ? const _VideoPositionPlaceholder()
+        : Column(
+            children: [
+              Padding(
+                // align with slider's inherent padding
+                padding: const EdgeInsets.symmetric(horizontal: 12.0),
+                child: Row(
+                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                  children: [
+                    FormattedDuration(position),
+                    FormattedDuration(duration),
+                  ],
+                ),
+              ),
+              Row(
+                children: [
+                  Expanded(
+                    child: Slider(
+                      value: min(
+                        position.inMicroseconds / duration.inMicroseconds * 100,
+                        100,
+                      ),
+                      min: 0,
+                      max: 100,
+                      thumbColor: Colors.white,
+                      activeColor: Colors.white,
+                      inactiveColor: whiteOpacity75,
+                      onChangeStart: (value) {
+                        final state =
+                            ref.read(videoPlaybackValueProvider).state;
+                        wasPlaying.value = state != VideoPlaybackState.paused;
+                        ref.read(videoPlayerControlsProvider.notifier).pause();
+                      },
+                      onChangeEnd: (value) {
+                        if (wasPlaying.value) {
+                          ref.read(videoPlayerControlsProvider.notifier).play();
+                        }
+                      },
+                      onChanged: (position) {
+                        ref
+                            .read(videoPlayerControlsProvider.notifier)
+                            .position = position;
+                      },
+                    ),
+                  ),
+                ],
+              ),
+            ],
+          );
+  }
+}
+
+class _VideoPositionPlaceholder extends StatelessWidget {
+  const _VideoPositionPlaceholder();
+
+  static void _onChangedDummy(_) {}
+
+  @override
+  Widget build(BuildContext context) {
+    return const Column(
+      children: [
+        Padding(
+          padding: EdgeInsets.symmetric(horizontal: 12.0),
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              FormattedDuration(Duration.zero),
+              FormattedDuration(Duration.zero),
+            ],
+          ),
+        ),
+        Row(
+          children: [
+            Expanded(
+              child: Slider(
+                value: 0.0,
+                min: 0,
+                max: 100,
+                thumbColor: Colors.white,
+                activeColor: Colors.white,
+                inactiveColor: whiteOpacity75,
+                onChanged: _onChangedDummy,
+              ),
+            ),
+          ],
+        ),
+      ],
+    );
+  }
+}
diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
index cd694336bc..38d161f852 100644
--- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
+++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
@@ -28,6 +28,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
     bool isHorizontal = !context.isMobile;
     final horizontalPadding = isHorizontal ? 100.0 : 20.0;
     final user = ref.watch(currentUserProvider);
+    final isLoggingOut = useState(false);
 
     useEffect(
       () {
@@ -63,11 +64,16 @@ class ImmichAppBarDialog extends HookConsumerWidget {
       );
     }
 
-    buildActionButton(IconData icon, String text, Function() onTap) {
+    buildActionButton(
+      IconData icon,
+      String text,
+      Function() onTap, {
+      Widget? trailing,
+    }) {
       return ListTile(
         dense: true,
         visualDensity: VisualDensity.standard,
-        contentPadding: const EdgeInsets.only(left: 30),
+        contentPadding: const EdgeInsets.only(left: 30, right: 30),
         minLeadingWidth: 40,
         leading: SizedBox(
           child: Icon(
@@ -83,6 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
           ),
         ).tr(),
         onTap: onTap,
+        trailing: trailing,
       );
     }
 
@@ -107,6 +114,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
         Icons.logout_rounded,
         "profile_drawer_sign_out",
         () async {
+          if (isLoggingOut.value) {
+            return;
+          }
+
           showDialog(
             context: context,
             builder: (BuildContext ctx) {
@@ -115,7 +126,11 @@ class ImmichAppBarDialog extends HookConsumerWidget {
                 content: "app_bar_signout_dialog_content",
                 ok: "app_bar_signout_dialog_ok",
                 onOk: () async {
-                  await ref.read(authenticationProvider.notifier).logout();
+                  isLoggingOut.value = true;
+                  await ref
+                      .read(authenticationProvider.notifier)
+                      .logout()
+                      .whenComplete(() => isLoggingOut.value = false);
 
                   ref.read(manualUploadProvider.notifier).cancelBackup();
                   ref.read(backupProvider.notifier).cancelBackup();
@@ -127,6 +142,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
             },
           );
         },
+        trailing: isLoggingOut.value
+            ? SizedBox.square(
+                dimension: 20,
+                child: const CircularProgressIndicator(strokeWidth: 2),
+              )
+            : null,
       );
     }
 
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 47bbc4be3c..cebb6817eb 100644
Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 0e432042da..a280dd6f9e 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 
 publish_to: 'none'
-version: 1.120.1+166
+version: 1.120.2+167
 
 environment:
   sdk: '>=3.3.0 <4.0.0'
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 5f907340e9..2d3f2fa6c2 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7385,7 +7385,7 @@
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.120.1",
+    "version": "1.120.2",
     "contact": {}
   },
   "tags": [],
diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json
index d7f3059b80..da78d70a77 100644
--- a/open-api/typescript-sdk/package-lock.json
+++ b/open-api/typescript-sdk/package-lock.json
@@ -1,18 +1,18 @@
 {
   "name": "@immich/sdk",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@immich/sdk",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
       },
       "devDependencies": {
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "typescript": "^5.3.3"
       }
     },
diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json
index 0cf484196d..5cc9cb3e9a 100644
--- a/open-api/typescript-sdk/package.json
+++ b/open-api/typescript-sdk/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@immich/sdk",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "description": "Auto-generated TypeScript SDK for the Immich API",
   "type": "module",
   "main": "./build/index.js",
@@ -19,7 +19,7 @@
     "@oazapfts/runtime": "^1.0.2"
   },
   "devDependencies": {
-    "@types/node": "^22.8.6",
+    "@types/node": "^22.9.0",
     "typescript": "^5.3.3"
   },
   "repository": {
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index f47c63a989..c2906ff6e0 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -1,6 +1,6 @@
 /**
  * Immich
- * 1.120.1
+ * 1.120.2
  * DO NOT MODIFY - This file has been generated using oazapfts.
  * See https://www.npmjs.com/package/oazapfts
  */
diff --git a/server/Dockerfile b/server/Dockerfile
index 896a2de300..6c8da4e305 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,5 +1,5 @@
 # dev build
-FROM ghcr.io/immich-app/base-server-dev:20241105@sha256:99eec44db9e281e30eb9c50161cfb8e810f06e4338896b900fb5cafd09e82cd5 AS dev
+FROM ghcr.io/immich-app/base-server-dev:20241112@sha256:889647c747b3f999b05e387eff414bcec5e42477958b267930e58ac58dadcfc7 AS dev
 
 RUN apt-get install --no-install-recommends -yqq tini
 WORKDIR /usr/src/app
@@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
 COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
 
 # web build
-FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS web
+FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS web
 
 WORKDIR /usr/src/open-api/typescript-sdk
 COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
@@ -42,7 +42,7 @@ RUN npm run build
 
 
 # prod build
-FROM ghcr.io/immich-app/base-server-prod:20241105@sha256:dbe566f5c53f36640da910ca86a7c5575a26e9b9f6bc8d90ae0a53b8bc3a1f73
+FROM ghcr.io/immich-app/base-server-prod:20241112@sha256:26a209563689f52b9a63feeedde9a16a8e0e558483cd3feb5c936423e55c7eea
 
 WORKDIR /usr/src/app
 ENV NODE_ENV=production \
diff --git a/server/package-lock.json b/server/package-lock.json
index 19398bb59d..08ad8a066f 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@nestjs/bullmq": "^10.0.1",
@@ -83,7 +83,7 @@
         "@types/lodash": "^4.14.197",
         "@types/mock-fs": "^4.13.1",
         "@types/multer": "^1.4.7",
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "@types/nodemailer": "^6.4.14",
         "@types/picomatch": "^3.0.0",
         "@types/pngjs": "^6.0.5",
diff --git a/server/package.json b/server/package.json
index 1ef0da4942..a54212052a 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "description": "",
   "author": "",
   "private": true,
@@ -108,7 +108,7 @@
     "@types/lodash": "^4.14.197",
     "@types/mock-fs": "^4.13.1",
     "@types/multer": "^1.4.7",
-    "@types/node": "^22.8.6",
+    "@types/node": "^22.9.0",
     "@types/nodemailer": "^6.4.14",
     "@types/picomatch": "^3.0.0",
     "@types/pngjs": "^6.0.5",
diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts
index b12847ee62..76f4fdfc98 100644
--- a/server/src/dtos/album.dto.ts
+++ b/server/src/dtos/album.dto.ts
@@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
 import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
 import { AlbumEntity } from 'src/entities/album.entity';
 import { AlbumUserRole, AssetOrder } from 'src/enum';
+import { getAssetDateTime } from 'src/utils/date-time';
 import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class AlbumInfoDto {
@@ -164,8 +165,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
   const hasSharedLink = entity.sharedLinks?.length > 0;
   const hasSharedUser = sharedUsers.length > 0;
 
-  let startDate = assets.at(0)?.fileCreatedAt || undefined;
-  let endDate = assets.at(-1)?.fileCreatedAt || undefined;
+  let startDate = getAssetDateTime(assets.at(0));
+  let endDate = getAssetDateTime(assets.at(-1));
   // Swap dates if start date is greater than end date.
   if (startDate && endDate && startDate > endDate) {
     [startDate, endDate] = [endDate, startDate];
diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts
index fe11d17b5f..468a6ad88d 100644
--- a/server/src/interfaces/media.interface.ts
+++ b/server/src/interfaces/media.interface.ts
@@ -114,7 +114,12 @@ export interface ImageBuffer {
 }
 
 export interface VideoCodecSWConfig {
-  getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
+  getCommand(
+    target: TranscodeTarget,
+    videoStream: VideoStreamInfo,
+    audioStream: AudioStreamInfo,
+    format?: VideoFormat,
+  ): TranscodeCommand;
 }
 
 export interface VideoCodecHWConfig extends VideoCodecSWConfig {
diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts
index 5f00596622..41ba7c2153 100644
--- a/server/src/services/backup.service.spec.ts
+++ b/server/src/services/backup.service.spec.ts
@@ -146,6 +146,7 @@ describe(BackupService.name, () => {
       storageMock.readdir.mockResolvedValue([]);
       processMock.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
       storageMock.rename.mockResolvedValue();
+      storageMock.unlink.mockResolvedValue();
       systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled);
       storageMock.createWriteStream.mockReturnValue(new PassThrough());
     });
@@ -188,5 +189,42 @@ describe(BackupService.name, () => {
       const result = await sut.handleBackupDatabase();
       expect(result).toBe(JobStatus.FAILED);
     });
+    it('should ignore unlink failing and still return failed job status', async () => {
+      processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
+      storageMock.unlink.mockRejectedValue(new Error('error'));
+      const result = await sut.handleBackupDatabase();
+      expect(storageMock.unlink).toHaveBeenCalled();
+      expect(result).toBe(JobStatus.FAILED);
+    });
+    it.each`
+      postgresVersion                       | expectedVersion
+      ${'14.10'}                            | ${14}
+      ${'14.10.3'}                          | ${14}
+      ${'14.10 (Debian 14.10-1.pgdg120+1)'} | ${14}
+      ${'15.3.3'}                           | ${15}
+      ${'16.4.2'}                           | ${16}
+      ${'17.15.1'}                          | ${17}
+    `(
+      `should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
+      async ({ postgresVersion, expectedVersion }) => {
+        databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
+        await sut.handleBackupDatabase();
+        expect(processMock.spawn).toHaveBeenCalledWith(
+          `/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
+          expect.any(Array),
+          expect.any(Object),
+        );
+      },
+    );
+    it.each`
+      postgresVersion
+      ${'13.99.99'}
+      ${'18.0.0'}
+    `(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => {
+      databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
+      const result = await sut.handleBackupDatabase();
+      expect(processMock.spawn).not.toHaveBeenCalled();
+      expect(result).toBe(JobStatus.FAILED);
+    });
   });
 });
diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts
index d08e0d397b..daa7d180f1 100644
--- a/server/src/services/backup.service.ts
+++ b/server/src/services/backup.service.ts
@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { default as path } from 'node:path';
+import semver from 'semver';
 import { StorageCore } from 'src/cores/storage.core';
 import { OnEvent, OnJob } from 'src/decorators';
 import { ImmichWorker, StorageFolder } from 'src/enum';
@@ -101,14 +102,29 @@ export class BackupService extends BaseService {
       `immich-db-backup-${Date.now()}.sql.gz.tmp`,
     );
 
+    const databaseVersion = await this.databaseRepository.getPostgresVersion();
+    const databaseSemver = semver.coerce(databaseVersion);
+    const databaseMajorVersion = databaseSemver?.major;
+
+    if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <18.0.0')) {
+      this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
+      return JobStatus.FAILED;
+    }
+
+    this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
+
     try {
       await new Promise<void>((resolve, reject) => {
-        const pgdump = this.processRepository.spawn(`pg_dumpall`, databaseParams, {
-          env: {
-            PATH: process.env.PATH,
-            PGPASSWORD: isUrlConnection ? undefined : config.password,
+        const pgdump = this.processRepository.spawn(
+          `/usr/lib/postgresql/${databaseMajorVersion}/bin/pg_dumpall`,
+          databaseParams,
+          {
+            env: {
+              PATH: process.env.PATH,
+              PGPASSWORD: isUrlConnection ? undefined : config.password,
+            },
           },
-        });
+        );
 
         // NOTE: `--rsyncable` is only supported in GNU gzip
         const gzip = this.processRepository.spawn(`gzip`, ['--rsyncable']);
@@ -163,10 +179,13 @@ export class BackupService extends BaseService {
       await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
     } catch (error) {
       this.logger.error('Database Backup Failure', error);
+      await this.storageRepository
+        .unlink(backupFilePath)
+        .catch((error) => this.logger.error('Failed to delete failed backup file', error));
       return JobStatus.FAILED;
     }
 
-    this.logger.debug(`Database Backup Success`);
+    this.logger.log(`Database Backup Success`);
     await this.cleanupDatabaseBackups();
     return JobStatus.SUCCESS;
   }
diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts
index 260497fcea..a23b05073c 100644
--- a/server/src/services/job.service.spec.ts
+++ b/server/src/services/job.service.spec.ts
@@ -1,5 +1,5 @@
 import { BadRequestException } from '@nestjs/common';
-import { defaults } from 'src/config';
+import { defaults, SystemConfig } from 'src/config';
 import { ImmichWorker } from 'src/enum';
 import { IAssetRepository } from 'src/interfaces/asset.interface';
 import { IConfigRepository } from 'src/interfaces/config.interface';
@@ -31,7 +31,7 @@ describe(JobService.name, () => {
 
   describe('onConfigUpdate', () => {
     it('should update concurrency', () => {
-      sut.onConfigInitOrUpdate({ newConfig: defaults });
+      sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
 
       expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15);
       expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1);
diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts
index 64490d4505..0528a4a925 100644
--- a/server/src/services/job.service.ts
+++ b/server/src/services/job.service.ts
@@ -39,8 +39,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
 @Injectable()
 export class JobService extends BaseService {
   @OnEvent({ name: 'config.init' })
-  @OnEvent({ name: 'config.update', server: true })
-  onConfigInitOrUpdate({ newConfig: config }: ArgOf<'config.init'>) {
+  onConfigInit({ newConfig: config }: ArgOf<'config.init'>) {
     if (this.worker !== ImmichWorker.MICROSERVICES) {
       return;
     }
@@ -56,6 +55,11 @@ export class JobService extends BaseService {
     }
   }
 
+  @OnEvent({ name: 'config.update', server: true })
+  onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) {
+    this.onConfigInit({ newConfig: config });
+  }
+
   async create(dto: JobCreateDto): Promise<void> {
     await this.jobRepository.queue(asJobItem(dto));
   }
diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts
index df1a04dff8..069376b8d3 100644
--- a/server/src/services/media.service.spec.ts
+++ b/server/src/services/media.service.spec.ts
@@ -487,6 +487,22 @@ describe(MediaService.name, () => {
         }),
       );
     });
+    it('should not skip intra frames for MTS file', async () => {
+      mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS);
+      assetMock.getById.mockResolvedValue(assetStub.video);
+      await sut.handleGenerateThumbnails({ id: assetStub.video.id });
+
+      expect(mediaMock.transcode).toHaveBeenCalledWith(
+        '/original/path.ext',
+        'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+        expect.objectContaining({
+          inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'],
+          outputOptions: expect.any(Array),
+          progress: expect.any(Object),
+          twoPass: false,
+        }),
+      );
+    });
 
     it('should use scaling divisible by 2 even when using quick sync', async () => {
       mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts
index c7b3dbced1..770e26b243 100644
--- a/server/src/services/media.service.ts
+++ b/server/src/services/media.service.ts
@@ -214,7 +214,7 @@ export class MediaService extends BaseService {
       const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
       const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
 
-      const orientation = Number(asset.exifInfo?.orientation) || undefined;
+      const orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
       const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation };
       const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions);
 
@@ -239,7 +239,7 @@ export class MediaService extends BaseService {
     const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
     this.storageCore.ensureFolders(previewPath);
 
-    const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
+    const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
     const mainVideoStream = this.getMainStream(videoStreams);
     if (!mainVideoStream) {
       throw new Error(`No video streams found for asset ${asset.id}`);
@@ -248,9 +248,14 @@ export class MediaService extends BaseService {
 
     const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
     const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
-
-    const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
-    const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
+    const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format);
+    const thumbnailOptions = thumbnailConfig.getCommand(
+      TranscodeTarget.VIDEO,
+      mainVideoStream,
+      mainAudioStream,
+      format,
+    );
+    this.logger.error(format.formatName);
     await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
     await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
 
diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts
index 496468a028..728e891d05 100644
--- a/server/src/services/storage-template.service.spec.ts
+++ b/server/src/services/storage-template.service.spec.ts
@@ -38,7 +38,7 @@ describe(StorageTemplateService.name, () => {
 
     systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } });
 
-    sut.onConfigInitOrUpdate({ newConfig: defaults });
+    sut.onConfigInit({ newConfig: defaults });
   });
 
   describe('onConfigValidate', () => {
@@ -171,7 +171,7 @@ describe(StorageTemplateService.name, () => {
       const config = structuredClone(defaults);
       config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
 
-      sut.onConfigInitOrUpdate({ newConfig: config });
+      sut.onConfigInit({ newConfig: config });
 
       userMock.get.mockResolvedValue(user);
       assetMock.getByIds.mockResolvedValueOnce([asset]);
@@ -192,7 +192,7 @@ describe(StorageTemplateService.name, () => {
       const user = userStub.user1;
       const config = structuredClone(defaults);
       config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
-      sut.onConfigInitOrUpdate({ newConfig: config });
+      sut.onConfigInit({ newConfig: config });
 
       userMock.get.mockResolvedValue(user);
       assetMock.getByIds.mockResolvedValueOnce([asset]);
diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts
index 08566446e3..e8e4bd12a5 100644
--- a/server/src/services/storage-template.service.ts
+++ b/server/src/services/storage-template.service.ts
@@ -75,8 +75,7 @@ export class StorageTemplateService extends BaseService {
   }
 
   @OnEvent({ name: 'config.init' })
-  @OnEvent({ name: 'config.update', server: true })
-  onConfigInitOrUpdate({ newConfig }: ArgOf<'config.init'>) {
+  onConfigInit({ newConfig }: ArgOf<'config.init'>) {
     const template = newConfig.storageTemplate.template;
     if (!this._template || template !== this.template.raw) {
       this.logger.debug(`Compiling new storage template: ${template}`);
@@ -84,6 +83,11 @@ export class StorageTemplateService extends BaseService {
     }
   }
 
+  @OnEvent({ name: 'config.update', server: true })
+  onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
+    this.onConfigInit({ newConfig });
+  }
+
   @OnEvent({ name: 'config.validate' })
   onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
     try {
diff --git a/server/src/utils/date-time.ts b/server/src/utils/date-time.ts
new file mode 100644
index 0000000000..e1578cbb19
--- /dev/null
+++ b/server/src/utils/date-time.ts
@@ -0,0 +1,5 @@
+import { AssetEntity } from 'src/entities/asset.entity';
+
+export const getAssetDateTime = (asset: AssetEntity | undefined) => {
+  return asset?.exifInfo?.dateTimeOriginal || asset?.fileCreatedAt;
+};
diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts
index f61b472b75..98d3c7fdbb 100644
--- a/server/src/utils/media.ts
+++ b/server/src/utils/media.ts
@@ -6,6 +6,7 @@ import {
   TranscodeCommand,
   VideoCodecHWConfig,
   VideoCodecSWConfig,
+  VideoFormat,
   VideoStreamInfo,
 } from 'src/interfaces/media.interface';
 
@@ -77,9 +78,14 @@ export class BaseConfig implements VideoCodecSWConfig {
     return handler;
   }
 
-  getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
+  getCommand(
+    target: TranscodeTarget,
+    videoStream: VideoStreamInfo,
+    audioStream?: AudioStreamInfo,
+    format?: VideoFormat,
+  ) {
     const options = {
-      inputOptions: this.getBaseInputOptions(videoStream),
+      inputOptions: this.getBaseInputOptions(videoStream, format),
       outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
       twoPass: this.eligibleForTwoPass(),
       progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
@@ -101,7 +107,7 @@ export class BaseConfig implements VideoCodecSWConfig {
   }
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  getBaseInputOptions(videoStream: VideoStreamInfo): string[] {
+  getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
     return this.getInputThreadOptions();
   }
 
@@ -377,8 +383,11 @@ export class ThumbnailConfig extends BaseConfig {
     return new ThumbnailConfig(config);
   }
 
-  getBaseInputOptions(): string[] {
-    return ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
+  getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
+    // skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
+    return format?.formatName === 'mpegts'
+      ? ['-sws_flags accurate_rnd+full_chroma_int']
+      : ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
   }
 
   getBaseOutputOptions() {
diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts
index 082959c227..de11c23f0a 100644
--- a/server/test/fixtures/media.stub.ts
+++ b/server/test/fixtures/media.stub.ts
@@ -95,6 +95,13 @@ export const probeStub = {
     ...probeStubDefault,
     videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
   }),
+  videoStreamMTS: Object.freeze<VideoInfo>({
+    ...probeStubDefault,
+    format: {
+      ...probeStubDefaultFormat,
+      formatName: 'mpegts',
+    },
+  }),
   videoStreamHDR: Object.freeze<VideoInfo>({
     ...probeStubDefault,
     videoStreams: [
diff --git a/web/Dockerfile b/web/Dockerfile
index 7e4d9769be..72bd9344da 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3
+FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82
 
 RUN apk add --no-cache tini
 USER node
diff --git a/web/package-lock.json b/web/package-lock.json
index 8cd702d7bd..e9f26bee80 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "immich-web",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "immich-web",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -36,7 +36,7 @@
         "@faker-js/faker": "^9.0.0",
         "@socket.io/component-emitter": "^3.1.0",
         "@sveltejs/adapter-static": "^3.0.5",
-        "@sveltejs/enhanced-img": "^0.3.0",
+        "@sveltejs/enhanced-img": "^0.3.9",
         "@sveltejs/kit": "^2.7.2",
         "@sveltejs/vite-plugin-svelte": "^4.0.0",
         "@testing-library/jest-dom": "^6.4.2",
@@ -53,7 +53,7 @@
         "dotenv": "^16.4.5",
         "eslint": "^9.0.0",
         "eslint-config-prettier": "^9.1.0",
-        "eslint-plugin-svelte": "^2.43.0",
+        "eslint-plugin-svelte": "^2.45.1",
         "eslint-plugin-unicorn": "^55.0.0",
         "factory.ts": "^1.4.1",
         "globals": "^15.9.0",
@@ -68,19 +68,19 @@
         "tailwindcss": "^3.4.1",
         "tslib": "^2.6.2",
         "typescript": "^5.5.0",
-        "vite": "^5.1.4",
+        "vite": "^5.4.4",
         "vitest": "^2.0.5"
       }
     },
     "../open-api/typescript-sdk": {
       "name": "@immich/sdk",
-      "version": "1.120.1",
+      "version": "1.120.2",
       "license": "GNU Affero General Public License version 3",
       "dependencies": {
         "@oazapfts/runtime": "^1.0.2"
       },
       "devDependencies": {
-        "@types/node": "^22.8.6",
+        "@types/node": "^22.9.0",
         "typescript": "^5.3.3"
       }
     },
diff --git a/web/package.json b/web/package.json
index b03379ee01..c0c600f5bc 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
 {
   "name": "immich-web",
-  "version": "1.120.1",
+  "version": "1.120.2",
   "license": "GNU Affero General Public License version 3",
   "scripts": {
     "dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -28,7 +28,7 @@
     "@faker-js/faker": "^9.0.0",
     "@socket.io/component-emitter": "^3.1.0",
     "@sveltejs/adapter-static": "^3.0.5",
-    "@sveltejs/enhanced-img": "^0.3.0",
+    "@sveltejs/enhanced-img": "^0.3.9",
     "@sveltejs/kit": "^2.7.2",
     "@sveltejs/vite-plugin-svelte": "^4.0.0",
     "@testing-library/jest-dom": "^6.4.2",
@@ -45,7 +45,7 @@
     "dotenv": "^16.4.5",
     "eslint": "^9.0.0",
     "eslint-config-prettier": "^9.1.0",
-    "eslint-plugin-svelte": "^2.43.0",
+    "eslint-plugin-svelte": "^2.45.1",
     "eslint-plugin-unicorn": "^55.0.0",
     "factory.ts": "^1.4.1",
     "globals": "^15.9.0",
@@ -60,7 +60,7 @@
     "tailwindcss": "^3.4.1",
     "tslib": "^2.6.2",
     "typescript": "^5.5.0",
-    "vite": "^5.1.4",
+    "vite": "^5.4.4",
     "vitest": "^2.0.5"
   },
   "type": "module",
diff --git a/web/src/lib/actions/__test__/focus-trap-test.svelte b/web/src/lib/actions/__test__/focus-trap-test.svelte
index 207c880cd9..e1cb6fa4fb 100644
--- a/web/src/lib/actions/__test__/focus-trap-test.svelte
+++ b/web/src/lib/actions/__test__/focus-trap-test.svelte
@@ -1,16 +1,20 @@
 <script lang="ts">
   import { focusTrap } from '$lib/actions/focus-trap';
 
-  export let show: boolean;
+  interface Props {
+    show: boolean;
+  }
+
+  let { show = $bindable() }: Props = $props();
 </script>
 
-<button type="button" on:click={() => (show = true)}>Open</button>
+<button type="button" onclick={() => (show = true)}>Open</button>
 
 {#if show}
   <div use:focusTrap>
     <div>
       <span>text</span>
-      <button data-testid="one" type="button" on:click={() => (show = false)}>Close</button>
+      <button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
     </div>
     <input data-testid="two" disabled />
     <input data-testid="three" />
diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts
index ff80454ef3..664039cb2a 100644
--- a/web/src/lib/actions/autogrow.ts
+++ b/web/src/lib/actions/autogrow.ts
@@ -1,4 +1,4 @@
-export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
+export const autoGrowHeight = (textarea?: HTMLTextAreaElement, height = 'auto') => {
   if (!textarea) {
     return;
   }
diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts
index 3b45e7fe52..89b7b76d24 100644
--- a/web/src/lib/actions/context-menu-navigation.ts
+++ b/web/src/lib/actions/context-menu-navigation.ts
@@ -10,7 +10,7 @@ interface Options {
   /**
    * The container element that with direct children that should be navigated.
    */
-  container: HTMLElement;
+  container?: HTMLElement;
   /**
    * Indicates if the dropdown is open.
    */
@@ -52,7 +52,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
       await tick();
     }
 
-    const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
+    if (!container) {
+      return;
+    }
+
+    const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
     if (children.length === 0) {
       return;
     }
diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts
index 8f8ed62ed0..cd4214f700 100644
--- a/web/src/lib/actions/list-navigation.ts
+++ b/web/src/lib/actions/list-navigation.ts
@@ -6,8 +6,15 @@ import type { Action } from 'svelte/action';
  * @param node Element which listens for keyboard events
  * @param container Element containing the list of elements
  */
-export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
+export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
+  node: HTMLElement,
+  container?: HTMLElement,
+) => {
   const moveFocus = (direction: 'up' | 'down') => {
+    if (!container) {
+      return;
+    }
+
     const children = Array.from(container?.children);
     if (children.length === 0) {
       return;
diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
index a2fbbe787a..6eb603263e 100644
--- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
+++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte
@@ -7,13 +7,17 @@
   import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let user: UserResponseDto;
-  export let onSuccess: () => void;
-  export let onFail: () => void;
-  export let onCancel: () => void;
+  interface Props {
+    user: UserResponseDto;
+    onSuccess: () => void;
+    onFail: () => void;
+    onCancel: () => void;
+  }
 
-  let forceDelete = false;
-  let deleteButtonDisabled = false;
+  let { user, onSuccess, onFail, onCancel }: Props = $props();
+
+  let forceDelete = $state(false);
+  let deleteButtonDisabled = $state(false);
   let userIdInput: string = '';
 
   const handleDeleteUser = async () => {
@@ -47,12 +51,14 @@
   {onCancel}
   disabled={deleteButtonDisabled}
 >
-  <svelte:fragment slot="prompt">
+  {#snippet promptSnippet()}
     <div class="flex flex-col gap-4">
       {#if forceDelete}
         <p>
-          <FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }} let:message>
-            <b>{message}</b>
+          <FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
+            {#snippet children({ message })}
+              <b>{message}</b>
+            {/snippet}
           </FormatMessage>
         </p>
       {:else}
@@ -60,9 +66,10 @@
           <FormatMessage
             key="admin.user_delete_delay"
             values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
-            let:message
           >
-            <b>{message}</b>
+            {#snippet children({ message })}
+              <b>{message}</b>
+            {/snippet}
           </FormatMessage>
         </p>
       {/if}
@@ -73,7 +80,7 @@
           label={$t('admin.user_delete_immediately_checkbox')}
           labelClass="text-sm dark:text-immich-dark-fg"
           bind:checked={forceDelete}
-          on:change={() => {
+          onchange={() => {
             deleteButtonDisabled = forceDelete;
           }}
         />
@@ -92,9 +99,9 @@
           aria-describedby="confirm-user-desc"
           name="confirm-user-id"
           type="text"
-          on:input={handleConfirm}
+          oninput={handleConfirm}
         />
       {/if}
     </div>
-  </svelte:fragment>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte
index 69d3706230..f71d8a3e44 100644
--- a/web/src/lib/components/admin-page/jobs/job-tile-button.svelte
+++ b/web/src/lib/components/admin-page/jobs/job-tile-button.svelte
@@ -1,10 +1,18 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type Colors = 'light-gray' | 'gray' | 'dark-gray';
 </script>
 
 <script lang="ts">
-  export let color: Colors;
-  export let disabled = false;
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    color: Colors;
+    disabled?: boolean;
+    children?: Snippet;
+    onClick?: () => void;
+  }
+
+  let { color, disabled = false, onClick = () => {}, children }: Props = $props();
 
   const colorClasses: Record<Colors, string> = {
     'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
@@ -23,7 +31,7 @@
   class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
     color
   ]} {hoverClasses}"
-  on:click
+  onclick={onClick}
 >
-  <slot />
+  {@render children?.()}
 </button>
diff --git a/web/src/lib/components/admin-page/jobs/job-tile-status.svelte b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte
index ca36764797..5bffa45b89 100644
--- a/web/src/lib/components/admin-page/jobs/job-tile-status.svelte
+++ b/web/src/lib/components/admin-page/jobs/job-tile-status.svelte
@@ -1,9 +1,16 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type Color = 'success' | 'warning';
 </script>
 
 <script lang="ts">
-  export let color: Color;
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    color: Color;
+    children?: Snippet;
+  }
+
+  let { color, children }: Props = $props();
 
   const colorClasses: Record<Color, string> = {
     success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
@@ -12,5 +19,5 @@
 </script>
 
 <div class="w-full p-2 text-center text-sm {colorClasses[color]}">
-  <slot />
+  {@render children?.()}
 </div>
diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte
index 81c23e927b..0e39647c75 100644
--- a/web/src/lib/components/admin-page/jobs/job-tile.svelte
+++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte
@@ -19,22 +19,37 @@
   import JobTileButton from './job-tile-button.svelte';
   import JobTileStatus from './job-tile-status.svelte';
 
-  export let title: string;
-  export let subtitle: string | undefined;
-  export let description: Component | undefined;
-  export let jobCounts: JobCountsDto;
-  export let queueStatus: QueueStatusDto;
-  export let icon: string;
-  export let disabled = false;
+  interface Props {
+    title: string;
+    subtitle: string | undefined;
+    description: Component | undefined;
+    jobCounts: JobCountsDto;
+    queueStatus: QueueStatusDto;
+    icon: string;
+    disabled?: boolean;
+    allText: string | undefined;
+    refreshText: string | undefined;
+    missingText: string;
+    onCommand: (command: JobCommandDto) => void;
+  }
 
-  export let allText: string | undefined;
-  export let refreshText: string | undefined;
-  export let missingText: string;
-  export let onCommand: (command: JobCommandDto) => void;
+  let {
+    title,
+    subtitle,
+    description,
+    jobCounts,
+    queueStatus,
+    icon,
+    disabled = false,
+    allText,
+    refreshText,
+    missingText,
+    onCommand,
+  }: Props = $props();
 
-  $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
-  $: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
-  $: multipleButtons = allText || refreshText;
+  let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed);
+  let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
+  let multipleButtons = $derived(allText || refreshText);
 
   const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
 </script>
@@ -67,7 +82,7 @@
                   title={$t('clear_message')}
                   size="12"
                   padding="1"
-                  on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
+                  onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
                 />
               </div>
             </Badge>
@@ -87,8 +102,9 @@
       {/if}
 
       {#if description}
+        {@const SvelteComponent = description}
         <div class="text-sm dark:text-white">
-          <svelte:component this={description} />
+          <SvelteComponent />
         </div>
       {/if}
 
@@ -118,7 +134,7 @@
       <JobTileButton
         disabled={true}
         color="light-gray"
-        on:click={() => onCommand({ command: JobCommand.Start, force: false })}
+        onClick={() => onCommand({ command: JobCommand.Start, force: false })}
       >
         <Icon path={mdiAlertCircle} size="36" />
         {$t('disabled').toUpperCase()}
@@ -127,20 +143,20 @@
 
     {#if !disabled && !isIdle}
       {#if waitingCount > 0}
-        <JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Empty, force: false })}>
+        <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}>
           <Icon path={mdiClose} size="24" />
           {$t('clear').toUpperCase()}
         </JobTileButton>
       {/if}
       {#if queueStatus.isPaused}
         {@const size = waitingCount > 0 ? '24' : '48'}
-        <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Resume, force: false })}>
+        <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
           <!-- size property is not reactive, so have to use width and height -->
           <Icon path={mdiFastForward} {size} />
           {$t('resume').toUpperCase()}
         </JobTileButton>
       {:else}
-        <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Pause, force: false })}>
+        <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
           <Icon path={mdiPause} size="24" />
           {$t('pause').toUpperCase()}
         </JobTileButton>
@@ -149,25 +165,25 @@
 
     {#if !disabled && multipleButtons && isIdle}
       {#if allText}
-        <JobTileButton color="dark-gray" on:click={() => onCommand({ command: JobCommand.Start, force: true })}>
+        <JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
           <Icon path={mdiAllInclusive} size="24" />
           {allText}
         </JobTileButton>
       {/if}
       {#if refreshText}
-        <JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Start, force: undefined })}>
+        <JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
           <Icon path={mdiImageRefreshOutline} size="24" />
           {refreshText}
         </JobTileButton>
       {/if}
-      <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
+      <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
         <Icon path={mdiSelectionSearch} size="24" />
         {missingText}
       </JobTileButton>
     {/if}
 
     {#if !disabled && !multipleButtons && isIdle}
-      <JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
+      <JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
         <Icon path={mdiPlay} size="48" />
         {$t('start').toUpperCase()}
       </JobTileButton>
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
index 67d672d398..9b4f3ffdd6 100644
--- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
+++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
@@ -25,7 +25,11 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let jobs: AllJobStatusResponseDto;
+  interface Props {
+    jobs: AllJobStatusResponseDto;
+  }
+
+  let { jobs = $bindable() }: Props = $props();
 
   interface JobDetails {
     title: string;
@@ -56,8 +60,7 @@
     await handleCommand(jobId, dto);
   };
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: jobDetails = <Partial<Record<JobName, JobDetails>>>{
+  let jobDetails: Partial<Record<JobName, JobDetails>> = {
     [JobName.ThumbnailGeneration]: {
       icon: mdiFileJpgBox,
       title: $getJobName(JobName.ThumbnailGeneration),
@@ -142,7 +145,8 @@
       missingText: $t('missing'),
     },
   };
-  $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
+
+  let jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
 
   async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
     const title = jobDetails[jobId]?.title;
diff --git a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
index 8a74d2c5ad..b47df1daae 100644
--- a/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
+++ b/web/src/lib/components/admin-page/jobs/storage-migration-description.svelte
@@ -7,12 +7,13 @@
 <FormatMessage
   key="admin.storage_template_migration_description"
   values={{ template: $t('admin.storage_template_settings') }}
-  let:message
 >
-  <a
-    href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
-    class="text-immich-primary dark:text-immich-dark-primary"
-  >
-    {message}
-  </a>
+  {#snippet children({ message })}
+    <a
+      href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
+      class="text-immich-primary dark:text-immich-dark-primary"
+    >
+      {message}
+    </a>
+  {/snippet}
 </FormatMessage>
diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte
index 25afbc6d4b..a72ada2ca5 100644
--- a/web/src/lib/components/admin-page/restore-dialogue.svelte
+++ b/web/src/lib/components/admin-page/restore-dialogue.svelte
@@ -5,10 +5,14 @@
   import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let user: UserResponseDto;
-  export let onSuccess: () => void;
-  export let onFail: () => void;
-  export let onCancel: () => void;
+  interface Props {
+    user: UserResponseDto;
+    onSuccess: () => void;
+    onFail: () => void;
+    onCancel: () => void;
+  }
+
+  let { user, onSuccess, onFail, onCancel }: Props = $props();
 
   const handleRestoreUser = async () => {
     try {
@@ -32,11 +36,13 @@
   onConfirm={handleRestoreUser}
   {onCancel}
 >
-  <svelte:fragment slot="prompt">
+  {#snippet promptSnippet()}
     <p>
-      <FormatMessage key="admin.user_restore_description" values={{ user: user.name }} let:message>
-        <b>{message}</b>
+      <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
+        {#snippet children({ message })}
+          <b>{message}</b>
+        {/snippet}
       </FormatMessage>
     </p>
-  </svelte:fragment>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
index 35afc0962d..feab6a9c6d 100644
--- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
+++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte
@@ -7,14 +7,20 @@
   import StatsCard from './stats-card.svelte';
   import { t } from 'svelte-i18n';
 
-  export let stats: ServerStatsResponseDto = {
-    photos: 0,
-    videos: 0,
-    usage: 0,
-    usageByUser: [],
-  };
+  interface Props {
+    stats?: ServerStatsResponseDto;
+  }
 
-  $: zeros = (value: number) => {
+  let {
+    stats = {
+      photos: 0,
+      videos: 0,
+      usage: 0,
+      usageByUser: [],
+    },
+  }: Props = $props();
+
+  const zeros = (value: number) => {
     const maxLength = 13;
     const valueLength = value.toString().length;
     const zeroLength = maxLength - valueLength;
@@ -23,7 +29,7 @@
   };
 
   const TiB = 1024 ** 4;
-  $: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0);
+  let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
 </script>
 
 <div class="flex flex-col gap-5">
diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte
index 31baa0afdd..14d32c055f 100644
--- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte
+++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte
@@ -2,18 +2,22 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { ByteUnit } from '$lib/utils/byte-units';
 
-  export let icon: string;
-  export let title: string;
-  export let value: number;
-  export let unit: ByteUnit | undefined = undefined;
+  interface Props {
+    icon: string;
+    title: string;
+    value: number;
+    unit?: ByteUnit | undefined;
+  }
 
-  $: zeros = () => {
+  let { icon, title, value, unit = undefined }: Props = $props();
+
+  const zeros = $derived(() => {
     const maxLength = 13;
     const valueLength = value.toString().length;
     const zeroLength = maxLength - valueLength;
 
     return '0'.repeat(zeroLength);
-  };
+  });
 </script>
 
 <div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte
index 19a8580d6b..199db0b571 100644
--- a/web/src/lib/components/admin-page/settings/admin-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte
@@ -1,5 +1,3 @@
-<svelte:options accessors />
-
 <script lang="ts">
   import {
     NotificationType,
@@ -13,12 +11,17 @@
   import type { SettingsResetOptions } from './admin-settings';
   import { t } from 'svelte-i18n';
 
-  export let config: SystemConfigDto;
+  interface Props {
+    config: SystemConfigDto;
+    children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
+  }
 
-  let savedConfig: SystemConfigDto;
-  let defaultConfig: SystemConfigDto;
+  let { config = $bindable(), children }: Props = $props();
 
-  const handleReset = async (options: SettingsResetOptions) => {
+  let savedConfig: SystemConfigDto | undefined = $state();
+  let defaultConfig: SystemConfigDto | undefined = $state();
+
+  export const handleReset = async (options: SettingsResetOptions) => {
     await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
   };
 
@@ -26,7 +29,8 @@
     let systemConfigDto = {
       ...savedConfig,
       ...update,
-    };
+    } as SystemConfigDto;
+
     if (isEqual(systemConfigDto, savedConfig)) {
       return;
     }
@@ -59,6 +63,10 @@
   };
 
   const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
+    if (!defaultConfig) {
+      return;
+    }
+
     for (const key of configKeys) {
       config = { ...config, [key]: defaultConfig[key] };
     }
@@ -75,5 +83,5 @@
 </script>
 
 {#if savedConfig && defaultConfig}
-  <slot {handleReset} {handleSave} {savedConfig} {defaultConfig} />
+  {@render children({ savedConfig, defaultConfig })}
 {/if}
diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
index 9b0e4b3270..7f94dfa253 100644
--- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte
@@ -2,9 +2,7 @@
   import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { type SystemConfigDto } from '@immich/sdk';
   import { isEqual } from 'lodash-es';
@@ -12,15 +10,20 @@
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
 
-  let isConfirmOpen = false;
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  let isConfirmOpen = $state(false);
 
   const handleToggleOverride = () => {
     // click runs before bind
@@ -48,29 +51,31 @@
     onCancel={() => (isConfirmOpen = false)}
     onConfirm={() => handleSave(true)}
   >
-    <svelte:fragment slot="prompt">
+    {#snippet promptSnippet()}
       <div class="flex flex-col gap-4">
         <p>{$t('admin.authentication_settings_disable_all')}</p>
         <p>
-          <FormatMessage key="admin.authentication_settings_reenable" let:message>
-            <a
-              href="https://immich.app/docs/administration/server-commands"
-              rel="noreferrer"
-              target="_blank"
-              class="underline"
-            >
-              {message}
-            </a>
+          <FormatMessage key="admin.authentication_settings_reenable">
+            {#snippet children({ message })}
+              <a
+                href="https://immich.app/docs/administration/server-commands"
+                rel="noreferrer"
+                target="_blank"
+                class="underline"
+              >
+                {message}
+              </a>
+            {/snippet}
           </FormatMessage>
         </p>
       </div>
-    </svelte:fragment>
+    {/snippet}
   </ConfirmDialog>
 {/if}
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
       <div class="ml-4 mt-4 flex flex-col">
         <SettingAccordion
           key="oauth"
@@ -79,15 +84,17 @@
         >
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <p class="text-sm dark:text-immich-dark-fg">
-              <FormatMessage key="admin.oauth_settings_more_details" let:message>
-                <a
-                  href="https://immich.app/docs/administration/oauth"
-                  class="underline"
-                  target="_blank"
-                  rel="noreferrer"
-                >
-                  {message}
-                </a>
+              <FormatMessage key="admin.oauth_settings_more_details">
+                {#snippet children({ message })}
+                  <a
+                    href="https://immich.app/docs/administration/oauth"
+                    class="underline"
+                    target="_blank"
+                    rel="noreferrer"
+                  >
+                    {message}
+                  </a>
+                {/snippet}
               </FormatMessage>
             </p>
 
@@ -147,7 +154,7 @@
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
                 label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
-                desc={$t('admin.oauth_profile_signing_algorithm_description')}
+                description={$t('admin.oauth_profile_signing_algorithm_description')}
                 bind:value={config.oauth.profileSigningAlgorithm}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -157,7 +164,7 @@
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
                 label={$t('admin.oauth_storage_label_claim').toUpperCase()}
-                desc={$t('admin.oauth_storage_label_claim_description')}
+                description={$t('admin.oauth_storage_label_claim_description')}
                 bind:value={config.oauth.storageLabelClaim}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -167,7 +174,7 @@
               <SettingInputField
                 inputType={SettingInputFieldType.TEXT}
                 label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
-                desc={$t('admin.oauth_storage_quota_claim_description')}
+                description={$t('admin.oauth_storage_quota_claim_description')}
                 bind:value={config.oauth.storageQuotaClaim}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -177,7 +184,7 @@
               <SettingInputField
                 inputType={SettingInputFieldType.NUMBER}
                 label={$t('admin.oauth_storage_quota_default').toUpperCase()}
-                desc={$t('admin.oauth_storage_quota_default_description')}
+                description={$t('admin.oauth_storage_quota_default_description')}
                 bind:value={config.oauth.defaultStorageQuota}
                 required={true}
                 disabled={disabled || !config.oauth.enabled}
@@ -213,7 +220,7 @@
                   values: { callback: 'app.immich:///oauth-callback' },
                 })}
                 disabled={disabled || !config.oauth.enabled}
-                on:click={() => handleToggleOverride()}
+                onToggle={() => handleToggleOverride()}
                 bind:checked={config.oauth.mobileOverrideEnabled}
               />
 
diff --git a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte
index 05543f1124..3ec477e29c 100644
--- a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte
@@ -3,33 +3,40 @@
   import { isEqual } from 'lodash-es';
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
 
-  $: cronExpressionOptions = [
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  let cronExpressionOptions = $derived([
     { text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
     { text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
     { text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
     { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
-  ];
+  ]);
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSwitch
           title={$t('admin.backup_database_enable_description')}
@@ -53,21 +60,23 @@
           bind:value={config.backup.database.cronExpression}
           isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
         >
-          <svelte:fragment slot="desc">
+          {#snippet descriptionSnippet()}
             <p class="text-sm dark:text-immich-dark-fg">
-              <FormatMessage key="admin.cron_expression_description" let:message>
-                <a
-                  href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
-                  class="underline"
-                  target="_blank"
-                  rel="noreferrer"
-                >
-                  {message}
-                  <br />
-                </a>
+              <FormatMessage key="admin.cron_expression_description">
+                {#snippet children({ message })}
+                  <a
+                    href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
+                    class="underline"
+                    target="_blank"
+                    rel="noreferrer"
+                  >
+                    {message}
+                    <br />
+                  </a>
+                {/snippet}
               </FormatMessage>
             </p>
-          </svelte:fragment>
+          {/snippet}
         </SettingInputField>
 
         <SettingInputField
diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
index 8f5b587ae6..702ec1c171 100644
--- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
@@ -15,44 +15,53 @@
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <p class="text-sm dark:text-immich-dark-fg">
           <Icon path={mdiHelpCircleOutline} class="inline" size="15" />
-          <FormatMessage key="admin.transcoding_codecs_learn_more" let:tag let:message>
-            {#if tag === 'h264-link'}
-              <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
-                {message}
-              </a>
-            {:else if tag === 'hevc-link'}
-              <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
-                {message}
-              </a>
-            {:else if tag === 'vp9-link'}
-              <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
-                {message}
-              </a>
-            {/if}
+          <FormatMessage key="admin.transcoding_codecs_learn_more">
+            {#snippet children({ tag, message })}
+              {#if tag === 'h264-link'}
+                <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
+                  {message}
+                </a>
+              {:else if tag === 'hevc-link'}
+                <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
+                  {message}
+                </a>
+              {:else if tag === 'vp9-link'}
+                <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
+                  {message}
+                </a>
+              {/if}
+            {/snippet}
           </FormatMessage>
         </p>
 
@@ -60,7 +69,7 @@
           inputType={SettingInputFieldType.NUMBER}
           {disabled}
           label={$t('admin.transcoding_constant_rate_factor')}
-          desc={$t('admin.transcoding_constant_rate_factor_description')}
+          description={$t('admin.transcoding_constant_rate_factor_description')}
           bind:value={config.ffmpeg.crf}
           required={true}
           isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
@@ -186,7 +195,7 @@
           inputType={SettingInputFieldType.TEXT}
           {disabled}
           label={$t('admin.transcoding_max_bitrate')}
-          desc={$t('admin.transcoding_max_bitrate_description')}
+          description={$t('admin.transcoding_max_bitrate_description')}
           bind:value={config.ffmpeg.maxBitrate}
           isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
         />
@@ -195,7 +204,7 @@
           inputType={SettingInputFieldType.NUMBER}
           {disabled}
           label={$t('admin.transcoding_threads')}
-          desc={$t('admin.transcoding_threads_description')}
+          description={$t('admin.transcoding_threads_description')}
           bind:value={config.ffmpeg.threads}
           isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
         />
@@ -329,7 +338,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               label={$t('admin.transcoding_preferred_hardware_device')}
-              desc={$t('admin.transcoding_preferred_hardware_device_description')}
+              description={$t('admin.transcoding_preferred_hardware_device_description')}
               bind:value={config.ffmpeg.preferredHwDevice}
               isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
               {disabled}
@@ -346,7 +355,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
               label={$t('admin.transcoding_max_b_frames')}
-              desc={$t('admin.transcoding_max_b_frames_description')}
+              description={$t('admin.transcoding_max_b_frames_description')}
               bind:value={config.ffmpeg.bframes}
               isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
               {disabled}
@@ -355,7 +364,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
               label={$t('admin.transcoding_reference_frames')}
-              desc={$t('admin.transcoding_reference_frames_description')}
+              description={$t('admin.transcoding_reference_frames_description')}
               bind:value={config.ffmpeg.refs}
               isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
               {disabled}
@@ -364,7 +373,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
               label={$t('admin.transcoding_max_keyframe_interval')}
-              desc={$t('admin.transcoding_max_keyframe_interval_description')}
+              description={$t('admin.transcoding_max_keyframe_interval_description')}
               bind:value={config.ffmpeg.gopSize}
               isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
               {disabled}
diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte
index 50ae494570..2f2bcbca64 100644
--- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte
@@ -7,24 +7,39 @@
 
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { t } from 'svelte-i18n';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
-  export let openByDefault = false;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+    openByDefault?: boolean;
+  }
+
+  let {
+    savedConfig,
+    defaultConfig,
+    config = $bindable(),
+    disabled = false,
+    onReset,
+    onSave,
+    openByDefault = false,
+  }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingAccordion
           key="thumbnail-settings"
@@ -65,7 +80,7 @@
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
             label={$t('admin.image_quality')}
-            desc={$t('admin.image_thumbnail_quality_description')}
+            description={$t('admin.image_thumbnail_quality_description')}
             bind:value={config.image.thumbnail.quality}
             isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
             {disabled}
@@ -110,7 +125,7 @@
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
             label={$t('admin.image_quality')}
-            desc={$t('admin.image_preview_quality_description')}
+            description={$t('admin.image_preview_quality_description')}
             bind:value={config.image.preview.quality}
             isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
             {disabled}
diff --git a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte
index e09fde8bae..356de6ae86 100644
--- a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte
@@ -5,17 +5,20 @@
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
 
   const jobNames = [
     JobName.ThumbnailGeneration,
@@ -34,11 +37,15 @@
   function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
     return jobName in config.job;
   }
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       {#each jobNames as jobName}
         <div class="ml-4 mt-4 flex flex-col gap-4">
           {#if isSystemConfigJobDto(jobName)}
@@ -46,7 +53,7 @@
               inputType={SettingInputFieldType.NUMBER}
               {disabled}
               label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
-              desc=""
+              description=""
               bind:value={config.job[jobName].concurrency}
               required={true}
               isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
@@ -55,7 +62,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.NUMBER}
               label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
-              desc=""
+              description=""
               value="1"
               disabled={true}
               title={$t('admin.job_not_concurrency_safe')}
diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
index b494dca53f..b1012c0287 100644
--- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte
@@ -4,34 +4,49 @@
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
-  export let openByDefault = false;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+    openByDefault?: boolean;
+  }
 
-  $: cronExpressionOptions = [
+  let {
+    savedConfig,
+    defaultConfig,
+    config = $bindable(),
+    disabled = false,
+    onReset,
+    onSave,
+    openByDefault = false,
+  }: Props = $props();
+
+  let cronExpressionOptions = $derived([
     { text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
     { text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
     { text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
     { text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
-  ];
+  ]);
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingAccordion
           key="library-watching"
@@ -77,20 +92,22 @@
               bind:value={config.library.scan.cronExpression}
               isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
             >
-              <svelte:fragment slot="desc">
+              {#snippet descriptionSnippet()}
                 <p class="text-sm dark:text-immich-dark-fg">
-                  <FormatMessage key="admin.cron_expression_description" let:message>
-                    <a
-                      href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
-                      class="underline"
-                      target="_blank"
-                      rel="noreferrer"
-                    >
-                      {message}
-                    </a>
+                  <FormatMessage key="admin.cron_expression_description">
+                    {#snippet children({ message })}
+                      <a
+                        href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
+                        class="underline"
+                        target="_blank"
+                        rel="noreferrer"
+                      >
+                        {message}
+                      </a>
+                    {/snippet}
                   </FormatMessage>
                 </p>
-              </svelte:fragment>
+              {/snippet}
             </SettingInputField>
           </div>
         </SettingAccordion>
diff --git a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
index 6e71ba926c..29a1c65162 100644
--- a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte
@@ -8,17 +8,25 @@
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import { t } from 'svelte-i18n';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSwitch
           title={$t('admin.logging_enable_description')}
diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
index aac8cd5212..13678a31c1 100644
--- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
@@ -5,26 +5,33 @@
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div class="mt-2">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
+    <form autocomplete="off" {onsubmit} class="mx-4 mt-4">
       <div class="flex flex-col gap-4">
         <SettingSwitch
           title={$t('admin.machine_learning_enabled')}
@@ -38,7 +45,7 @@
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
           label={$t('url')}
-          desc={$t('admin.machine_learning_url_description')}
+          description={$t('admin.machine_learning_url_description')}
           bind:value={config.machineLearning.url}
           required={true}
           disabled={disabled || !config.machineLearning.enabled}
@@ -69,11 +76,15 @@
             disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
             isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
           >
-            <p slot="desc" class="immich-form-label pb-2 text-sm">
-              <FormatMessage key="admin.machine_learning_clip_model_description" let:message>
-                <a href="https://huggingface.co/immich-app"><u>{message}</u></a>
-              </FormatMessage>
-            </p>
+            {#snippet descriptionSnippet()}
+              <p class="immich-form-label pb-2 text-sm">
+                <FormatMessage key="admin.machine_learning_clip_model_description">
+                  {#snippet children({ message })}
+                    <a href="https://huggingface.co/immich-app"><u>{message}</u></a>
+                  {/snippet}
+                </FormatMessage>
+              </p>
+            {/snippet}
           </SettingInputField>
         </div>
       </SettingAccordion>
@@ -100,7 +111,7 @@
             step="0.0005"
             min={0.001}
             max={0.1}
-            desc={$t('admin.machine_learning_max_detection_distance_description')}
+            description={$t('admin.machine_learning_max_detection_distance_description')}
             disabled={disabled || !$featureFlags.duplicateDetection}
             isEdited={config.machineLearning.duplicateDetection.maxDistance !==
               savedConfig.machineLearning.duplicateDetection.maxDistance}
@@ -142,7 +153,7 @@
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
             label={$t('admin.machine_learning_min_detection_score')}
-            desc={$t('admin.machine_learning_min_detection_score_description')}
+            description={$t('admin.machine_learning_min_detection_score_description')}
             bind:value={config.machineLearning.facialRecognition.minScore}
             step="0.1"
             min={0.1}
@@ -155,7 +166,7 @@
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
             label={$t('admin.machine_learning_max_recognition_distance')}
-            desc={$t('admin.machine_learning_max_recognition_distance_description')}
+            description={$t('admin.machine_learning_max_recognition_distance_description')}
             bind:value={config.machineLearning.facialRecognition.maxDistance}
             step="0.1"
             min={0.1}
@@ -168,7 +179,7 @@
           <SettingInputField
             inputType={SettingInputFieldType.NUMBER}
             label={$t('admin.machine_learning_min_recognized_faces')}
-            desc={$t('admin.machine_learning_min_recognized_faces_description')}
+            description={$t('admin.machine_learning_min_recognized_faces_description')}
             bind:value={config.machineLearning.facialRecognition.minFaces}
             step="1"
             min={1}
diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
index 7c2c5c856a..4a4b23ded2 100644
--- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte
@@ -6,23 +6,30 @@
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div class="mt-2">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="flex flex-col gap-4">
         <SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
           <div class="ml-4 mt-4 flex flex-col gap-4">
@@ -38,7 +45,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               label={$t('admin.map_light_style')}
-              desc={$t('admin.map_style_description')}
+              description={$t('admin.map_style_description')}
               bind:value={config.map.lightStyle}
               disabled={disabled || !config.map.enabled}
               isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
@@ -46,7 +53,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               label={$t('admin.map_dark_style')}
-              desc={$t('admin.map_style_description')}
+              description={$t('admin.map_style_description')}
               bind:value={config.map.darkStyle}
               disabled={disabled || !config.map.enabled}
               isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
@@ -55,20 +62,22 @@
         >
 
         <SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
-          <svelte:fragment slot="subtitle">
+          {#snippet subtitleSnippet()}
             <p class="text-sm dark:text-immich-dark-fg">
-              <FormatMessage key="admin.map_manage_reverse_geocoding_settings" let:message>
-                <a
-                  href="https://immich.app/docs/features/reverse-geocoding"
-                  class="underline"
-                  target="_blank"
-                  rel="noreferrer"
-                >
-                  {message}
-                </a>
+              <FormatMessage key="admin.map_manage_reverse_geocoding_settings">
+                {#snippet children({ message })}
+                  <a
+                    href="https://immich.app/docs/features/reverse-geocoding"
+                    class="underline"
+                    target="_blank"
+                    rel="noreferrer"
+                  >
+                    {message}
+                  </a>
+                {/snippet}
               </FormatMessage>
             </p>
-          </svelte:fragment>
+          {/snippet}
           <div class="ml-4 mt-4 flex flex-col gap-4">
             <SettingSwitch
               title={$t('admin.map_reverse_geocoding_enable_description')}
diff --git a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte
index c28050e022..1ba82b2eb9 100644
--- a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte
@@ -7,17 +7,25 @@
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { t } from 'svelte-i18n';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div class="mt-2">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
+    <form autocomplete="off" {onsubmit} class="mx-4 mt-4">
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSwitch
           title={$t('admin.metadata_faces_import_setting')}
diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
index 76c238df82..1a6f0ab866 100644
--- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte
@@ -7,17 +7,25 @@
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { t } from 'svelte-i18n';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4">
         <SettingSwitch
           title={$t('admin.version_check_enabled_description')}
diff --git a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
index fcd26c684b..28187978f9 100644
--- a/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/notification-settings/notification-settings.svelte
@@ -3,9 +3,7 @@
   import { isEqual } from 'lodash-es';
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
@@ -18,15 +16,20 @@
   import { user } from '$lib/stores/user.store';
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { handleError } from '$lib/utils/handle-error';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
 
-  let isSending = false;
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  let isSending = $state(false);
 
   const handleSendTestEmail = async () => {
     if (isSending) {
@@ -65,11 +68,15 @@
       isSending = false;
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault class="mt-4">
+    <form autocomplete="off" {onsubmit} class="mt-4">
       <div class="flex flex-col gap-4">
         <SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
           <div class="ml-4 mt-4 flex flex-col gap-4">
@@ -85,7 +92,7 @@
               inputType={SettingInputFieldType.TEXT}
               required
               label={$t('host')}
-              desc={$t('admin.notification_email_host_description')}
+              description={$t('admin.notification_email_host_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.host}
               isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
@@ -95,7 +102,7 @@
               inputType={SettingInputFieldType.NUMBER}
               required
               label={$t('port')}
-              desc={$t('admin.notification_email_port_description')}
+              description={$t('admin.notification_email_port_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.port}
               isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
@@ -104,7 +111,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.TEXT}
               label={$t('username')}
-              desc={$t('admin.notification_email_username_description')}
+              description={$t('admin.notification_email_username_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.username}
               isEdited={config.notifications.smtp.transport.username !==
@@ -114,7 +121,7 @@
             <SettingInputField
               inputType={SettingInputFieldType.PASSWORD}
               label={$t('password')}
-              desc={$t('admin.notification_email_password_description')}
+              description={$t('admin.notification_email_password_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.transport.password}
               isEdited={config.notifications.smtp.transport.password !==
@@ -134,14 +141,14 @@
               inputType={SettingInputFieldType.TEXT}
               required
               label={$t('admin.notification_email_from_address')}
-              desc={$t('admin.notification_email_from_address_description')}
+              description={$t('admin.notification_email_from_address_description')}
               disabled={disabled || !config.notifications.smtp.enabled}
               bind:value={config.notifications.smtp.from}
               isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
             />
 
             <div class="flex gap-2 place-items-center">
-              <Button size="sm" disabled={!config.notifications.smtp.enabled} on:click={handleSendTestEmail}>
+              <Button size="sm" disabled={!config.notifications.smtp.enabled} onclick={handleSendTestEmail}>
                 {#if disabled}
                   {$t('admin.notification_email_test_email')}
                 {:else}
diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte
index f021c99f24..14d5624c5f 100644
--- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte
@@ -3,28 +3,35 @@
   import { isEqual } from 'lodash-es';
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="mt-4 ml-4">
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
           label={$t('admin.server_external_domain_settings')}
-          desc={$t('admin.server_external_domain_settings_description')}
+          description={$t('admin.server_external_domain_settings_description')}
           bind:value={config.server.externalDomain}
           isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
         />
@@ -32,7 +39,7 @@
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
           label={$t('admin.server_welcome_message')}
-          desc={$t('admin.server_welcome_message_description')}
+          description={$t('admin.server_welcome_message_description')}
           bind:value={config.server.loginPageMessage}
           isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
         />
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index 4ebf4ed118..74d240a4a6 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -1,6 +1,9 @@
 <script lang="ts">
+  import { createBubbler, preventDefault } from 'svelte/legacy';
+
+  const bubble = createBubbler();
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
-  import { AppRoute } from '$lib/constants';
+  import { AppRoute, SettingInputFieldType } from '$lib/constants';
   import { user } from '$lib/stores/user.store';
   import {
     getStorageTemplateOptions,
@@ -15,24 +18,38 @@
   import SupportedDatetimePanel from './supported-datetime-panel.svelte';
   import SupportedVariablesPanel from './supported-variables-panel.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import type { Snippet } from 'svelte';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let minified = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
-  export let duration: number = 500;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    minified?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+    duration?: number;
+    children?: Snippet;
+  }
 
-  let templateOptions: SystemConfigTemplateStorageOptionDto;
-  let selectedPreset = '';
+  let {
+    savedConfig,
+    defaultConfig,
+    config = $bindable(),
+    disabled = false,
+    minified = false,
+    onReset,
+    onSave,
+    duration = 500,
+    children,
+  }: Props = $props();
+
+  let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
+  let selectedPreset = $state('');
 
   const getTemplateOptions = async () => {
     templateOptions = await getStorageTemplateOptions();
@@ -41,15 +58,11 @@
 
   const getSupportDateTimeFormat = () => getStorageTemplateOptions();
 
-  $: parsedTemplate = () => {
-    try {
-      return renderTemplate(config.storageTemplate.template);
-    } catch {
-      return 'error';
-    }
-  };
-
   const renderTemplate = (templateString: string) => {
+    if (!templateOptions) {
+      return '';
+    }
+
     const template = handlebar.compile(templateString, {
       knownHelpers: undefined,
     });
@@ -85,31 +98,40 @@
   const handlePresetSelection = () => {
     config.storageTemplate.template = selectedPreset;
   };
+  let parsedTemplate = $derived(() => {
+    try {
+      return renderTemplate(config.storageTemplate.template);
+    } catch {
+      return 'error';
+    }
+  });
 </script>
 
 <section class="dark:text-immich-dark-fg mt-2">
   <div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
     <p class="text-sm dark:text-immich-dark-fg">
-      <FormatMessage key="admin.storage_template_more_details" let:tag let:message>
-        {#if tag === 'template-link'}
-          <a
-            href="https://immich.app/docs/administration/storage-template"
-            class="underline"
-            target="_blank"
-            rel="noreferrer"
-          >
-            {message}
-          </a>
-        {:else if tag === 'implications-link'}
-          <a
-            href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
-            class="underline"
-            target="_blank"
-            rel="noreferrer"
-          >
-            {message}
-          </a>
-        {/if}
+      <FormatMessage key="admin.storage_template_more_details">
+        {#snippet children({ tag, message })}
+          {#if tag === 'template-link'}
+            <a
+              href="https://immich.app/docs/administration/storage-template"
+              class="underline"
+              target="_blank"
+              rel="noreferrer"
+            >
+              {message}
+            </a>
+          {:else if tag === 'implications-link'}
+            <a
+              href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
+              class="underline"
+              target="_blank"
+              rel="noreferrer"
+            >
+              {message}
+            </a>
+          {/if}
+        {/snippet}
       </FormatMessage>
     </p>
   </div>
@@ -164,19 +186,18 @@
             <FormatMessage
               key="admin.storage_template_path_length"
               values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
-              let:message
             >
-              <span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
+              {#snippet children({ message })}
+                <span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
+              {/snippet}
             </FormatMessage>
           </p>
 
           <p class="text-sm">
-            <FormatMessage
-              key="admin.storage_template_user_label"
-              values={{ label: $user.storageLabel || $user.id }}
-              let:message
-            >
-              <code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
+            <FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
+              {#snippet children({ message })}
+                <code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
+              {/snippet}
             </FormatMessage>
           </p>
 
@@ -186,24 +207,30 @@
             >/{parsedTemplate()}.jpg
           </p>
 
-          <form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
+          <form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
             <div class="flex flex-col my-2">
-              <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="preset-select">
-                {$t('preset')}
-              </label>
-              <select
-                class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
-                disabled={disabled || !config.storageTemplate.enabled}
-                name="presets"
-                id="preset-select"
-                bind:value={selectedPreset}
-                on:change={handlePresetSelection}
-              >
-                {#each templateOptions.presetOptions as preset}
-                  <option value={preset}>{renderTemplate(preset)}</option>
-                {/each}
-              </select>
+              {#if templateOptions}
+                <label
+                  class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
+                  for="preset-select"
+                >
+                  {$t('preset')}
+                </label>
+                <select
+                  class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
+                  disabled={disabled || !config.storageTemplate.enabled}
+                  name="presets"
+                  id="preset-select"
+                  bind:value={selectedPreset}
+                  onchange={handlePresetSelection}
+                >
+                  {#each templateOptions.presetOptions as preset}
+                    <option value={preset}>{renderTemplate(preset)}</option>
+                  {/each}
+                </select>
+              {/if}
             </div>
+
             <div class="flex gap-2 align-bottom">
               <SettingInputField
                 label={$t('template')}
@@ -232,11 +259,12 @@
                     <FormatMessage
                       key="admin.storage_template_migration_info"
                       values={{ job: $t('admin.storage_template_migration_job') }}
-                      let:message
                     >
-                      <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
-                        {message}
-                      </a>
+                      {#snippet children({ message })}
+                        <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
+                          {message}
+                        </a>
+                      {/snippet}
                     </FormatMessage>
                   </p>
                 </section>
@@ -247,7 +275,7 @@
       {/if}
 
       {#if minified}
-        <slot />
+        {@render children?.()}
       {:else}
         <SettingButtonsRow
           onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}
diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
index 10f22c1805..379e366df6 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/supported-datetime-panel.svelte
@@ -4,7 +4,11 @@
   import { DateTime } from 'luxon';
   import { t } from 'svelte-i18n';
 
-  export let options: SystemConfigTemplateStorageOptionDto;
+  interface Props {
+    options: SystemConfigTemplateStorageOptionDto;
+  }
+
+  let { options }: Props = $props();
 
   const getLuxonExample = (format: string) => {
     return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
index 84a12e05c9..ca5b4c934b 100644
--- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte
@@ -7,22 +7,30 @@
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import { t } from 'svelte-i18n';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingTextarea
           {disabled}
           label={$t('admin.theme_custom_css_settings')}
-          desc={$t('admin.theme_custom_css_settings_description')}
+          description={$t('admin.theme_custom_css_settings_description')}
           bind:value={config.theme.customCss}
           required={true}
           isEdited={config.theme.customCss !== savedConfig.theme.customCss}
diff --git a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
index 8f287d48e0..05979bf9f0 100644
--- a/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/trash-settings/trash-settings.svelte
@@ -4,23 +4,30 @@
   import { fade } from 'svelte/transition';
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
 
@@ -29,7 +36,7 @@
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           label={$t('admin.trash_number_of_days')}
-          desc={$t('admin.trash_number_of_days_description')}
+          description={$t('admin.trash_number_of_days_description')}
           bind:value={config.trash.days}
           required={true}
           disabled={disabled || !config.trash.enabled}
diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
index 21453cbc70..f96c3808a8 100644
--- a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte
@@ -5,28 +5,31 @@
   import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
 
   import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let savedConfig: SystemConfigDto;
-  export let defaultConfig: SystemConfigDto;
-  export let config: SystemConfigDto; // this is the config that is being edited
-  export let disabled = false;
-  export let onReset: SettingsResetEvent;
-  export let onSave: SettingsSaveEvent;
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+  }
+
+  let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
 </script>
 
 <div>
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           min={1}
           label={$t('admin.user_delete_delay_settings')}
-          desc={$t('admin.user_delete_delay_settings_description')}
+          description={$t('admin.user_delete_delay_settings_description')}
           bind:value={config.user.deleteDelay}
           isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
         />
diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
index 8da9fbfd45..9e396bec3e 100644
--- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts
+++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
@@ -1,14 +1,15 @@
 import { sdkMock } from '$lib/__mocks__/sdk.mock';
 import { albumFactory } from '@test-data/factories/album-factory';
 import '@testing-library/jest-dom';
-import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
+import { render, waitFor, type RenderResult } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
 import { init, register, waitLocale } from 'svelte-i18n';
 import AlbumCard from '../album-card.svelte';
 
 const onShowContextMenu = vi.fn();
 
 describe('AlbumCard component', () => {
-  let sut: RenderResult<AlbumCard>;
+  let sut: RenderResult<typeof AlbumCard>;
 
   beforeAll(async () => {
     await init({ fallbackLocale: 'en-US' });
@@ -110,13 +111,9 @@ describe('AlbumCard component', () => {
         toJSON: () => ({}),
       });
 
-      await fireEvent(
-        contextMenuButton,
-        new MouseEvent('click', {
-          clientX: 123,
-          clientY: 456,
-        }),
-      );
+      const user = userEvent.setup();
+      await user.click(contextMenuButton);
+
       expect(onShowContextMenu).toHaveBeenCalledTimes(1);
       expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
     });
diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte
index f899cebd8c..ae2b27efac 100644
--- a/web/src/lib/components/album-page/album-card-group.svelte
+++ b/web/src/lib/components/album-page/album-card-group.svelte
@@ -11,28 +11,43 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { t } from 'svelte-i18n';
 
-  export let albums: AlbumResponseDto[];
-  export let group: AlbumGroup | undefined = undefined;
-  export let showOwner = false;
-  export let showDateRange = false;
-  export let showItemCount = false;
-  export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
-    undefined;
+  interface Props {
+    albums: AlbumResponseDto[];
+    group?: AlbumGroup | undefined;
+    showOwner?: boolean;
+    showDateRange?: boolean;
+    showItemCount?: boolean;
+    onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
+  }
 
-  $: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id);
+  let {
+    albums,
+    group = undefined,
+    showOwner = false,
+    showDateRange = false,
+    showItemCount = false,
+    onShowContextMenu = undefined,
+  }: Props = $props();
+
+  let isCollapsed = $derived(!!group && isAlbumGroupCollapsed($albumViewSettings, group.id));
 
   const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
     onShowContextMenu?.(position, album);
   };
 
-  $: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90';
+  let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
+
+  const oncontextmenu = (event: MouseEvent, album: AlbumResponseDto) => {
+    event.preventDefault();
+    showContextMenu({ x: event.x, y: event.y }, album);
+  };
 </script>
 
 {#if group}
   <div class="grid">
     <button
       type="button"
-      on:click={() => toggleAlbumGroupCollapsing(group.id)}
+      onclick={() => toggleAlbumGroupCollapsing(group.id)}
       class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
       aria-expanded={!isCollapsed}
     >
@@ -56,7 +71,7 @@
           data-sveltekit-preload-data="hover"
           href="{AppRoute.ALBUMS}/{album.id}"
           animate:flip={{ duration: 400 }}
-          on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)}
+          oncontextmenu={(event) => oncontextmenu(event, album)}
         >
           <AlbumCard
             {album}
diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte
index f574c65f0b..cec4919e4e 100644
--- a/web/src/lib/components/album-page/album-card.svelte
+++ b/web/src/lib/components/album-page/album-card.svelte
@@ -8,12 +8,23 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let showOwner = false;
-  export let showDateRange = false;
-  export let showItemCount = false;
-  export let preload = false;
-  export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined;
+  interface Props {
+    album: AlbumResponseDto;
+    showOwner?: boolean;
+    showDateRange?: boolean;
+    showItemCount?: boolean;
+    preload?: boolean;
+    onShowContextMenu?: ((position: ContextMenuPosition) => unknown) | undefined;
+  }
+
+  let {
+    album,
+    showOwner = false,
+    showDateRange = false,
+    showItemCount = false,
+    preload = false,
+    onShowContextMenu = undefined,
+  }: Props = $props();
 
   const showAlbumContextMenu = (e: MouseEvent) => {
     e.stopPropagation();
@@ -39,7 +50,7 @@
         size="20"
         padding="2"
         class="icon-white-drop-shadow"
-        on:click={showAlbumContextMenu}
+        onclick={showAlbumContextMenu}
       />
     </div>
   {/if}
diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte
index d0444f3599..3f71bbe632 100644
--- a/web/src/lib/components/album-page/album-cover.svelte
+++ b/web/src/lib/components/album-page/album-cover.svelte
@@ -5,13 +5,18 @@
   import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let preload = false;
-  let className = '';
-  export { className as class };
+  interface Props {
+    album: AlbumResponseDto;
+    preload?: boolean;
+    class?: string;
+  }
 
-  $: alt = album.albumName || $t('unnamed_album');
-  $: thumbnailUrl = album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null;
+  let { album, preload = false, class: className = '' }: Props = $props();
+
+  let alt = $derived(album.albumName || $t('unnamed_album'));
+  let thumbnailUrl = $derived(
+    album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
+  );
 </script>
 
 {#if thumbnailUrl}
diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte
index b3ad688a30..46b424f93a 100644
--- a/web/src/lib/components/album-page/album-description.svelte
+++ b/web/src/lib/components/album-page/album-description.svelte
@@ -4,9 +4,13 @@
   import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
   import { t } from 'svelte-i18n';
 
-  export let id: string;
-  export let description: string;
-  export let isOwned: boolean;
+  interface Props {
+    id: string;
+    description: string;
+    isOwned: boolean;
+  }
+
+  let { id, description = $bindable(), isOwned }: Props = $props();
 
   const handleUpdateDescription = async (newDescription: string) => {
     try {
diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte
index 3ec1842757..884de8c2a2 100644
--- a/web/src/lib/components/album-page/album-options.svelte
+++ b/web/src/lib/components/album-page/album-options.svelte
@@ -23,24 +23,38 @@
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 
-  export let album: AlbumResponseDto;
-  export let order: AssetOrder | undefined;
-  export let user: UserResponseDto; // Declare user as a prop
-  export let onChangeOrder: (order: AssetOrder) => void;
-  export let onClose: () => void;
-  export let onToggleEnabledActivity: () => void;
-  export let onShowSelectSharedUser: () => void;
-  export let onRemove: (userId: string) => void;
-  export let onRefreshAlbum: () => void;
+  interface Props {
+    album: AlbumResponseDto;
+    order: AssetOrder | undefined;
+    user: UserResponseDto;
+    onChangeOrder: (order: AssetOrder) => void;
+    onClose: () => void;
+    onToggleEnabledActivity: () => void;
+    onShowSelectSharedUser: () => void;
+    onRemove: (userId: string) => void;
+    onRefreshAlbum: () => void;
+  }
 
-  let selectedRemoveUser: UserResponseDto | null = null;
+  let {
+    album,
+    order,
+    user,
+    onChangeOrder,
+    onClose,
+    onToggleEnabledActivity,
+    onShowSelectSharedUser,
+    onRemove,
+    onRefreshAlbum,
+  }: Props = $props();
+
+  let selectedRemoveUser: UserResponseDto | null = $state(null);
 
   const options: Record<AssetOrder, RenderedOption> = {
     [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
     [AssetOrder.Desc]: { icon: mdiArrowDownThin, title: $t('newest_first') },
   };
 
-  $: selectedOption = order ? options[order] : options[AssetOrder.Desc];
+  let selectedOption = $derived(order ? options[order] : options[AssetOrder.Desc]);
 
   const handleToggle = async (returnedOption: RenderedOption): Promise<void> => {
     if (selectedOption === returnedOption) {
@@ -125,7 +139,7 @@
       <div class="py-2">
         <div class="text-gray text-sm mb-3">{$t('people').toUpperCase()}</div>
         <div class="p-2">
-          <button type="button" class="flex items-center gap-2" on:click={onShowSelectSharedUser}>
+          <button type="button" class="flex items-center gap-2" onclick={onShowSelectSharedUser}>
             <div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
               <div><Icon path={mdiPlus} size="25" /></div>
             </div>
diff --git a/web/src/lib/components/album-page/album-summary.svelte b/web/src/lib/components/album-page/album-summary.svelte
index 0277035d5c..f2cd23f616 100644
--- a/web/src/lib/components/album-page/album-summary.svelte
+++ b/web/src/lib/components/album-page/album-summary.svelte
@@ -4,10 +4,11 @@
   import type { AlbumResponseDto } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
+  interface Props {
+    album: AlbumResponseDto;
+  }
 
-  $: startDate = formatDate(album.startDate);
-  $: endDate = formatDate(album.endDate);
+  let { album }: Props = $props();
 
   const formatDate = (date?: string) => {
     return date ? new Date(date).toLocaleDateString($locale, dateFormats.album) : undefined;
@@ -24,6 +25,8 @@
 
     return '';
   };
+  let startDate = $derived(formatDate(album.startDate));
+  let endDate = $derived(formatDate(album.endDate));
 </script>
 
 <span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte
index 1e69ecf1a3..74786c1ea4 100644
--- a/web/src/lib/components/album-page/album-title.svelte
+++ b/web/src/lib/components/album-page/album-title.svelte
@@ -4,12 +4,20 @@
   import { shortcut } from '$lib/actions/shortcut';
   import { t } from 'svelte-i18n';
 
-  export let id: string;
-  export let albumName: string;
-  export let isOwned: boolean;
-  export let onUpdate: (albumName: string) => void;
+  interface Props {
+    id: string;
+    albumName: string;
+    isOwned: boolean;
+    onUpdate: (albumName: string) => void;
+  }
 
-  $: newAlbumName = albumName;
+  let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
+
+  let newAlbumName = $state(albumName);
+
+  $effect(() => {
+    newAlbumName = albumName;
+  });
 
   const handleUpdateName = async () => {
     if (newAlbumName === albumName) {
@@ -33,7 +41,7 @@
 
 <input
   use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
-  on:blur={handleUpdateName}
+  onblur={handleUpdateName}
   class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned
     ? 'hover:border-gray-400'
     : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte
index 87b3d8e2c5..1dc43c5b61 100644
--- a/web/src/lib/components/album-page/album-viewer.svelte
+++ b/web/src/lib/components/album-page/album-viewer.svelte
@@ -21,11 +21,15 @@
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
 
-  export let sharedLink: SharedLinkResponseDto;
-  export let user: UserResponseDto | undefined = undefined;
+  interface Props {
+    sharedLink: SharedLinkResponseDto;
+    user?: UserResponseDto | undefined;
+  }
+
+  let { sharedLink, user = undefined }: Props = $props();
 
   const album = sharedLink.album as AlbumResponseDto;
-  let innerWidth: number;
+  let innerWidth: number = $state(0);
 
   let { isViewing: showAssetViewer } = assetViewingStore;
 
@@ -70,15 +74,15 @@
     </AssetSelectControlBar>
   {:else}
     <ControlAppBar showBackButton={false}>
-      <svelte:fragment slot="leading">
+      {#snippet leading()}
         <ImmichLogoSmallLink width={innerWidth} />
-      </svelte:fragment>
+      {/snippet}
 
-      <svelte:fragment slot="trailing">
+      {#snippet trailing()}
         {#if sharedLink.allowUpload}
           <CircleIconButton
             title={$t('add_photos')}
-            on:click={() => openFileUploadDialog({ albumId: album.id })}
+            onclick={() => openFileUploadDialog({ albumId: album.id })}
             icon={mdiFileImagePlusOutline}
           />
         {/if}
@@ -86,13 +90,13 @@
         {#if album.assetCount > 0 && sharedLink.allowDownload}
           <CircleIconButton
             title={$t('download')}
-            on:click={() => downloadAlbum(album)}
+            onclick={() => downloadAlbum(album)}
             icon={mdiFolderDownloadOutline}
           />
         {/if}
 
         <ThemeButton />
-      </svelte:fragment>
+      {/snippet}
     </ControlAppBar>
   {/if}
 </header>
diff --git a/web/src/lib/components/album-page/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte
index 34563eddd3..85a7260f40 100644
--- a/web/src/lib/components/album-page/albums-controls.svelte
+++ b/web/src/lib/components/album-page/albums-controls.svelte
@@ -38,8 +38,12 @@
   import { fly } from 'svelte/transition';
   import { t } from 'svelte-i18n';
 
-  export let albumGroups: string[];
-  export let searchQuery: string;
+  interface Props {
+    albumGroups: string[];
+    searchQuery: string;
+  }
+
+  let { albumGroups, searchQuery = $bindable() }: Props = $props();
 
   const flipOrdering = (ordering: string) => {
     return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
@@ -73,62 +77,38 @@
       $albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
   };
 
-  let selectedGroupOption: AlbumGroupOptionMetadata;
-  let groupIcon: string;
-
-  $: selectedFilterOption = albumFilterNames[findFilterOption($albumViewSettings.filter)];
-
-  $: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy);
-
-  $: {
-    selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy);
-    if (selectedGroupOption.isDisabled()) {
-      selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None);
+  let groupIcon = $derived.by(() => {
+    if (selectedGroupOption?.id === AlbumGroupBy.None) {
+      return mdiFolderRemoveOutline;
     }
-  }
+    return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
+  });
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: {
-    if (selectedGroupOption.id === AlbumGroupBy.None) {
-      groupIcon = mdiFolderRemoveOutline;
-    } else {
-      groupIcon =
-        $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
-    }
-  }
+  let albumFilterNames: Record<AlbumFilter, string> = $derived({
+    [AlbumFilter.All]: $t('all'),
+    [AlbumFilter.Owned]: $t('owned'),
+    [AlbumFilter.Shared]: $t('shared'),
+  });
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
+  let selectedFilterOption = $derived(albumFilterNames[findFilterOption($albumViewSettings.filter)]);
+  let selectedSortOption = $derived(findSortOptionMetadata($albumViewSettings.sortBy));
+  let selectedGroupOption = $derived(findGroupOptionMetadata($albumViewSettings.groupBy));
+  let sortIcon = $derived($albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin);
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: albumFilterNames = ((): Record<AlbumFilter, string> => {
-    return {
-      [AlbumFilter.All]: $t('all'),
-      [AlbumFilter.Owned]: $t('owned'),
-      [AlbumFilter.Shared]: $t('shared'),
-    };
-  })();
+  let albumSortByNames: Record<AlbumSortBy, string> = $derived({
+    [AlbumSortBy.Title]: $t('sort_title'),
+    [AlbumSortBy.ItemCount]: $t('sort_items'),
+    [AlbumSortBy.DateModified]: $t('sort_modified'),
+    [AlbumSortBy.DateCreated]: $t('sort_created'),
+    [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
+    [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
+  });
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: albumSortByNames = ((): Record<AlbumSortBy, string> => {
-    return {
-      [AlbumSortBy.Title]: $t('sort_title'),
-      [AlbumSortBy.ItemCount]: $t('sort_items'),
-      [AlbumSortBy.DateModified]: $t('sort_modified'),
-      [AlbumSortBy.DateCreated]: $t('sort_created'),
-      [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
-      [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
-    };
-  })();
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: albumGroupByNames = ((): Record<AlbumGroupBy, string> => {
-    return {
-      [AlbumGroupBy.None]: $t('group_no'),
-      [AlbumGroupBy.Owner]: $t('group_owner'),
-      [AlbumGroupBy.Year]: $t('group_year'),
-    };
-  })();
+  let albumGroupByNames: Record<AlbumGroupBy, string> = $derived({
+    [AlbumGroupBy.None]: $t('group_no'),
+    [AlbumGroupBy.Owner]: $t('group_owner'),
+    [AlbumGroupBy.Year]: $t('group_year'),
+  });
 </script>
 
 <!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
@@ -147,7 +127,7 @@
 </div>
 
 <!-- Create Album -->
-<LinkButton on:click={() => createAlbumAndRedirect()}>
+<LinkButton onclick={() => createAlbumAndRedirect()}>
   <div class="flex place-items-center gap-2 text-sm">
     <Icon path={mdiPlusBoxOutline} size="18" />
     <p class="hidden md:block">{$t('create_album')}</p>
@@ -184,7 +164,7 @@
     <!-- Expand Album Groups -->
     <div class="hidden xl:flex gap-0">
       <div class="block">
-        <LinkButton title={$t('expand_all')} on:click={() => expandAllAlbumGroups()}>
+        <LinkButton title={$t('expand_all')} onclick={() => expandAllAlbumGroups()}>
           <div class="flex place-items-center gap-2 text-sm">
             <Icon path={mdiUnfoldMoreHorizontal} size="18" />
           </div>
@@ -193,7 +173,7 @@
 
       <!-- Collapse Album Groups -->
       <div class="block">
-        <LinkButton title={$t('collapse_all')} on:click={() => collapseAllAlbumGroups(albumGroups)}>
+        <LinkButton title={$t('collapse_all')} onclick={() => collapseAllAlbumGroups(albumGroups)}>
           <div class="flex place-items-center gap-2 text-sm">
             <Icon path={mdiUnfoldLessHorizontal} size="18" />
           </div>
@@ -204,7 +184,7 @@
 {/if}
 
 <!-- Cover/List Display Toggle -->
-<LinkButton on:click={() => handleChangeListMode()}>
+<LinkButton onclick={() => handleChangeListMode()}>
   <div class="flex place-items-center gap-2 text-sm">
     {#if $albumViewSettings.view === AlbumViewMode.List}
       <Icon path={mdiViewGridOutline} size="18" />
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte
index 3858dd23b7..178190dc34 100644
--- a/web/src/lib/components/album-page/albums-list.svelte
+++ b/web/src/lib/components/album-page/albums-list.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { onMount } from 'svelte';
+  import { onMount, type Snippet } from 'svelte';
   import { groupBy } from 'lodash-es';
   import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
   import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
@@ -38,14 +38,29 @@
   import { goto } from '$app/navigation';
   import { AppRoute } from '$lib/constants';
   import { t } from 'svelte-i18n';
+  import { run } from 'svelte/legacy';
 
-  export let ownedAlbums: AlbumResponseDto[] = [];
-  export let sharedAlbums: AlbumResponseDto[] = [];
-  export let searchQuery: string = '';
-  export let userSettings: AlbumViewSettings;
-  export let allowEdit = false;
-  export let showOwner = false;
-  export let albumGroupIds: string[] = [];
+  interface Props {
+    ownedAlbums?: AlbumResponseDto[];
+    sharedAlbums?: AlbumResponseDto[];
+    searchQuery?: string;
+    userSettings: AlbumViewSettings;
+    allowEdit?: boolean;
+    showOwner?: boolean;
+    albumGroupIds?: string[];
+    empty?: Snippet;
+  }
+
+  let {
+    ownedAlbums = $bindable([]),
+    sharedAlbums = $bindable([]),
+    searchQuery = '',
+    userSettings,
+    allowEdit = false,
+    showOwner = false,
+    albumGroupIds = $bindable([]),
+    empty,
+  }: Props = $props();
 
   interface AlbumGroupOption {
     [option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[];
@@ -118,25 +133,24 @@
     },
   };
 
-  let albums: AlbumResponseDto[] = [];
-  let filteredAlbums: AlbumResponseDto[] = [];
-  let groupedAlbums: AlbumGroup[] = [];
+  let albums: AlbumResponseDto[] = $state([]);
+  let filteredAlbums: AlbumResponseDto[] = $state([]);
+  let groupedAlbums: AlbumGroup[] = $state([]);
 
-  let albumGroupOption: string = AlbumGroupBy.None;
+  let albumGroupOption: string = $state(AlbumGroupBy.None);
 
-  let showShareByURLModal = false;
+  let showShareByURLModal = $state(false);
 
-  let albumToEdit: AlbumResponseDto | null = null;
-  let albumToShare: AlbumResponseDto | null = null;
+  let albumToEdit: AlbumResponseDto | null = $state(null);
+  let albumToShare: AlbumResponseDto | null = $state(null);
   let albumToDelete: AlbumResponseDto | null = null;
 
-  let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
-  let contextMenuTargetAlbum: AlbumResponseDto | null = null;
-  let isOpen = false;
+  let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
+  let contextMenuTargetAlbum: AlbumResponseDto | undefined = $state();
+  let isOpen = $state(false);
 
   // Step 1: Filter between Owned and Shared albums, or both.
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: {
+  run(() => {
     switch (userSettings.filter) {
       case AlbumFilter.Owned: {
         albums = ownedAlbums;
@@ -152,10 +166,10 @@
         albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
       }
     }
-  }
+  });
 
   // Step 2: Filter using the given search query.
-  $: {
+  run(() => {
     if (searchQuery) {
       const searchAlbumNormalized = normalizeSearchString(searchQuery);
 
@@ -165,17 +179,17 @@
     } else {
       filteredAlbums = albums;
     }
-  }
+  });
 
   // Step 3: Group albums.
-  $: {
+  run(() => {
     albumGroupOption = getSelectedAlbumGroupOption(userSettings);
     const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
     groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
-  }
+  });
 
   // Step 4: Sort albums amongst each group.
-  $: {
+  run(() => {
     groupedAlbums = groupedAlbums.map((group) => ({
       id: group.id,
       name: group.name,
@@ -183,9 +197,11 @@
     }));
 
     albumGroupIds = groupedAlbums.map(({ id }) => id);
-  }
+  });
 
-  $: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
+  let showFullContextMenu = $derived(
+    allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id,
+  );
 
   onMount(async () => {
     if (allowEdit) {
@@ -320,6 +336,10 @@
   };
 
   const openShareModal = () => {
+    if (!contextMenuTargetAlbum) {
+      return;
+    }
+
     albumToShare = contextMenuTargetAlbum;
     closeAlbumContextMenu();
   };
@@ -359,7 +379,7 @@
   {/if}
 {:else}
   <!-- Empty Message -->
-  <slot name="empty" />
+  {@render empty?.()}
 {/if}
 
 <!-- Context Menu -->
diff --git a/web/src/lib/components/album-page/albums-table-header.svelte b/web/src/lib/components/album-page/albums-table-header.svelte
index 84e32b82f5..4c018f7454 100644
--- a/web/src/lib/components/album-page/albums-table-header.svelte
+++ b/web/src/lib/components/album-page/albums-table-header.svelte
@@ -3,7 +3,11 @@
   import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils';
   import { t } from 'svelte-i18n';
 
-  export let option: AlbumSortOptionMetadata;
+  interface Props {
+    option: AlbumSortOptionMetadata;
+  }
+
+  let { option }: Props = $props();
 
   const handleSort = () => {
     if ($albumViewSettings.sortBy === option.id) {
@@ -13,24 +17,22 @@
       $albumViewSettings.sortOrder = option.defaultOrder;
     }
   };
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: albumSortByNames = ((): Record<AlbumSortBy, string> => {
-    return {
-      [AlbumSortBy.Title]: $t('sort_title'),
-      [AlbumSortBy.ItemCount]: $t('sort_items'),
-      [AlbumSortBy.DateModified]: $t('sort_modified'),
-      [AlbumSortBy.DateCreated]: $t('sort_created'),
-      [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
-      [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
-    };
-  })();
+
+  let albumSortByNames: Record<AlbumSortBy, string> = $derived({
+    [AlbumSortBy.Title]: $t('sort_title'),
+    [AlbumSortBy.ItemCount]: $t('sort_items'),
+    [AlbumSortBy.DateModified]: $t('sort_modified'),
+    [AlbumSortBy.DateCreated]: $t('sort_created'),
+    [AlbumSortBy.MostRecentPhoto]: $t('sort_recent'),
+    [AlbumSortBy.OldestPhoto]: $t('sort_oldest'),
+  });
 </script>
 
 <th class="text-sm font-medium {option.columnStyle}">
   <button
     type="button"
     class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
-    on:click={handleSort}
+    onclick={handleSort}
   >
     {#if $albumViewSettings.sortBy === option.id}
       {#if $albumViewSettings.sortOrder === SortOrder.Desc}
diff --git a/web/src/lib/components/album-page/albums-table-row.svelte b/web/src/lib/components/album-page/albums-table-row.svelte
index 3e9027de3d..c900930f8a 100644
--- a/web/src/lib/components/album-page/albums-table-row.svelte
+++ b/web/src/lib/components/album-page/albums-table-row.svelte
@@ -9,9 +9,12 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
-    undefined;
+  interface Props {
+    album: AlbumResponseDto;
+    onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
+  }
+
+  let { album, onShowContextMenu = undefined }: Props = $props();
 
   const showContextMenu = (position: ContextMenuPosition) => {
     onShowContextMenu?.(position, album);
@@ -20,12 +23,17 @@
   const dateLocaleString = (dateString: string) => {
     return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
   };
+
+  const oncontextmenu = (event: MouseEvent) => {
+    event.preventDefault();
+    showContextMenu({ x: event.x, y: event.y });
+  };
 </script>
 
 <tr
   class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
-  on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
-  on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y })}
+  onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
+  {oncontextmenu}
 >
   <td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
     {album.albumName}
diff --git a/web/src/lib/components/album-page/albums-table.svelte b/web/src/lib/components/album-page/albums-table.svelte
index d9ffe8595b..bd7c7fd7f5 100644
--- a/web/src/lib/components/album-page/albums-table.svelte
+++ b/web/src/lib/components/album-page/albums-table.svelte
@@ -15,10 +15,13 @@
   } from '$lib/utils/album-utils';
   import { t } from 'svelte-i18n';
 
-  export let groupedAlbums: AlbumGroup[];
-  export let albumGroupOption: string = AlbumGroupBy.None;
-  export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
-    undefined;
+  interface Props {
+    groupedAlbums: AlbumGroup[];
+    albumGroupOption?: string;
+    onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
+  }
+
+  let { groupedAlbums, albumGroupOption = AlbumGroupBy.None, onShowContextMenu }: Props = $props();
 </script>
 
 <table class="mt-2 w-full text-left">
@@ -46,7 +49,7 @@
       >
         <tr
           class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3"
-          on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
+          onclick={() => toggleAlbumGroupCollapsing(albumGroup.id)}
           aria-expanded={!isCollapsed}
         >
           <td class="text-md text-left -mb-1">
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte
index ee98d5a821..778943af3a 100644
--- a/web/src/lib/components/album-page/share-info-modal.svelte
+++ b/web/src/lib/components/album-page/share-info-modal.svelte
@@ -18,15 +18,19 @@
   import { t } from 'svelte-i18n';
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 
-  export let album: AlbumResponseDto;
-  export let onClose: () => void;
-  export let onRemove: (userId: string) => void;
-  export let onRefreshAlbum: () => void;
+  interface Props {
+    album: AlbumResponseDto;
+    onClose: () => void;
+    onRemove: (userId: string) => void;
+    onRefreshAlbum: () => void;
+  }
 
-  let currentUser: UserResponseDto;
-  let selectedRemoveUser: UserResponseDto | null = null;
+  let { album, onClose, onRemove, onRefreshAlbum }: Props = $props();
 
-  $: isOwned = currentUser?.id == album.ownerId;
+  let currentUser: UserResponseDto | undefined = $state();
+  let selectedRemoveUser: UserResponseDto | null = $state(null);
+
+  let isOwned = $derived(currentUser?.id == album.ownerId);
 
   onMount(async () => {
     try {
@@ -123,7 +127,7 @@
             {:else if user.id == currentUser?.id}
               <button
                 type="button"
-                on:click={() => (selectedRemoveUser = user)}
+                onclick={() => (selectedRemoveUser = user)}
                 class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
                 >{$t('leave')}</button
               >
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte
index ee0a5c7410..fca244ac75 100644
--- a/web/src/lib/components/album-page/user-selection-modal.svelte
+++ b/web/src/lib/components/album-page/user-selection-modal.svelte
@@ -18,13 +18,17 @@
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let onClose: () => void;
-  export let onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
-  export let onShare: () => void;
+  interface Props {
+    album: AlbumResponseDto;
+    onClose: () => void;
+    onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
+    onShare: () => void;
+  }
 
-  let users: UserResponseDto[] = [];
-  let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
+  let { album, onClose, onSelect, onShare }: Props = $props();
+
+  let users: UserResponseDto[] = $state([]);
+  let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = $state({});
 
   const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
     { title: $t('role_editor'), value: AlbumUserRole.Editor, icon: mdiPencil },
@@ -32,7 +36,7 @@
     { title: $t('remove_user'), value: 'none' },
   ];
 
-  let sharedLinks: SharedLinkResponseDto[] = [];
+  let sharedLinks: SharedLinkResponseDto[] = $state([]);
   onMount(async () => {
     await getSharedLinks();
     const data = await searchUsers();
@@ -121,11 +125,7 @@
         {#each users as user}
           {#if !Object.keys(selectedUsers).includes(user.id)}
             <div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
-              <button
-                type="button"
-                on:click={() => handleToggle(user)}
-                class="flex w-full place-items-center gap-4 p-4"
-              >
+              <button type="button" onclick={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
                 <UserAvatar {user} size="md" />
                 <div class="text-left flex-grow">
                   <p class="text-immich-fg dark:text-immich-dark-fg">
@@ -150,7 +150,7 @@
         fullwidth
         rounded="full"
         disabled={Object.keys(selectedUsers).length === 0}
-        on:click={() =>
+        onclick={() =>
           onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
         >{$t('add')}</Button
       >
@@ -163,7 +163,7 @@
     <button
       type="button"
       class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
-      on:click={onShare}
+      onclick={onShare}
     >
       <Icon path={mdiLink} size={24} />
       <p class="text-sm">{$t('create_link')}</p>
diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte
index cd4e8091af..ab0da059d0 100644
--- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte
@@ -9,11 +9,15 @@
   import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let onAction: OnAction;
-  export let shared = false;
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+    shared?: boolean;
+  }
 
-  let showSelectionModal = false;
+  let { asset, onAction, shared = false }: Props = $props();
+
+  let showSelectionModal = $state(false);
 
   const handleAddToNewAlbum = async (albumName: string) => {
     showSelectionModal = false;
diff --git a/web/src/lib/components/asset-viewer/actions/archive-action.svelte b/web/src/lib/components/asset-viewer/actions/archive-action.svelte
index 3e2c453f39..6337b27892 100644
--- a/web/src/lib/components/asset-viewer/actions/archive-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/archive-action.svelte
@@ -8,8 +8,12 @@
   import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let onAction: OnAction;
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+  }
+
+  let { asset, onAction }: Props = $props();
 
   const onArchive = async () => {
     const updatedAsset = await toggleArchive(asset);
diff --git a/web/src/lib/components/asset-viewer/actions/close-action.svelte b/web/src/lib/components/asset-viewer/actions/close-action.svelte
index 647ad61e4f..26cb81edd8 100644
--- a/web/src/lib/components/asset-viewer/actions/close-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/close-action.svelte
@@ -4,9 +4,13 @@
   import { mdiArrowLeft } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let onClose: () => void;
+  interface Props {
+    onClose: () => void;
+  }
+
+  let { onClose }: Props = $props();
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
 
-<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={onClose} />
+<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} onclick={onClose} />
diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte
index ae5f83c456..c0f163634a 100644
--- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte
@@ -16,10 +16,14 @@
   import { t } from 'svelte-i18n';
   import type { OnAction } from './action';
 
-  export let asset: AssetResponseDto;
-  export let onAction: OnAction;
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+  }
 
-  let showConfirmModal = false;
+  let { asset, onAction }: Props = $props();
+
+  let showConfirmModal = $state(false);
 
   const trashOrDelete = async (force = false) => {
     if (force || !$featureFlags.trash) {
@@ -77,7 +81,7 @@
   color="opaque"
   icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
   title={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
-  on:click={() => trashOrDelete(asset.isTrashed)}
+  onclick={() => trashOrDelete(asset.isTrashed)}
 />
 
 {#if showConfirmModal}
diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte
index 88c0eeadf2..d7f4f56352 100644
--- a/web/src/lib/components/asset-viewer/actions/download-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/download-action.svelte
@@ -7,8 +7,12 @@
   import { mdiFolderDownloadOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let menuItem = false;
+  interface Props {
+    asset: AssetResponseDto;
+    menuItem?: boolean;
+  }
+
+  let { asset, menuItem = false }: Props = $props();
 
   const onDownloadFile = () => downloadFile(asset);
 </script>
@@ -16,7 +20,7 @@
 <svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
 
 {#if !menuItem}
-  <CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} on:click={onDownloadFile} />
+  <CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} onclick={onDownloadFile} />
 {:else}
   <MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} />
 {/if}
diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte
index 488ed7ecb2..0cc3188d51 100644
--- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte
@@ -12,8 +12,12 @@
   import { t } from 'svelte-i18n';
   import type { OnAction } from './action';
 
-  export let asset: AssetResponseDto;
-  export let onAction: OnAction;
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+  }
+
+  let { asset, onAction }: Props = $props();
 
   const toggleFavorite = async () => {
     try {
@@ -24,7 +28,8 @@
         },
       });
 
-      asset.isFavorite = data.isFavorite;
+      asset = { ...asset, isFavorite: data.isFavorite };
+
       onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
 
       notificationController.show({
@@ -43,5 +48,5 @@
   color="opaque"
   icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
   title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
-  on:click={toggleFavorite}
+  onclick={toggleFavorite}
 />
diff --git a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte
index fd519a05d4..f629a42db7 100644
--- a/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/motion-photo-action.svelte
@@ -3,13 +3,17 @@
   import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let isPlaying: boolean;
-  export let onClick: (shouldPlay: boolean) => void;
+  interface Props {
+    isPlaying: boolean;
+    onClick: (shouldPlay: boolean) => void;
+  }
+
+  let { isPlaying, onClick }: Props = $props();
 </script>
 
 <CircleIconButton
   color="opaque"
   icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
   title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
-  on:click={() => onClick(!isPlaying)}
+  onclick={() => onClick(!isPlaying)}
 />
diff --git a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte
index cc074f3b6c..355f816a6b 100644
--- a/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/next-asset-action.svelte
@@ -5,7 +5,11 @@
   import { t } from 'svelte-i18n';
   import NavigationArea from '../navigation-area.svelte';
 
-  export let onNextAsset: () => void;
+  interface Props {
+    onNextAsset: () => void;
+  }
+
+  let { onNextAsset }: Props = $props();
 </script>
 
 <svelte:window
diff --git a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte
index 9f8c638e12..1770bc673a 100644
--- a/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/previous-asset-action.svelte
@@ -5,7 +5,11 @@
   import { t } from 'svelte-i18n';
   import NavigationArea from '../navigation-area.svelte';
 
-  export let onPreviousAsset: () => void;
+  interface Props {
+    onPreviousAsset: () => void;
+  }
+
+  let { onPreviousAsset }: Props = $props();
 </script>
 
 <svelte:window
diff --git a/web/src/lib/components/asset-viewer/actions/restore-action.svelte b/web/src/lib/components/asset-viewer/actions/restore-action.svelte
index c000dad9a1..abcae5c4c9 100644
--- a/web/src/lib/components/asset-viewer/actions/restore-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/restore-action.svelte
@@ -11,8 +11,12 @@
   import { t } from 'svelte-i18n';
   import type { OnAction } from './action';
 
-  export let asset: AssetResponseDto;
-  export let onAction: OnAction;
+  interface Props {
+    asset: AssetResponseDto;
+    onAction: OnAction;
+  }
+
+  let { asset = $bindable(), onAction }: Props = $props();
 
   const handleRestoreAsset = async () => {
     try {
diff --git a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte
index f20c4872bc..c015c224ff 100644
--- a/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/set-album-cover-action.svelte
@@ -9,8 +9,12 @@
   import { mdiImageOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let album: AlbumResponseDto;
+  interface Props {
+    asset: AssetResponseDto;
+    album: AlbumResponseDto;
+  }
+
+  let { asset, album }: Props = $props();
 
   const handleUpdateThumbnail = async () => {
     try {
diff --git a/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte b/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte
index 23c147815c..a35ff11c48 100644
--- a/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/set-profile-picture-action.svelte
@@ -6,9 +6,13 @@
   import { mdiAccountCircleOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
+  interface Props {
+    asset: AssetResponseDto;
+  }
 
-  let showProfileImageCrop = false;
+  let { asset }: Props = $props();
+
+  let showProfileImageCrop = $state(false);
 </script>
 
 <MenuOption
diff --git a/web/src/lib/components/asset-viewer/actions/share-action.svelte b/web/src/lib/components/asset-viewer/actions/share-action.svelte
index f0b2177128..6fd5aa456e 100644
--- a/web/src/lib/components/asset-viewer/actions/share-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/share-action.svelte
@@ -6,17 +6,16 @@
   import { mdiShareVariantOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
+  interface Props {
+    asset: AssetResponseDto;
+  }
 
-  let showModal = false;
+  let { asset }: Props = $props();
+
+  let showModal = $state(false);
 </script>
 
-<CircleIconButton
-  color="opaque"
-  icon={mdiShareVariantOutline}
-  on:click={() => (showModal = true)}
-  title={$t('share')}
-/>
+<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} />
 
 {#if showModal}
   <Portal target="body">
diff --git a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte
index 66e5d0e10f..5613114cad 100644
--- a/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/show-detail-action.svelte
@@ -4,9 +4,13 @@
   import { mdiInformationOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let onShowDetail: () => void;
+  interface Props {
+    onShowDetail: () => void;
+  }
+
+  let { onShowDetail }: Props = $props();
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
 
-<CircleIconButton color="opaque" icon={mdiInformationOutline} on:click={onShowDetail} title={$t('info')} />
+<CircleIconButton color="opaque" icon={mdiInformationOutline} onclick={onShowDetail} title={$t('info')} />
diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte
index bd18e0e8bf..f2a50cce13 100644
--- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte
@@ -7,8 +7,12 @@
   import { t } from 'svelte-i18n';
   import type { OnAction } from './action';
 
-  export let stack: StackResponseDto;
-  export let onAction: OnAction;
+  interface Props {
+    stack: StackResponseDto;
+    onAction: OnAction;
+  }
+
+  let { stack, onAction }: Props = $props();
 
   const handleUnstack = async () => {
     const unstackedAssets = await deleteStack([stack.id]);
diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte
index fe6ee79363..494c6fcbf7 100644
--- a/web/src/lib/components/asset-viewer/activity-status.svelte
+++ b/web/src/lib/components/asset-viewer/activity-status.svelte
@@ -4,20 +4,24 @@
   import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
   import Icon from '../elements/icon.svelte';
 
-  export let isLiked: ActivityResponseDto | null;
-  export let numberOfComments: number | undefined;
-  export let disabled: boolean;
-  export let onOpenActivityTab: () => void;
-  export let onFavorite: () => void;
+  interface Props {
+    isLiked: ActivityResponseDto | null;
+    numberOfComments: number | undefined;
+    disabled: boolean;
+    onOpenActivityTab: () => void;
+    onFavorite: () => void;
+  }
+
+  let { isLiked, numberOfComments, disabled, onOpenActivityTab, onFavorite }: Props = $props();
 </script>
 
 <div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60">
-  <button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={onFavorite} {disabled}>
+  <button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
     <div class="items-center justify-center">
       <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
     </div>
   </button>
-  <button type="button" on:click={onOpenActivityTab}>
+  <button type="button" onclick={onOpenActivityTab}>
     <div class="flex gap-2 items-center justify-center">
       <Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
       {#if numberOfComments}
diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte
index 4f4fdb2649..34940aee56 100644
--- a/web/src/lib/components/asset-viewer/activity-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte
@@ -47,40 +47,45 @@
     return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
   };
 
-  export let reactions: ActivityResponseDto[];
-  export let user: UserResponseDto;
-  export let assetId: string | undefined = undefined;
-  export let albumId: string;
-  export let assetType: AssetTypeEnum | undefined = undefined;
-  export let albumOwnerId: string;
-  export let disabled: boolean;
-  export let isLiked: ActivityResponseDto | null;
-  export let onDeleteComment: () => void;
-  export let onDeleteLike: () => void;
-  export let onAddComment: () => void;
-  export let onClose: () => void;
-
-  let textArea: HTMLTextAreaElement;
-  let innerHeight: number;
-  let activityHeight: number;
-  let chatHeight: number;
-  let divHeight: number;
-  let previousAssetId: string | undefined = assetId;
-  let message = '';
-  let isSendingMessage = false;
-
-  $: {
-    if (innerHeight && activityHeight) {
-      divHeight = innerHeight - activityHeight;
-    }
+  interface Props {
+    reactions: ActivityResponseDto[];
+    user: UserResponseDto;
+    assetId?: string | undefined;
+    albumId: string;
+    assetType?: AssetTypeEnum | undefined;
+    albumOwnerId: string;
+    disabled: boolean;
+    isLiked: ActivityResponseDto | null;
+    onDeleteComment: () => void;
+    onDeleteLike: () => void;
+    onAddComment: () => void;
+    onClose: () => void;
   }
 
-  $: {
-    if (assetId && previousAssetId != assetId) {
-      handlePromiseError(getReactions());
-      previousAssetId = assetId;
-    }
-  }
+  let {
+    reactions = $bindable(),
+    user,
+    assetId = undefined,
+    albumId,
+    assetType = undefined,
+    albumOwnerId,
+    disabled,
+    isLiked,
+    onDeleteComment,
+    onDeleteLike,
+    onAddComment,
+    onClose,
+  }: Props = $props();
+
+  let textArea: HTMLTextAreaElement | undefined = $state();
+  let innerHeight: number = $state(0);
+  let activityHeight: number = $state(0);
+  let chatHeight: number = $state(0);
+  let divHeight: number = $state(0);
+  let previousAssetId: string | undefined = $state(assetId);
+  let message = $state('');
+  let isSendingMessage = $state(false);
+
   onMount(async () => {
     await getReactions();
   });
@@ -136,7 +141,11 @@
         activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
       });
       reactions.push(data);
-      textArea.style.height = '18px';
+
+      if (textArea) {
+        textArea.style.height = '18px';
+      }
+
       message = '';
       onAddComment();
       // Re-render the activity feed
@@ -148,6 +157,22 @@
     }
     isSendingMessage = false;
   };
+  $effect(() => {
+    if (innerHeight && activityHeight) {
+      divHeight = innerHeight - activityHeight;
+    }
+  });
+  $effect(() => {
+    if (assetId && previousAssetId != assetId) {
+      handlePromiseError(getReactions());
+      previousAssetId = assetId;
+    }
+  });
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleSendComment();
+  };
 </script>
 
 <div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
@@ -157,7 +182,7 @@
       bind:clientHeight={activityHeight}
     >
       <div class="flex place-items-center gap-2">
-        <CircleIconButton on:click={onClose} icon={mdiClose} title={$t('close')} />
+        <CircleIconButton onclick={onClose} icon={mdiClose} title={$t('close')} />
 
         <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
       </div>
@@ -277,7 +302,7 @@
         <div>
           <UserAvatar {user} size="md" showTitle={false} />
         </div>
-        <form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
+        <form class="flex w-full max-h-56 gap-1" {onsubmit}>
           <div class="flex w-full items-center gap-4">
             <textarea
               {disabled}
@@ -285,7 +310,7 @@
               bind:value={message}
               use:autoGrowHeight={'5px'}
               placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
-              on:input={() => autoGrowHeight(textArea, '5px')}
+              oninput={() => autoGrowHeight(textArea, '5px')}
               use:shortcut={{
                 shortcut: { key: 'Enter' },
                 onShortcut: () => handleSendComment(),
@@ -308,7 +333,7 @@
                 size="15"
                 icon={mdiSend}
                 class="dark:text-immich-dark-gray"
-                on:click={() => handleSendComment()}
+                onclick={() => handleSendComment()}
               />
             </div>
           {/if}
diff --git a/web/src/lib/components/asset-viewer/album-list-item-details.svelte b/web/src/lib/components/asset-viewer/album-list-item-details.svelte
index ecc38b7c24..08dd105ca1 100644
--- a/web/src/lib/components/asset-viewer/album-list-item-details.svelte
+++ b/web/src/lib/components/asset-viewer/album-list-item-details.svelte
@@ -2,7 +2,11 @@
   import type { AlbumResponseDto } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
+  interface Props {
+    album: AlbumResponseDto;
+  }
+
+  let { album }: Props = $props();
 </script>
 
 <span>{$t('items_count', { values: { count: album.assetCount } })}</span>
diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte
index 8e9f6f6b5a..43352a4904 100644
--- a/web/src/lib/components/asset-viewer/album-list-item.svelte
+++ b/web/src/lib/components/asset-viewer/album-list-item.svelte
@@ -4,15 +4,19 @@
   import { normalizeSearchString } from '$lib/utils/string-utils.js';
   import AlbumListItemDetails from './album-list-item-details.svelte';
 
-  export let album: AlbumResponseDto;
-  export let searchQuery = '';
-  export let onAlbumClick: () => void;
+  interface Props {
+    album: AlbumResponseDto;
+    searchQuery?: string;
+    onAlbumClick: () => void;
+  }
 
-  let albumNameArray: string[] = ['', '', ''];
+  let { album, searchQuery = '', onAlbumClick }: Props = $props();
+
+  let albumNameArray: string[] = $state(['', '', '']);
 
   // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
   // It is used to highlight the search query in the album name
-  $: {
+  $effect(() => {
     let { albumName } = album;
     let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
     let findLength = searchQuery.length;
@@ -21,12 +25,12 @@
       albumName.slice(findIndex, findIndex + findLength),
       albumName.slice(findIndex + findLength),
     ];
-  }
+  });
 </script>
 
 <button
   type="button"
-  on:click={onAlbumClick}
+  onclick={onAlbumClick}
   class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
 >
   <span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index d142c43f20..7972ff6c72 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -44,25 +44,44 @@
   } from '@mdi/js';
   import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let asset: AssetResponseDto;
-  export let album: AlbumResponseDto | null = null;
-  export let stack: StackResponseDto | null = null;
-  export let showDetailButton: boolean;
-  export let showSlideshow = false;
-  export let onZoomImage: () => void;
-  export let onCopyImage: () => void;
-  export let onAction: OnAction;
-  export let onRunJob: (name: AssetJobName) => void;
-  export let onPlaySlideshow: () => void;
-  export let onShowDetail: () => void;
-  // export let showEditorHandler: () => void;
-  export let onClose: () => void;
+  interface Props {
+    asset: AssetResponseDto;
+    album?: AlbumResponseDto | null;
+    stack?: StackResponseDto | null;
+    showDetailButton: boolean;
+    showSlideshow?: boolean;
+    onZoomImage: () => void;
+    onCopyImage?: () => Promise<void>;
+    onAction: OnAction;
+    onRunJob: (name: AssetJobName) => void;
+    onPlaySlideshow: () => void;
+    onShowDetail: () => void;
+    // export let showEditorHandler: () => void;
+    onClose: () => void;
+    motionPhoto?: Snippet;
+  }
+
+  let {
+    asset,
+    album = null,
+    stack = null,
+    showDetailButton,
+    showSlideshow = false,
+    onZoomImage,
+    onCopyImage,
+    onAction,
+    onRunJob,
+    onPlaySlideshow,
+    onShowDetail,
+    onClose,
+    motionPhoto,
+  }: Props = $props();
 
   const sharedLink = getSharedLink();
-  $: isOwner = $user && asset.ownerId === $user?.id;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
+  let isOwner = $derived($user && asset.ownerId === $user?.id);
+  let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
   // $: showEditorButton =
   //   isOwner &&
   //   asset.type === AssetTypeEnum.Image &&
@@ -88,10 +107,10 @@
       <ShareAction {asset} />
     {/if}
     {#if asset.isOffline}
-      <CircleIconButton color="alert" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} />
+      <CircleIconButton color="alert" icon={mdiAlertOutline} onclick={onShowDetail} title={$t('asset_offline')} />
     {/if}
     {#if asset.livePhotoVideoId}
-      <slot name="motion-photo" />
+      {@render motionPhoto?.()}
     {/if}
     {#if asset.type === AssetTypeEnum.Image}
       <CircleIconButton
@@ -99,11 +118,11 @@
         hideMobile={true}
         icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
         title={$t('zoom_image')}
-        on:click={onZoomImage}
+        onclick={onZoomImage}
       />
     {/if}
     {#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
-      <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
+      <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} onclick={() => onCopyImage?.()} />
     {/if}
 
     {#if !isOwner && showDownloadButton}
@@ -122,7 +141,7 @@
         color="opaque"
         hideMobile={true}
         icon={mdiImageEditOutline}
-        on:click={showEditorHandler}
+        onclick={showEditorHandler}
         title={$t('editor')}
       />
     {/if} -->
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 4caeab6dd4..988707cfa7 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -48,18 +48,37 @@
   import SlideshowBar from './slideshow-bar.svelte';
   import VideoViewer from './video-wrapper-viewer.svelte';
 
-  export let assetStore: AssetStore | null = null;
-  export let asset: AssetResponseDto;
-  export let preloadAssets: AssetResponseDto[] = [];
-  export let showNavigation = true;
-  export let withStacked = false;
-  export let isShared = false;
-  export let album: AlbumResponseDto | null = null;
-  export let onAction: OnAction | undefined = undefined;
-  export let reactions: ActivityResponseDto[] = [];
-  export let onClose: (dto: { asset: AssetResponseDto }) => void;
-  export let onNext: () => void;
-  export let onPrevious: () => void;
+  interface Props {
+    assetStore?: AssetStore | null;
+    asset: AssetResponseDto;
+    preloadAssets?: AssetResponseDto[];
+    showNavigation?: boolean;
+    withStacked?: boolean;
+    isShared?: boolean;
+    album?: AlbumResponseDto | null;
+    onAction?: OnAction | undefined;
+    reactions?: ActivityResponseDto[];
+    onClose: (dto: { asset: AssetResponseDto }) => void;
+    onNext: () => void;
+    onPrevious: () => void;
+    copyImage?: () => Promise<void>;
+  }
+
+  let {
+    assetStore = null,
+    asset = $bindable(),
+    preloadAssets = $bindable([]),
+    showNavigation = true,
+    withStacked = false,
+    isShared = false,
+    album = null,
+    onAction = undefined,
+    reactions = $bindable([]),
+    onClose,
+    onNext,
+    onPrevious,
+    copyImage = $bindable(),
+  }: Props = $props();
 
   const { setAsset } = assetViewingStore;
   const {
@@ -70,26 +89,23 @@
     slideshowTransition,
   } = slideshowStore;
 
-  let appearsInAlbums: AlbumResponseDto[] = [];
-  let shouldPlayMotionPhoto = false;
+  let appearsInAlbums: AlbumResponseDto[] = $state([]);
+  let shouldPlayMotionPhoto = $state(false);
   let sharedLink = getSharedLink();
   let enableDetailPanel = asset.hasMetadata;
   let slideshowStateUnsubscribe: () => void;
   let shuffleSlideshowUnsubscribe: () => void;
-  let previewStackedAsset: AssetResponseDto | undefined;
-  let isShowActivity = false;
-  let isShowEditor = false;
-  let isLiked: ActivityResponseDto | null = null;
-  let numberOfComments: number;
-  let fullscreenElement: Element;
+  let previewStackedAsset: AssetResponseDto | undefined = $state();
+  let isShowActivity = $state(false);
+  let isShowEditor = $state(false);
+  let isLiked: ActivityResponseDto | null = $state(null);
+  let numberOfComments = $state(0);
+  let fullscreenElement = $state<Element>();
   let unsubscribes: (() => void)[] = [];
-  let selectedEditType: string = '';
-  let stack: StackResponseDto | null = null;
+  let selectedEditType: string = $state('');
+  let stack: StackResponseDto | null = $state(null);
 
-  let zoomToggle = () => void 0;
-  let copyImage: () => Promise<void>;
-
-  $: isFullScreen = fullscreenElement !== null;
+  let zoomToggle = $state(() => void 0);
 
   const refreshStack = async () => {
     if (isSharedLink()) {
@@ -109,16 +125,6 @@
     }
   };
 
-  $: if (asset) {
-    handlePromiseError(refreshStack());
-  }
-
-  $: {
-    if (album && !album.isActivityEnabled && numberOfComments === 0) {
-      isShowActivity = false;
-    }
-  }
-
   const handleAddComment = () => {
     numberOfComments++;
     updateNumberOfComments(1);
@@ -184,13 +190,6 @@
     }
   };
 
-  $: {
-    if (isShared && asset.id) {
-      handlePromiseError(getFavorite());
-      handlePromiseError(getNumberOfComments());
-    }
-  }
-
   onMount(async () => {
     unsubscribes.push(
       websocketEvents.on('on_upload_success', onAssetUpdate),
@@ -233,12 +232,6 @@
     }
   });
 
-  $: {
-    if (asset.id && !sharedLink) {
-      handlePromiseError(handleGetAllAlbums());
-    }
-  }
-
   const handleGetAllAlbums = async () => {
     if (isSharedLink()) {
       return;
@@ -337,7 +330,7 @@
    * Slide show mode
    */
 
-  let assetViewerHtmlElement: HTMLElement;
+  let assetViewerHtmlElement = $state<HTMLElement>();
 
   const slideshowHistory = new SlideshowHistory((asset) => {
     setAsset(asset);
@@ -352,7 +345,7 @@
 
   const handlePlaySlideshow = async () => {
     try {
-      await assetViewerHtmlElement.requestFullscreen?.();
+      await assetViewerHtmlElement?.requestFullscreen?.();
     } catch (error) {
       handleError(error, $t('errors.unable_to_enter_fullscreen'));
       $slideshowState = SlideshowState.StopSlideshow;
@@ -395,6 +388,28 @@
   const handleUpdateSelectedEditType = (type: string) => {
     selectedEditType = type;
   };
+  let isFullScreen = $derived(fullscreenElement !== null);
+  $effect(() => {
+    if (asset) {
+      handlePromiseError(refreshStack());
+    }
+  });
+  $effect(() => {
+    if (album && !album.isActivityEnabled && numberOfComments === 0) {
+      isShowActivity = false;
+    }
+  });
+  $effect(() => {
+    if (isShared && asset.id) {
+      handlePromiseError(getFavorite());
+      handlePromiseError(getNumberOfComments());
+    }
+  });
+  $effect(() => {
+    if (asset.id && !sharedLink) {
+      handlePromiseError(handleGetAllAlbums());
+    }
+  });
 </script>
 
 <svelte:document bind:fullscreenElement />
@@ -421,11 +436,12 @@
         onShowDetail={toggleDetailPanel}
         onClose={closeViewer}
       >
-        <MotionPhotoAction
-          slot="motion-photo"
-          isPlaying={shouldPlayMotionPhoto}
-          onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
-        />
+        {#snippet motionPhoto()}
+          <MotionPhotoAction
+            isPlaying={shouldPlayMotionPhoto}
+            onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
+          />
+        {/snippet}
       </AssetViewerNavBar>
     </div>
   {/if}
@@ -442,7 +458,7 @@
       <div class="z-[1000] absolute w-full flex">
         <SlideshowBar
           {isFullScreen}
-          onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen?.()}
+          onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
           onPrevious={() => navigateAsset('previous')}
           onNext={() => navigateAsset('next')}
           onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
@@ -460,7 +476,7 @@
             {preloadAssets}
             onPreviousAsset={() => navigateAsset('previous')}
             onNextAsset={() => navigateAsset('next')}
-            on:close={closeViewer}
+            onClose={closeViewer}
             haveFadeTransition={false}
             {sharedLink}
           />
@@ -472,9 +488,9 @@
             loopVideo={true}
             onPreviousAsset={() => navigateAsset('previous')}
             onNextAsset={() => navigateAsset('next')}
-            on:close={closeViewer}
-            on:onVideoEnded={() => navigateAsset()}
-            on:onVideoStarted={handleVideoStarted}
+            onClose={closeViewer}
+            onVideoEnded={() => navigateAsset()}
+            onVideoStarted={handleVideoStarted}
           />
         {/if}
       {/key}
@@ -489,8 +505,7 @@
               loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
               onPreviousAsset={() => navigateAsset('previous')}
               onNextAsset={() => navigateAsset('next')}
-              on:close={closeViewer}
-              on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
+              onVideoEnded={() => (shouldPlayMotionPhoto = false)}
             />
           {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
                 .toLowerCase()
@@ -506,7 +521,7 @@
               {preloadAssets}
               onPreviousAsset={() => navigateAsset('previous')}
               onNextAsset={() => navigateAsset('next')}
-              on:close={closeViewer}
+              onClose={closeViewer}
               {sharedLink}
               haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
             />
@@ -519,9 +534,9 @@
             loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
             onPreviousAsset={() => navigateAsset('previous')}
             onNextAsset={() => navigateAsset('next')}
-            on:close={closeViewer}
-            on:onVideoEnded={() => navigateAsset()}
-            on:onVideoStarted={handleVideoStarted}
+            onClose={closeViewer}
+            onVideoEnded={() => navigateAsset()}
+            onVideoStarted={handleVideoStarted}
           />
         {/if}
         {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
@@ -574,7 +589,7 @@
       class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
     >
       <div class="relative w-full whitespace-nowrap transition-all">
-        {#each stackedAssets as stackedAsset, index (stackedAsset.id)}
+        {#each stackedAssets as stackedAsset (stackedAsset.id)}
           <div
             class="{stackedAsset.id == asset.id
               ? '-translate-y-[1px]'
@@ -587,7 +602,6 @@
               asset={stackedAsset}
               onClick={(stackedAsset) => {
                 asset = stackedAsset;
-                preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
               }}
               onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
               disableMouseOver
diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte
index b916733476..0eba78b0c0 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte
@@ -8,14 +8,21 @@
   import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let isOwner: boolean;
+  interface Props {
+    asset: AssetResponseDto;
+    isOwner: boolean;
+  }
 
-  $: description = asset.exifInfo?.description || '';
+  let { asset, isOwner }: Props = $props();
+
+  let description = $derived(asset.exifInfo?.description || '');
 
   const handleFocusOut = async (newDescription: string) => {
     try {
       await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
+
+      asset.exifInfo = { ...asset.exifInfo, description: newDescription };
+
       notificationController.show({
         type: NotificationType.Info,
         message: $t('asset_description_updated'),
@@ -23,7 +30,6 @@
     } catch (error) {
       handleError(error, $t('cannot_update_the_description'));
     }
-    description = newDescription;
   };
 </script>
 
diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte
index 7d5d86b443..9e59243aa1 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte
@@ -7,10 +7,14 @@
   import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let isOwner: boolean;
-  export let asset: AssetResponseDto;
+  interface Props {
+    isOwner: boolean;
+    asset: AssetResponseDto;
+  }
 
-  let isShowChangeLocation = false;
+  let { isOwner, asset = $bindable() }: Props = $props();
+
+  let isShowChangeLocation = $state(false);
 
   async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
     isShowChangeLocation = false;
@@ -30,7 +34,7 @@
   <button
     type="button"
     class="flex w-full text-left justify-between place-items-start gap-4 py-4"
-    on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
+    onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
     title={isOwner ? $t('edit_location') : ''}
     class:hover:dark:text-immich-dark-primary={isOwner}
     class:hover:text-immich-primary={isOwner}
@@ -65,7 +69,7 @@
   <button
     type="button"
     class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
-    on:click={() => (isShowChangeLocation = true)}
+    onclick={() => (isShowChangeLocation = true)}
     title={$t('add_location')}
   >
     <div class="flex gap-4">
diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte
index b73fe71716..4c5bfd71a8 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte
@@ -6,10 +6,14 @@
   import { handlePromiseError, isSharedLink } from '$lib/utils';
   import { preferences } from '$lib/stores/user.store';
 
-  export let asset: AssetResponseDto;
-  export let isOwner: boolean;
+  interface Props {
+    asset: AssetResponseDto;
+    isOwner: boolean;
+  }
 
-  $: rating = asset.exifInfo?.rating || 0;
+  let { asset, isOwner }: Props = $props();
+
+  let rating = $derived(asset.exifInfo?.rating || 0);
 
   const handleChangeRating = async (rating: number) => {
     try {
diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
index 449f61183f..c1175f5eb4 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
@@ -9,12 +9,16 @@
   import { mdiClose, mdiPlus } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let isOwner: boolean;
+  interface Props {
+    asset: AssetResponseDto;
+    isOwner: boolean;
+  }
 
-  $: tags = asset.tags || [];
+  let { asset = $bindable(), isOwner }: Props = $props();
 
-  let isOpen = false;
+  let tags = $derived(asset.tags || []);
+
+  let isOpen = $state(false);
 
   const handleAdd = () => (isOpen = true);
 
@@ -58,7 +62,7 @@
             type="button"
             class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
             title="Remove tag"
-            on:click={() => handleRemove(tag.id)}
+            onclick={() => handleRemove(tag.id)}
           >
             <Icon path={mdiClose} />
           </button>
@@ -68,7 +72,7 @@
         type="button"
         class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
         title="Add tag"
-        on:click={handleAdd}
+        onclick={handleAdd}
       >
         <span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
       </button>
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index ab84896b7b..9908630233 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -46,10 +46,14 @@
   import AlbumListItemDetails from './album-list-item-details.svelte';
   import Portal from '$lib/components/shared-components/portal/portal.svelte';
 
-  export let asset: AssetResponseDto;
-  export let albums: AlbumResponseDto[] = [];
-  export let currentAlbum: AlbumResponseDto | null = null;
-  export let onClose: () => void;
+  interface Props {
+    asset: AssetResponseDto;
+    albums?: AlbumResponseDto[];
+    currentAlbum?: AlbumResponseDto | null;
+    onClose: () => void;
+  }
+
+  let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
 
   const getDimensions = (exifInfo: ExifResponseDto) => {
     const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
@@ -60,11 +64,11 @@
     return { width, height };
   };
 
-  let showAssetPath = false;
-  let showEditFaces = false;
-  let previousId: string;
+  let showAssetPath = $state(false);
+  let showEditFaces = $state(false);
+  let previousId: string | undefined = $state();
 
-  $: {
+  $effect(() => {
     if (!previousId) {
       previousId = asset.id;
     }
@@ -72,9 +76,9 @@
       showEditFaces = false;
       previousId = asset.id;
     }
-  }
+  });
 
-  $: isOwner = $user?.id === asset.ownerId;
+  let isOwner = $derived($user?.id === asset.ownerId);
 
   const handleNewAsset = async (newAsset: AssetResponseDto) => {
     // TODO: check if reloading asset data is necessary
@@ -85,27 +89,30 @@
     }
   };
 
-  $: handlePromiseError(handleNewAsset(asset));
+  $effect(() => {
+    handlePromiseError(handleNewAsset(asset));
+  });
 
-  $: latlng = (() => {
-    const lat = asset.exifInfo?.latitude;
-    const lng = asset.exifInfo?.longitude;
+  let latlng = $derived(
+    (() => {
+      const lat = asset.exifInfo?.latitude;
+      const lng = asset.exifInfo?.longitude;
 
-    if (lat && lng) {
-      return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
-    }
-  })();
+      if (lat && lng) {
+        return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
+      }
+    })(),
+  );
 
-  $: people = asset.people || [];
-  $: showingHiddenPeople = false;
-
-  $: unassignedFaces = asset.unassignedFaces || [];
-
-  $: timeZone = asset.exifInfo?.timeZone;
-  $: dateTime =
+  let people = $state(asset.people || []);
+  let unassignedFaces = $state(asset.unassignedFaces || []);
+  let showingHiddenPeople = $state(false);
+  let timeZone = $derived(asset.exifInfo?.timeZone);
+  let dateTime = $derived(
     timeZone && asset.exifInfo?.dateTimeOriginal
       ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
-      : fromLocalDateTime(asset.localDateTime);
+      : fromLocalDateTime(asset.localDateTime),
+  );
 
   const getMegapixel = (width: number, height: number): number | undefined => {
     const megapixel = Math.round((height * width) / 1_000_000);
@@ -127,7 +134,7 @@
 
   const toggleAssetPath = () => (showAssetPath = !showAssetPath);
 
-  let isShowChangeDate = false;
+  let isShowChangeDate = $state(false);
 
   async function handleConfirmChangeDate(dateTimeOriginal: string) {
     isShowChangeDate = false;
@@ -141,7 +148,7 @@
 
 <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
   <div class="flex place-items-center gap-2">
-    <CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
+    <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
     <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
   </div>
 
@@ -190,7 +197,7 @@
               icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
               padding="1"
               buttonSize="32"
-              on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
+              onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
             />
           {/if}
           <CircleIconButton
@@ -199,7 +206,7 @@
             padding="1"
             size="20"
             buttonSize="32"
-            on:click={() => (showEditFaces = true)}
+            onclick={() => (showEditFaces = true)}
           />
         </div>
       </div>
@@ -212,10 +219,10 @@
               href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
                 ? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
                 : AppRoute.PHOTOS}"
-              on:focus={() => ($boundingBoxesArray = people[index].faces)}
-              on:blur={() => ($boundingBoxesArray = [])}
-              on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
-              on:mouseleave={() => ($boundingBoxesArray = [])}
+              onfocus={() => ($boundingBoxesArray = people[index].faces)}
+              onblur={() => ($boundingBoxesArray = [])}
+              onmouseover={() => ($boundingBoxesArray = people[index].faces)}
+              onmouseleave={() => ($boundingBoxesArray = [])}
             >
               <div class="relative">
                 <ImageThumbnail
@@ -278,7 +285,7 @@
       <button
         type="button"
         class="flex w-full text-left justify-between place-items-start gap-4 py-4"
-        on:click={() => (isOwner ? (isShowChangeDate = true) : null)}
+        onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
         title={isOwner ? $t('edit_date') : ''}
         class:hover:dark:text-immich-dark-primary={isOwner}
         class:hover:text-immich-primary={isOwner}
@@ -357,7 +364,7 @@
               title={$t('show_file_location')}
               size="16"
               padding="2"
-              on:click={toggleAssetPath}
+              onclick={toggleAssetPath}
             />
           {/if}
         </p>
@@ -428,8 +435,7 @@
         </div>
       {/await}
     {:then component}
-      <svelte:component
-        this={component.default}
+      <component.default
         mapMarkers={[
           {
             lat: latlng.lat,
@@ -446,7 +452,7 @@
         useLocationPin
         onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)}
       >
-        <svelte:fragment slot="popup" let:marker>
+        {#snippet popup({ marker })}
           {@const { lat, lon } = marker}
           <div class="flex flex-col items-center gap-1">
             <p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
@@ -458,8 +464,8 @@
               {$t('open_in_openstreetmap')}
             </a>
           </div>
-        </svelte:fragment>
-      </svelte:component>
+        {/snippet}
+      </component.default>
     {/await}
   </div>
 {/if}
diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte
index db46e1eff0..17f5e7e6a8 100644
--- a/web/src/lib/components/asset-viewer/download-panel.svelte
+++ b/web/src/lib/components/asset-viewer/download-panel.svelte
@@ -44,7 +44,7 @@
           <div class="absolute right-2">
             <CircleIconButton
               title={$t('close')}
-              on:click={() => abort(downloadKey, download)}
+              onclick={() => abort(downloadKey, download)}
               size="20"
               icon={mdiClose}
               class="dark:text-immich-dark-gray"
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte
index c35fd91519..2b7153ed4e 100644
--- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { onMount, afterUpdate, onDestroy, tick } from 'svelte';
+  import { onMount, onDestroy, tick } from 'svelte';
   import { t } from 'svelte-i18n';
   import { getAssetOriginalUrl } from '$lib/utils';
   import { handleError } from '$lib/utils/handle-error';
@@ -17,11 +17,23 @@
     resetGlobalCropStore,
     rotateDegrees,
   } from '$lib/stores/asset-editor.store';
+  import type { AssetResponseDto } from '@immich/sdk';
 
-  export let asset;
-  let img: HTMLImageElement;
+  interface Props {
+    asset: AssetResponseDto;
+  }
 
-  $: imgElement.set(img);
+  let { asset }: Props = $props();
+
+  let img = $state<HTMLImageElement>();
+
+  $effect(() => {
+    if (!img) {
+      return;
+    }
+
+    imgElement.set(img);
+  });
 
   cropAspectRatio.subscribe((value) => {
     if (!img || !$cropAreaEl) {
@@ -54,7 +66,7 @@
     resetGlobalCropStore();
   });
 
-  afterUpdate(() => {
+  $effect(() => {
     resizeCanvas();
   });
 </script>
@@ -64,8 +76,8 @@
     class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
     style={`rotate:${$rotateDegrees}deg`}
     bind:this={$cropAreaEl}
-    on:mousedown={handleMouseDown}
-    on:mouseup={handleMouseUp}
+    onmousedown={handleMouseDown}
+    onmouseup={handleMouseUp}
     aria-label="Crop area"
     type="button"
   >
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte
index 667191274f..eb788b2d16 100644
--- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte
@@ -3,37 +3,41 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
 
-  export let size: {
-    icon: string;
-    name: CropAspectRatio;
-    viewBox: string;
-    rotate?: boolean;
-  };
-  export let selectedSize: CropAspectRatio;
-  export let rotateHorizontal: boolean;
-  export let selectType: (size: CropAspectRatio) => void;
+  interface Props {
+    size: {
+      icon: string;
+      name: CropAspectRatio;
+      viewBox: string;
+      rotate?: boolean;
+    };
+    selectedSize: CropAspectRatio;
+    rotateHorizontal: boolean;
+    selectType: (size: CropAspectRatio) => void;
+  }
 
-  $: isSelected = selectedSize === size.name;
-  $: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color;
+  let { size, selectedSize, rotateHorizontal, selectType }: Props = $props();
 
-  $: rotatedTitle = (title: string, toRotate: boolean) => {
+  let isSelected = $derived(selectedSize === size.name);
+  let buttonColor = $derived((isSelected ? 'primary' : 'transparent-gray') as Color);
+
+  let rotatedTitle = $derived((title: string, toRotate: boolean) => {
     let sides = title.split(':');
     if (toRotate) {
       sides.reverse();
     }
     return sides.join(':');
-  };
+  });
 
-  $: toRotate = (def: boolean | undefined) => {
+  let toRotate = $derived((def: boolean | undefined) => {
     if (def === false) {
       return false;
     }
     return (def && !rotateHorizontal) || (!def && rotateHorizontal);
-  };
+  });
 </script>
 
 <li>
-  <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}>
+  <Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" onclick={() => selectType(size.name)}>
     <Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
     <span>{rotatedTitle(size.name, rotateHorizontal)}</span>
   </Button>
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte
index dba3be5d67..363bec7c1f 100644
--- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte
@@ -16,7 +16,7 @@
   import { tick } from 'svelte';
   import CropPreset from './crop-preset.svelte';
 
-  $: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees);
+  let rotateHorizontal = $derived([90, 270].includes($normaizedRorateDegrees));
   const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
   const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
   const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
@@ -92,14 +92,17 @@
     },
   ];
 
-  let selectedSize: CropAspectRatio = 'free';
-  $cropAspectRatio = selectedSize;
+  let selectedSize: CropAspectRatio = $state('free');
 
-  $: sizesRows = [
+  $effect(() => {
+    $cropAspectRatio = selectedSize;
+  });
+
+  let sizesRows = $derived([
     sizes.filter((s) => s.rotate === false),
     sizes.filter((s) => s.rotate === undefined),
     sizes.filter((s) => s.rotate === true),
-  ];
+  ]);
 
   async function rotate(clock: boolean) {
     rotateDegrees.update((v) => {
@@ -145,7 +148,7 @@
     <h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2>
   </div>
   <ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
-    <li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li>
-    <li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li>
+    <li><CircleIconButton title={$t('anti_clockwise')} onclick={() => rotate(false)} icon={mdiRotateLeft} /></li>
+    <li><CircleIconButton title={$t('clockwise')} onclick={() => rotate(true)} icon={mdiRotateRight} /></li>
   </ul>
 </div>
diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
index 78d5ca26e0..133d9c9021 100644
--- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
+++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
@@ -9,8 +9,6 @@
   import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
   import { shortcut } from '$lib/actions/shortcut';
 
-  export let asset: AssetResponseDto;
-
   onMount(() => {
     return websocketEvents.on('on_asset_update', (assetUpdate) => {
       if (assetUpdate.id === asset.id) {
@@ -19,12 +17,16 @@
     });
   });
 
-  export let onUpdateSelectedType: (type: string) => void;
-  export let onClose: () => void;
+  interface Props {
+    asset: AssetResponseDto;
+    onUpdateSelectedType: (type: string) => void;
+    onClose: () => void;
+  }
 
-  let selectedType: string = editTypes[0].name;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
+  let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
+
+  let selectedType: string = $state(editTypes[0].name);
+  let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]);
 
   setTimeout(() => {
     onUpdateSelectedType(selectedType);
@@ -39,7 +41,7 @@
 
 <section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
   <div class="flex place-items-center gap-2">
-    <CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
+    <CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
     <p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
   </div>
   <section class="px-4 py-4">
@@ -50,14 +52,14 @@
             color={etype.name === selectedType ? 'primary' : 'opaque'}
             icon={etype.icon}
             title={etype.name}
-            on:click={() => selectType(etype.name)}
+            onclick={() => selectType(etype.name)}
           />
         </li>
       {/each}
     </ul>
   </section>
   <section>
-    <svelte:component this={selectedTypeObj.component} />
+    <selectedTypeObj.component />
   </section>
 </section>
 
diff --git a/web/src/lib/components/asset-viewer/navigation-area.svelte b/web/src/lib/components/asset-viewer/navigation-area.svelte
index e69d93b6b6..88f0baf0bc 100644
--- a/web/src/lib/components/asset-viewer/navigation-area.svelte
+++ b/web/src/lib/components/asset-viewer/navigation-area.svelte
@@ -1,13 +1,20 @@
 <script lang="ts">
-  export let onClick: (e: MouseEvent) => void;
-  export let label: string;
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    onClick: (e: MouseEvent) => void;
+    label: string;
+    children?: Snippet;
+  }
+
+  let { onClick, label, children }: Props = $props();
 </script>
 
 <button
   type="button"
   class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
   aria-label={label}
-  on:click={onClick}
+  onclick={onClick}
 >
-  <slot />
+  {@render children?.()}
 </button>
diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte
index 396685e351..b17f9fdea7 100644
--- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte
@@ -8,7 +8,11 @@
   import { fade } from 'svelte/transition';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
 
-  export let asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
+  interface Props {
+    asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
+  }
+
+  let { asset }: Props = $props();
 
   const photoSphereConfigs =
     asset.type === AssetTypeEnum.Video
@@ -43,14 +47,7 @@
   {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
     <LoadingSpinner />
   {:then [data, module, adapter, plugins, navbar]}
-    <svelte:component
-      this={module.default}
-      panorama={data}
-      plugins={plugins ?? undefined}
-      {navbar}
-      {adapter}
-      {originalImageUrl}
-    />
+    <module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} />
   {:catch}
     {$t('errors.failed_to_load_asset')}
   {/await}
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
index 1745cd66b6..c18e6bd14b 100644
--- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
+++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte
@@ -10,16 +10,24 @@
   import '@photo-sphere-viewer/core/index.css';
   import { onDestroy, onMount } from 'svelte';
 
-  export let panorama: string | { source: string };
-  export let originalImageUrl: string | null;
-  export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter;
-  export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = [];
-  export let navbar = false;
+  interface Props {
+    panorama: string | { source: string };
+    originalImageUrl: string | null;
+    adapter?: AdapterConstructor | [AdapterConstructor, unknown];
+    plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
+    navbar?: boolean;
+  }
 
-  let container: HTMLDivElement;
+  let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
+
+  let container: HTMLDivElement | undefined = $state();
   let viewer: Viewer;
 
   onMount(() => {
+    if (!container) {
+      return;
+    }
+
     viewer = new Viewer({
       adapter,
       plugins,
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index d7595f6b7e..e24751b3c8 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -20,33 +20,38 @@
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
   import { handleError } from '$lib/utils/handle-error';
 
-  export let asset: AssetResponseDto;
-  export let preloadAssets: AssetResponseDto[] | undefined = undefined;
-  export let element: HTMLDivElement | undefined = undefined;
-  export let haveFadeTransition = true;
-  export let sharedLink: SharedLinkResponseDto | undefined = undefined;
-  export let onPreviousAsset: (() => void) | null = null;
-  export let onNextAsset: (() => void) | null = null;
-  export let copyImage: (() => Promise<void>) | null = null;
-  export let zoomToggle: (() => void) | null = null;
+  interface Props {
+    asset: AssetResponseDto;
+    preloadAssets?: AssetResponseDto[] | undefined;
+    element?: HTMLDivElement | undefined;
+    haveFadeTransition?: boolean;
+    sharedLink?: SharedLinkResponseDto | undefined;
+    onPreviousAsset?: (() => void) | null;
+    onNextAsset?: (() => void) | null;
+    copyImage?: () => Promise<void>;
+    zoomToggle?: (() => void) | null;
+    onClose?: () => void;
+  }
+
+  let {
+    asset,
+    preloadAssets = undefined,
+    element = $bindable(),
+    haveFadeTransition = true,
+    sharedLink = undefined,
+    onPreviousAsset = null,
+    onNextAsset = null,
+    copyImage = $bindable(),
+    zoomToggle = $bindable(),
+  }: Props = $props();
 
   const { slideshowState, slideshowLook } = slideshowStore;
 
-  let assetFileUrl: string = '';
-  let imageLoaded: boolean = false;
-  let imageError: boolean = false;
-  let forceUseOriginal: boolean = false;
-  let loader: HTMLImageElement;
+  let assetFileUrl: string = $state('');
+  let imageLoaded: boolean = $state(false);
+  let imageError: boolean = $state(false);
 
-  $: isWebCompatible = isWebCompatibleImage(asset);
-  $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
-  $: useOriginalImage = useOriginalByDefault || forceUseOriginal;
-  // when true, will force loading of the original image
-  $: forceUseOriginal =
-    forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible);
-
-  $: preload(useOriginalImage, preloadAssets);
-  $: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum);
+  let loader = $state<HTMLImageElement>();
 
   photoZoomState.set({
     currentRotation: 0,
@@ -129,16 +134,31 @@
     const onerror = () => {
       imageError = imageLoaded = true;
     };
-    if (loader.complete) {
+    if (loader?.complete) {
       onload();
     }
-    loader.addEventListener('load', onload);
-    loader.addEventListener('error', onerror);
+    loader?.addEventListener('load', onload);
+    loader?.addEventListener('error', onerror);
     return () => {
       loader?.removeEventListener('load', onload);
       loader?.removeEventListener('error', onerror);
     };
   });
+  let isWebCompatible = $derived(isWebCompatibleImage(asset));
+  let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
+  // when true, will force loading of the original image
+
+  let forceUseOriginal: boolean = $derived(
+    asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible),
+  );
+
+  let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal);
+
+  $effect(() => {
+    preload(useOriginalImage, preloadAssets);
+  });
+
+  let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum));
 </script>
 
 <svelte:window
@@ -150,15 +170,15 @@
 {#if imageError}
   <BrokenAsset class="text-xl" />
 {/if}
-<!-- svelte-ignore a11y-missing-attribute -->
+<!-- svelte-ignore a11y_missing_attribute -->
 <img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
 <div bind:this={element} class="relative h-full select-none">
   <img
     style="display:none"
     src={imageLoaderUrl}
     alt={$getAltText(asset)}
-    on:load={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))}
-    on:error={() => (imageError = imageLoaded = true)}
+    onload={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))}
+    onerror={() => (imageError = imageLoaded = true)}
   />
   {#if !imageLoaded}
     <div id="spinner" class="flex h-full items-center justify-center">
@@ -168,7 +188,7 @@
     <div
       use:zoomImageAction
       use:swipe
-      on:swipe={onSwipe}
+      onswipe={onSwipe}
       class="h-full w-full"
       transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
     >
diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
index 1acc06f21b..95e08cb310 100644
--- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte
+++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
@@ -9,20 +9,30 @@
   import { t } from 'svelte-i18n';
   import { fly } from 'svelte/transition';
 
-  export let isFullScreen: boolean;
-  export let onNext = () => {};
-  export let onPrevious = () => {};
-  export let onClose = () => {};
-  export let onSetToFullScreen = () => {};
+  interface Props {
+    isFullScreen: boolean;
+    onNext?: () => void;
+    onPrevious?: () => void;
+    onClose?: () => void;
+    onSetToFullScreen?: () => void;
+  }
+
+  let {
+    isFullScreen,
+    onNext = () => {},
+    onPrevious = () => {},
+    onClose = () => {},
+    onSetToFullScreen = () => {},
+  }: Props = $props();
 
   const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore;
 
-  let progressBarStatus: ProgressBarStatus;
-  let progressBar: ProgressBar;
-  let showSettings = false;
-  let showControls = true;
+  let progressBarStatus: ProgressBarStatus | undefined = $state();
+  let progressBar = $state<ReturnType<typeof ProgressBar>>();
+  let showSettings = $state(false);
+  let showControls = $state(true);
   let timer: NodeJS.Timeout;
-  let isOverControls = false;
+  let isOverControls = $state(false);
 
   let unsubscribeRestart: () => void;
   let unsubscribeStop: () => void;
@@ -55,13 +65,13 @@
     hideControlsAfterDelay();
     unsubscribeRestart = restartProgress.subscribe((value) => {
       if (value) {
-        progressBar.restart(value);
+        progressBar?.restart(value);
       }
     });
 
     unsubscribeStop = stopProgress.subscribe((value) => {
       if (value) {
-        progressBar.restart(false);
+        progressBar?.restart(false);
         stopControlsHideTimer();
       }
     });
@@ -77,7 +87,9 @@
     }
   });
 
-  const handleDone = () => {
+  const handleDone = async () => {
+    await progressBar?.reset();
+
     if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) {
       onPrevious();
       return;
@@ -87,7 +99,7 @@
 </script>
 
 <svelte:window
-  on:mousemove={showControlBar}
+  onmousemove={showControlBar}
   use:shortcuts={[
     { shortcut: { key: 'Escape' }, onShortcut: onClose },
     { shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
@@ -98,32 +110,32 @@
 {#if showControls}
   <div
     class="m-4 flex gap-2"
-    on:mouseenter={() => (isOverControls = true)}
-    on:mouseleave={() => (isOverControls = false)}
+    onmouseenter={() => (isOverControls = true)}
+    onmouseleave={() => (isOverControls = false)}
     transition:fly={{ duration: 150 }}
     role="navigation"
   >
-    <CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} />
+    <CircleIconButton buttonSize="50" icon={mdiClose} onclick={onClose} title={$t('exit_slideshow')} />
 
     <CircleIconButton
       buttonSize="50"
       icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
-      on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
+      onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
       title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
     />
-    <CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} />
-    <CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} />
+    <CircleIconButton buttonSize="50" icon={mdiChevronLeft} onclick={onPrevious} title={$t('previous')} />
+    <CircleIconButton buttonSize="50" icon={mdiChevronRight} onclick={onNext} title={$t('next')} />
     <CircleIconButton
       buttonSize="50"
       icon={mdiCog}
-      on:click={() => (showSettings = !showSettings)}
+      onclick={() => (showSettings = !showSettings)}
       title={$t('slideshow_settings')}
     />
     {#if !isFullScreen}
       <CircleIconButton
         buttonSize="50"
         icon={mdiFullscreen}
-        on:click={onSetToFullScreen}
+        onclick={onSetToFullScreen}
         title={$t('set_slideshow_to_fullscreen')}
       />
     {/if}
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
index 58012ccfce..d019ef273f 100644
--- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
@@ -4,31 +4,53 @@
   import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
   import { handleError } from '$lib/utils/handle-error';
   import { AssetMediaSize } from '@immich/sdk';
-  import { tick } from 'svelte';
+  import { onDestroy, onMount } from 'svelte';
   import { swipe } from 'svelte-gestures';
   import type { SwipeCustomEvent } from 'svelte-gestures';
   import { fade } from 'svelte/transition';
   import { t } from 'svelte-i18n';
 
-  export let assetId: string;
-  export let loopVideo: boolean;
-  export let checksum: string;
-  export let onPreviousAsset: () => void = () => {};
-  export let onNextAsset: () => void = () => {};
-  export let onVideoEnded: () => void = () => {};
-  export let onVideoStarted: () => void = () => {};
-
-  let element: HTMLVideoElement | undefined = undefined;
-  let isVideoLoading = true;
-  let assetFileUrl: string;
-  let forceMuted = false;
-
-  $: if (element) {
-    assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
-    forceMuted = false;
-    element.load();
+  interface Props {
+    assetId: string;
+    loopVideo: boolean;
+    checksum: string;
+    onPreviousAsset?: () => void;
+    onNextAsset?: () => void;
+    onVideoEnded?: () => void;
+    onVideoStarted?: () => void;
+    onClose?: () => void;
   }
 
+  let {
+    assetId,
+    loopVideo,
+    checksum,
+    onPreviousAsset = () => {},
+    onNextAsset = () => {},
+    onVideoEnded = () => {},
+    onVideoStarted = () => {},
+    onClose = () => {},
+  }: Props = $props();
+
+  let videoPlayer: HTMLVideoElement | undefined = $state();
+  let isLoading = $state(true);
+  let assetFileUrl = $state('');
+  let forceMuted = $state(false);
+
+  onMount(() => {
+    if (videoPlayer) {
+      assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
+      forceMuted = false;
+      videoPlayer.load();
+    }
+  });
+
+  onDestroy(() => {
+    if (videoPlayer) {
+      videoPlayer.src = '';
+    }
+  });
+
   const handleCanPlay = async (video: HTMLVideoElement) => {
     try {
       await video.play();
@@ -38,16 +60,16 @@
         await tryForceMutedPlay(video);
         return;
       }
+
       handleError(error, $t('errors.unable_to_play_video'));
     } finally {
-      isVideoLoading = false;
+      isLoading = false;
     }
   };
 
   const tryForceMutedPlay = async (video: HTMLVideoElement) => {
     try {
-      forceMuted = true;
-      await tick();
+      video.muted = true;
       await handleCanPlay(video);
     } catch (error) {
       handleError(error, $t('errors.unable_to_play_video'));
@@ -66,21 +88,22 @@
 
 <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
   <video
-    bind:this={element}
+    bind:this={videoPlayer}
     loop={$loopVideoPreference && loopVideo}
     autoplay
     playsinline
     controls
     class="h-full object-contain"
     use:swipe
-    on:swipe={onSwipe}
-    on:canplay={(e) => handleCanPlay(e.currentTarget)}
-    on:ended={onVideoEnded}
-    on:volumechange={(e) => {
+    onswipe={onSwipe}
+    oncanplay={(e) => handleCanPlay(e.currentTarget)}
+    onended={onVideoEnded}
+    onvolumechange={(e) => {
       if (!forceMuted) {
         $videoViewerMuted = e.currentTarget.muted;
       }
     }}
+    onclose={() => onClose()}
     muted={forceMuted || $videoViewerMuted}
     bind:volume={$videoViewerVolume}
     poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
@@ -88,7 +111,7 @@
   >
   </video>
 
-  {#if isVideoLoading}
+  {#if isLoading}
     <div class="absolute flex place-content-center place-items-center">
       <LoadingSpinner />
     </div>
diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
index 5f03784c42..3ee4791b07 100644
--- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
@@ -4,12 +4,29 @@
   import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
   import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
 
-  export let assetId: string;
-  export let projectionType: string | null | undefined;
-  export let checksum: string;
-  export let loopVideo: boolean;
-  export let onPreviousAsset: () => void;
-  export let onNextAsset: () => void;
+  interface Props {
+    assetId: string;
+    projectionType: string | null | undefined;
+    checksum: string;
+    loopVideo: boolean;
+    onClose?: () => void;
+    onPreviousAsset?: () => void;
+    onNextAsset?: () => void;
+    onVideoEnded?: () => void;
+    onVideoStarted?: () => void;
+  }
+
+  let {
+    assetId,
+    projectionType,
+    checksum,
+    loopVideo,
+    onPreviousAsset,
+    onClose,
+    onNextAsset,
+    onVideoEnded,
+    onVideoStarted,
+  }: Props = $props();
 </script>
 
 {#if projectionType === ProjectionType.EQUIRECTANGULAR}
@@ -21,7 +38,8 @@
     {assetId}
     {onPreviousAsset}
     {onNextAsset}
-    on:onVideoEnded
-    on:onVideoStarted
+    {onVideoEnded}
+    {onVideoStarted}
+    {onClose}
   />
 {/if}
diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte
index dd54afba01..31acb832e5 100644
--- a/web/src/lib/components/assets/broken-asset.svelte
+++ b/web/src/lib/components/assets/broken-asset.svelte
@@ -3,11 +3,14 @@
   import { mdiImageBrokenVariant } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  let className = '';
-  export { className as class };
-  export let hideMessage = false;
-  export let width: string | undefined = undefined;
-  export let height: string | undefined = undefined;
+  interface Props {
+    class?: string;
+    hideMessage?: boolean;
+    width?: string | undefined;
+    height?: string | undefined;
+  }
+
+  let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
 </script>
 
 <div
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
index 282d152e90..9d69bdeeb2 100644
--- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
@@ -7,29 +7,49 @@
   import { onMount } from 'svelte';
   import { fade } from 'svelte/transition';
 
-  export let url: string;
-  export let altText: string | undefined;
-  export let title: string | null = null;
-  export let heightStyle: string | undefined = undefined;
-  export let widthStyle: string;
-  export let base64ThumbHash: string | null = null;
-  export let curve = false;
-  export let shadow = false;
-  export let circle = false;
-  export let hidden = false;
-  export let border = false;
-  export let preload = true;
-  export let hiddenIconClass = 'text-white';
-  export let onComplete: (() => void) | undefined = undefined;
+  interface Props {
+    url: string;
+    altText: string | undefined;
+    title?: string | null;
+    heightStyle?: string | undefined;
+    widthStyle: string;
+    base64ThumbHash?: string | null;
+    curve?: boolean;
+    shadow?: boolean;
+    circle?: boolean;
+    hidden?: boolean;
+    border?: boolean;
+    preload?: boolean;
+    hiddenIconClass?: string;
+    onComplete?: (() => void) | undefined;
+    onClick?: (() => void) | undefined;
+  }
+
+  let {
+    url,
+    altText,
+    title = null,
+    heightStyle = undefined,
+    widthStyle,
+    base64ThumbHash = null,
+    curve = false,
+    shadow = false,
+    circle = false,
+    hidden = false,
+    border = false,
+    preload = true,
+    hiddenIconClass = 'text-white',
+    onComplete = undefined,
+  }: Props = $props();
 
   let {
     IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
   } = TUNABLES;
 
-  let loaded = false;
-  let errored = false;
+  let loaded = $state(false);
+  let errored = $state(false);
 
-  let img: HTMLImageElement;
+  let img = $state<HTMLImageElement>();
 
   const setLoaded = () => {
     loaded = true;
@@ -40,20 +60,22 @@
     onComplete?.();
   };
   onMount(() => {
-    if (img.complete) {
+    if (img?.complete) {
       setLoaded();
     }
   });
 
-  $: optionalClasses = [
-    curve && 'rounded-xl',
-    circle && 'rounded-full',
-    shadow && 'shadow-lg',
-    (circle || !heightStyle) && 'aspect-square',
-    border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
-  ]
-    .filter(Boolean)
-    .join(' ');
+  let optionalClasses = $derived(
+    [
+      curve && 'rounded-xl',
+      circle && 'rounded-full',
+      shadow && 'shadow-lg',
+      (circle || !heightStyle) && 'aspect-square',
+      border && 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary',
+    ]
+      .filter(Boolean)
+      .join(' '),
+  );
 </script>
 
 {#if errored}
@@ -61,8 +83,8 @@
 {:else}
   <img
     bind:this={img}
-    on:load={setLoaded}
-    on:error={setErrored}
+    onload={setLoaded}
+    onerror={setErrored}
     loading={preload ? 'eager' : 'lazy'}
     style:width={widthStyle}
     style:height={heightStyle}
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 4c2cf74518..536ea90163 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -31,62 +31,89 @@
   import { TUNABLES } from '$lib/utils/tunables';
   import { thumbhash } from '$lib/actions/thumbhash';
 
-  export let asset: AssetResponseDto;
-  export let dateGroup: DateGroup | undefined = undefined;
-  export let assetStore: AssetStore | undefined = undefined;
-  export let groupIndex = 0;
-  export let thumbnailSize: number | undefined = undefined;
-  export let thumbnailWidth: number | undefined = undefined;
-  export let thumbnailHeight: number | undefined = undefined;
-  export let selected = false;
-  export let selectionCandidate = false;
-  export let disabled = false;
-  export let readonly = false;
-  export let showArchiveIcon = false;
-  export let showStackedIcon = true;
-  export let disableMouseOver = false;
-  export let intersectionConfig: {
-    root?: HTMLElement;
-    bottom?: string;
-    top?: string;
-    left?: string;
-    priority?: number;
+  interface Props {
+    asset: AssetResponseDto;
+    dateGroup?: DateGroup | undefined;
+    assetStore?: AssetStore | undefined;
+    groupIndex?: number;
+    thumbnailSize?: number | undefined;
+    thumbnailWidth?: number | undefined;
+    thumbnailHeight?: number | undefined;
+    selected?: boolean;
+    selectionCandidate?: boolean;
     disabled?: boolean;
-  } = {};
+    readonly?: boolean;
+    showArchiveIcon?: boolean;
+    showStackedIcon?: boolean;
+    disableMouseOver?: boolean;
+    intersectionConfig?: {
+      root?: HTMLElement;
+      bottom?: string;
+      top?: string;
+      left?: string;
+      priority?: number;
+      disabled?: boolean;
+    };
+    retrieveElement?: boolean;
+    onIntersected?: (() => void) | undefined;
+    onClick?: ((asset: AssetResponseDto) => void) | undefined;
+    onRetrieveElement?: ((elment: HTMLElement) => void) | undefined;
+    onSelect?: ((asset: AssetResponseDto) => void) | undefined;
+    onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
+    class?: string;
+  }
 
-  export let retrieveElement: boolean = false;
-  export let onIntersected: (() => void) | undefined = undefined;
-  export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined;
-  export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined;
-  export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined;
-  export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined =
-    undefined;
-
-  let className = '';
-  export { className as class };
+  let {
+    asset,
+    dateGroup = undefined,
+    assetStore = undefined,
+    groupIndex = 0,
+    thumbnailSize = undefined,
+    thumbnailWidth = undefined,
+    thumbnailHeight = undefined,
+    selected = false,
+    selectionCandidate = false,
+    disabled = false,
+    readonly = false,
+    showArchiveIcon = false,
+    showStackedIcon = true,
+    disableMouseOver = false,
+    intersectionConfig = {},
+    retrieveElement = false,
+    onIntersected = undefined,
+    onClick = undefined,
+    onRetrieveElement = undefined,
+    onSelect = undefined,
+    onMouseEvent = undefined,
+    class: className = '',
+  }: Props = $props();
 
   let {
     IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
   } = TUNABLES;
 
   const componentId = generateId();
-  let element: HTMLElement | undefined;
-  let mouseOver = false;
-  let intersecting = false;
-  let lastRetrievedElement: HTMLElement | undefined;
-  let loaded = false;
+  let element: HTMLElement | undefined = $state();
+  let mouseOver = $state(false);
+  let intersecting = $state(false);
+  let lastRetrievedElement: HTMLElement | undefined = $state();
+  let loaded = $state(false);
 
-  $: if (!retrieveElement) {
-    lastRetrievedElement = undefined;
-  }
-  $: if (retrieveElement && element && lastRetrievedElement !== element) {
-    lastRetrievedElement = element;
-    onRetrieveElement?.(element);
-  }
+  $effect(() => {
+    if (!retrieveElement) {
+      lastRetrievedElement = undefined;
+    }
+  });
+  $effect(() => {
+    if (retrieveElement && element && lastRetrievedElement !== element) {
+      lastRetrievedElement = element;
+      onRetrieveElement?.(element);
+    }
+  });
 
-  $: width = thumbnailSize || thumbnailWidth || 235;
-  $: height = thumbnailSize || thumbnailHeight || 235;
-  $: display = intersecting;
+  let width = $derived(thumbnailSize || thumbnailWidth || 235);
+  let height = $derived(thumbnailSize || thumbnailHeight || 235);
+  let display = $derived(intersecting);
 
   const onIconClickedHandler = (e?: MouseEvent) => {
     e?.stopPropagation();
@@ -197,15 +224,15 @@
       class="group"
       class:cursor-not-allowed={disabled}
       class:cursor-pointer={!disabled}
-      on:mouseenter={onMouseEnter}
-      on:mouseleave={onMouseLeave}
-      on:keypress={(evt) => {
+      onmouseenter={onMouseEnter}
+      onmouseleave={onMouseLeave}
+      onkeypress={(evt) => {
         if (evt.key === 'Enter') {
           callClickHandlers();
         }
       }}
       tabindex={0}
-      on:click={handleClick}
+      onclick={handleClick}
       role="link"
     >
       {#if mouseOver && !disableMouseOver}
@@ -216,7 +243,7 @@
           style:width="{width}px"
           style:height="{height}px"
           href={currentUrlReplaceAssetId(asset.id)}
-          on:click={(evt) => evt.preventDefault()}
+          onclick={(evt) => evt.preventDefault()}
           tabindex={0}
           aria-label="Thumbnail URL"
         >
@@ -227,7 +254,7 @@
         {#if !readonly && (mouseOver || selected || selectionCandidate)}
           <button
             type="button"
-            on:click={onIconClickedHandler}
+            onclick={onIconClickedHandler}
             class="absolute p-2 focus:outline-none"
             class:cursor-not-allowed={disabled}
             role="checkbox"
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
index 14f99ac331..9188ab9a4f 100644
--- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte
@@ -7,31 +7,47 @@
   import { generateId } from '$lib/utils/generate-id';
   import { onDestroy } from 'svelte';
 
-  export let assetStore: AssetStore | undefined = undefined;
-  export let url: string;
-  export let durationInSeconds = 0;
-  export let enablePlayback = false;
-  export let playbackOnIconHover = false;
-  export let showTime = true;
-  export let curve = false;
-  export let playIcon = mdiPlayCircleOutline;
-  export let pauseIcon = mdiPauseCircleOutline;
+  interface Props {
+    assetStore?: AssetStore | undefined;
+    url: string;
+    durationInSeconds?: number;
+    enablePlayback?: boolean;
+    playbackOnIconHover?: boolean;
+    showTime?: boolean;
+    curve?: boolean;
+    playIcon?: string;
+    pauseIcon?: string;
+  }
+
+  let {
+    assetStore = undefined,
+    url,
+    durationInSeconds = 0,
+    enablePlayback = $bindable(false),
+    playbackOnIconHover = false,
+    showTime = true,
+    curve = false,
+    playIcon = mdiPlayCircleOutline,
+    pauseIcon = mdiPauseCircleOutline,
+  }: Props = $props();
 
   const componentId = generateId();
-  let remainingSeconds = durationInSeconds;
-  let loading = true;
-  let error = false;
-  let player: HTMLVideoElement;
+  let remainingSeconds = $state(durationInSeconds);
+  let loading = $state(true);
+  let error = $state(false);
+  let player: HTMLVideoElement | undefined = $state();
 
-  $: if (!enablePlayback) {
-    // Reset remaining time when playback is disabled.
-    remainingSeconds = durationInSeconds;
+  $effect(() => {
+    if (!enablePlayback) {
+      // Reset remaining time when playback is disabled.
+      remainingSeconds = durationInSeconds;
 
-    if (player) {
-      // Cancel video buffering.
-      player.src = '';
+      if (player) {
+        // Cancel video buffering.
+        player.src = '';
+      }
     }
-  }
+  });
   const onMouseEnter = () => {
     if (assetStore) {
       assetStore.taskManager.queueScrollSensitiveTask({
@@ -78,8 +94,8 @@
     </span>
   {/if}
 
-  <!-- svelte-ignore a11y-no-static-element-interactions -->
-  <span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
+  <!-- svelte-ignore a11y_no_static_element_interactions -->
+  <span class="pr-2 pt-2" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
     {#if enablePlayback}
       {#if loading}
         <LoadingSpinner />
@@ -103,15 +119,19 @@
     autoplay
     loop
     src={url}
-    on:play={() => {
+    onplay={() => {
       loading = false;
       error = false;
     }}
-    on:error={() => {
+    onerror={() => {
+      if (!player?.src) {
+        // Do not show error when the URL is empty.
+        return;
+      }
       error = true;
       loading = false;
     }}
-    on:timeupdate={({ currentTarget }) => {
+    ontimeupdate={({ currentTarget }) => {
       const remaining = currentTarget.duration - currentTarget.currentTime;
       remainingSeconds = Math.min(
         Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
diff --git a/web/src/lib/components/elements/badge.svelte b/web/src/lib/components/elements/badge.svelte
index da305e40f9..0db6e3fa40 100644
--- a/web/src/lib/components/elements/badge.svelte
+++ b/web/src/lib/components/elements/badge.svelte
@@ -1,11 +1,18 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type Color = 'primary' | 'secondary';
   export type Rounded = false | true | 'full';
 </script>
 
 <script lang="ts">
-  export let color: Color = 'primary';
-  export let rounded: Rounded = true;
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    color?: Color;
+    rounded?: Rounded;
+    children?: Snippet;
+  }
+
+  let { color = 'primary', rounded = true, children }: Props = $props();
 
   const colorClasses: Record<Color, string> = {
     primary: 'text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary',
@@ -20,5 +27,5 @@
   class:rounded-md={rounded === true}
   class:rounded-full={rounded === 'full'}
 >
-  <slot />
+  {@render children?.()}
 </span>
diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte
index cdd7463445..7e8418e2f5 100644
--- a/web/src/lib/components/elements/buttons/button.svelte
+++ b/web/src/lib/components/elements/buttons/button.svelte
@@ -1,6 +1,4 @@
-<script lang="ts" context="module">
-  import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
-
+<script lang="ts" module>
   export type Color =
     | 'primary'
     | 'primary-inversed'
@@ -17,44 +15,47 @@
   export type Size = 'tiny' | 'icon' | 'link' | 'sm' | 'base' | 'lg';
   export type Rounded = 'lg' | '3xl' | 'full' | 'none';
   export type Shadow = 'md' | false;
+</script>
 
-  type BaseProps = {
-    class?: string;
+<script lang="ts">
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    type?: string;
+    href?: string;
     color?: Color;
     size?: Size;
     rounded?: Rounded;
     shadow?: Shadow;
     fullwidth?: boolean;
     border?: boolean;
-  };
+    class?: string;
+    children?: Snippet;
+    onclick?: (event: MouseEvent) => void;
+    onfocus?: () => void;
+    onblur?: () => void;
+    form?: string;
+    disabled?: boolean;
+    title?: string;
+    'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | undefined | null;
+  }
 
-  export type ButtonProps = HTMLButtonAttributes &
-    BaseProps & {
-      href?: never;
-    };
-
-  export type LinkProps = HTMLLinkAttributes &
-    BaseProps & {
-      type?: never;
-    };
-
-  export type Props = ButtonProps | LinkProps;
-</script>
-
-<script lang="ts">
-  type $$Props = Props;
-
-  export let type: $$Props['type'] = 'button';
-  export let href: $$Props['href'] = undefined;
-  export let color: Color = 'primary';
-  export let size: Size = 'base';
-  export let rounded: Rounded = '3xl';
-  export let shadow: Shadow = 'md';
-  export let fullwidth = false;
-  export let border = false;
-
-  let className = '';
-  export { className as class };
+  let {
+    type = 'button',
+    href = undefined,
+    color = 'primary',
+    size = 'base',
+    rounded = '3xl',
+    shadow = 'md',
+    fullwidth = false,
+    border = false,
+    class: className = '',
+    children,
+    onclick,
+    onfocus,
+    onblur,
+    ...rest
+  }: Props = $props();
 
   const colorClasses: Record<Color, string> = {
     primary:
@@ -93,29 +94,31 @@
     full: 'rounded-full',
   };
 
-  $: computedClass = [
-    className,
-    colorClasses[color],
-    sizeClasses[size],
-    roundedClasses[rounded],
-    shadow === 'md' && 'shadow-md',
-    fullwidth && 'w-full',
-    border && 'border',
-  ]
-    .filter(Boolean)
-    .join(' ');
+  let computedClass = $derived(
+    [
+      className,
+      colorClasses[color],
+      sizeClasses[size],
+      roundedClasses[rounded],
+      shadow === 'md' && 'shadow-md',
+      fullwidth && 'w-full',
+      border && 'border',
+    ]
+      .filter(Boolean)
+      .join(' '),
+  );
 </script>
 
-<!-- svelte-ignore a11y-no-static-element-interactions -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <svelte:element
   this={href ? 'a' : 'button'}
   type={href ? undefined : type}
   {href}
-  on:click
-  on:focus
-  on:blur
+  {onclick}
+  {onfocus}
+  {onblur}
   class="inline-flex items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 disabled:pointer-events-none {computedClass}"
-  {...$$restProps}
+  {...rest}
 >
-  <slot />
+  {@render children?.()}
 </svelte:element>
diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte
index 8af3f75ade..4b984154f3 100644
--- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte
+++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte
@@ -1,64 +1,64 @@
-<script lang="ts" context="module">
-  import type { HTMLButtonAttributes, HTMLLinkAttributes } from 'svelte/elements';
-
+<script lang="ts" module>
   export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'alert';
   export type Padding = '1' | '2' | '3';
-
-  type BaseProps = {
-    icon: string;
-    title: string;
-    class?: string;
-    color?: Color;
-    padding?: Padding;
-    size?: string;
-    hideMobile?: true;
-    buttonSize?: string;
-    viewBox?: string;
-  };
-
-  export type ButtonProps = HTMLButtonAttributes &
-    BaseProps & {
-      href?: never;
-    };
-
-  export type LinkProps = HTMLLinkAttributes &
-    BaseProps & {
-      type?: never;
-    };
-
-  export type Props = ButtonProps | LinkProps;
 </script>
 
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
 
-  type $$Props = Props;
-
-  export let type: $$Props['type'] = 'button';
-  export let href: $$Props['href'] = undefined;
-  export let icon: string;
-  export let color: Color = 'transparent';
-  export let title: string;
-  /**
-   * The padding of the button, used by the `p-{padding}` Tailwind CSS class.
-   */
-  export let padding: Padding = '3';
-  /**
-   * Size of the button, used for a CSS value.
-   */
-  export let size = '24';
-  export let hideMobile = false;
-  export let buttonSize: string | undefined = undefined;
-  /**
-   * viewBox attribute for the SVG icon.
-   */
-  export let viewBox: string | undefined = undefined;
-
   /**
    * Override the default styling of the button for specific use cases, such as the icon color.
    */
-  let className = '';
-  export { className as class };
+  interface Props {
+    id?: string;
+    type?: string;
+    href?: string;
+    icon: string;
+    color?: Color;
+    title: string;
+    /**
+     * The padding of the button, used by the `p-{padding}` Tailwind CSS class.
+     */
+    padding?: Padding;
+    /**
+     * Size of the button, used for a CSS value.
+     */
+    size?: string;
+    hideMobile?: boolean;
+    buttonSize?: string | undefined;
+    /**
+     * viewBox attribute for the SVG icon.
+     */
+    viewBox?: string | undefined;
+    class?: string;
+
+    'aria-hidden'?: boolean | undefined | null;
+    'aria-checked'?: 'true' | 'false' | undefined | null;
+    'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' | undefined | null;
+    'aria-controls'?: string | undefined | null;
+    'aria-expanded'?: boolean;
+    'aria-haspopup'?: boolean;
+    tabindex?: number | undefined | null;
+    role?: string | undefined | null;
+    onclick: (e: MouseEvent) => void;
+    disabled?: boolean;
+  }
+
+  let {
+    type = 'button',
+    href = undefined,
+    icon,
+    color = 'transparent',
+    title,
+    padding = '3',
+    size = '24',
+    hideMobile = false,
+    buttonSize = undefined,
+    viewBox = undefined,
+    class: className = '',
+    onclick,
+    ...rest
+  }: Props = $props();
 
   const colorClasses: Record<Color, string> = {
     transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
@@ -77,12 +77,12 @@
     '3': 'p-3',
   };
 
-  $: colorClass = colorClasses[color];
-  $: mobileClass = hideMobile ? 'hidden sm:flex' : '';
-  $: paddingClass = paddingClasses[padding];
+  let colorClass = $derived(colorClasses[color]);
+  let mobileClass = $derived(hideMobile ? 'hidden sm:flex' : '');
+  let paddingClass = $derived(paddingClasses[padding]);
 </script>
 
-<!-- svelte-ignore a11y-no-static-element-interactions -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <svelte:element
   this={href ? 'a' : 'button'}
   type={href ? undefined : type}
@@ -91,8 +91,8 @@
   style:width={buttonSize ? buttonSize + 'px' : ''}
   style:height={buttonSize ? buttonSize + 'px' : ''}
   class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all disabled:cursor-default hover:dark:text-immich-dark-gray {className} {mobileClass}"
-  on:click
-  {...$$restProps}
+  {onclick}
+  {...rest}
 >
   <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
 </svelte:element>
diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte
index b8e81f4469..a39e2608cf 100644
--- a/web/src/lib/components/elements/buttons/link-button.svelte
+++ b/web/src/lib/components/elements/buttons/link-button.svelte
@@ -1,22 +1,25 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type Color = 'transparent-primary' | 'transparent-gray';
-
-  type BaseProps = {
-    color?: Color;
-  };
-
-  export type Props = (LinkProps & BaseProps) | (ButtonProps & BaseProps);
 </script>
 
 <script lang="ts">
-  import Button, { type ButtonProps, type LinkProps } from '$lib/components/elements/buttons/button.svelte';
+  import Button from '$lib/components/elements/buttons/button.svelte';
+  import type { Snippet } from 'svelte';
 
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  type $$Props = Props;
+  interface Props {
+    href?: string;
+    color?: Color;
+    children?: Snippet;
+    onclick?: (e: MouseEvent) => void;
+    title?: string;
+    disabled?: boolean;
+    fullwidth?: boolean;
+    class?: string;
+  }
 
-  export let color: Color = 'transparent-gray';
+  let { color = 'transparent-gray', children, ...rest }: Props = $props();
 </script>
 
-<Button size="link" {color} shadow={false} rounded="lg" on:click {...$$restProps}>
-  <slot />
+<Button size="link" {color} shadow={false} rounded="lg" {...rest}>
+  {@render children?.()}
 </Button>
diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte
index d1ad667379..858d296c30 100644
--- a/web/src/lib/components/elements/buttons/skip-link.svelte
+++ b/web/src/lib/components/elements/buttons/skip-link.svelte
@@ -2,13 +2,17 @@
   import { t } from 'svelte-i18n';
   import Button from './button.svelte';
 
-  /**
-   * Target for the skip link to move focus to.
-   */
-  export let target: string = 'main';
-  export let text: string = $t('skip_to_content');
+  interface Props {
+    /**
+     * Target for the skip link to move focus to.
+     */
+    target?: string;
+    text?: string;
+  }
 
-  let isFocused = false;
+  let { target = 'main', text = $t('skip_to_content') }: Props = $props();
+
+  let isFocused = $state(false);
 
   const moveFocus = () => {
     const targetEl = document.querySelector<HTMLElement>(target);
@@ -20,9 +24,9 @@
   <Button
     size={'sm'}
     rounded="none"
-    on:click={moveFocus}
-    on:focus={() => (isFocused = true)}
-    on:blur={() => (isFocused = false)}
+    onclick={moveFocus}
+    onfocus={() => (isFocused = true)}
+    onblur={() => (isFocused = false)}
   >
     {text}
   </Button>
diff --git a/web/src/lib/components/elements/checkbox.svelte b/web/src/lib/components/elements/checkbox.svelte
index 3407262551..4595c06bfb 100644
--- a/web/src/lib/components/elements/checkbox.svelte
+++ b/web/src/lib/components/elements/checkbox.svelte
@@ -1,11 +1,25 @@
 <script lang="ts">
-  export let id: string;
-  export let label: string;
-  export let checked: boolean | undefined = undefined;
-  export let disabled: boolean = false;
-  export let labelClass: string | undefined = undefined;
-  export let name: string | undefined = undefined;
-  export let value: string | undefined = undefined;
+  interface Props {
+    id: string;
+    label: string;
+    checked?: boolean | undefined;
+    disabled?: boolean;
+    labelClass?: string | undefined;
+    name?: string | undefined;
+    value?: string | undefined;
+    onchange?: () => void;
+  }
+
+  let {
+    id,
+    label,
+    checked = $bindable(),
+    disabled = false,
+    labelClass = undefined,
+    name = undefined,
+    value = undefined,
+    onchange = () => {},
+  }: Props = $props();
 </script>
 
 <div class="flex items-center space-x-2">
@@ -17,7 +31,7 @@
     {disabled}
     class="size-5 flex-shrink-0 focus-visible:ring"
     bind:checked
-    on:change
+    {onchange}
   />
   <label class={labelClass} for={id}>{label}</label>
 </div>
diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte
index f42fff4359..687e9442e7 100644
--- a/web/src/lib/components/elements/date-input.svelte
+++ b/web/src/lib/components/elements/date-input.svelte
@@ -1,29 +1,35 @@
 <script lang="ts">
-  import type { HTMLInputAttributes } from 'svelte/elements';
-
-  interface $$Props extends HTMLInputAttributes {
+  interface Props {
     type: 'date' | 'datetime-local';
+    value?: string;
+    min?: string;
+    max?: string;
+    class?: string;
+    id?: string;
+    name?: string;
+    placeholder?: string;
   }
 
-  export let type: $$Props['type'];
-  export let value: $$Props['value'] = undefined;
-  export let max: $$Props['max'] = undefined;
+  let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
 
-  $: fallbackMax = type === 'date' ? '9999-12-31' : '9999-12-31T23:59';
+  let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
 
   // Updating `value` directly causes the date input to reset itself or
   // interfere with user changes.
-  $: updatedValue = value;
+  let updatedValue = $state<string>();
+  $effect(() => {
+    updatedValue = value;
+  });
 </script>
 
 <input
-  {...$$restProps}
+  {...rest}
   {type}
   {value}
   max={max || fallbackMax}
-  on:input={(e) => (updatedValue = e.currentTarget.value)}
-  on:blur={() => (value = updatedValue)}
-  on:keydown={(e) => {
+  oninput={(e) => (updatedValue = e.currentTarget.value)}
+  onblur={() => (value = updatedValue)}
+  onkeydown={(e) => {
     if (e.key === 'Enter') {
       value = updatedValue;
     }
diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte
index 80689ef1fe..b146f347dc 100644
--- a/web/src/lib/components/elements/dropdown.svelte
+++ b/web/src/lib/components/elements/dropdown.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   // Necessary for eslint
   /* eslint-disable @typescript-eslint/no-explicit-any */
   type T = any;
@@ -20,19 +20,31 @@
   import { clickOutside } from '$lib/actions/click-outside';
   import { fly } from 'svelte/transition';
 
-  let className = '';
-  export { className as class };
+  interface Props {
+    class?: string;
+    options: T[];
+    selectedOption?: any;
+    showMenu?: boolean;
+    controlable?: boolean;
+    hideTextOnSmallScreen?: boolean;
+    title?: string | undefined;
+    onSelect: (option: T) => void;
+    onClickOutside?: () => void;
+    render?: (item: T) => string | RenderedOption;
+  }
 
-  export let options: T[];
-  export let selectedOption = options[0];
-  export let showMenu = false;
-  export let controlable = false;
-  export let hideTextOnSmallScreen = true;
-  export let title: string | undefined = undefined;
-  export let onSelect: (option: T) => void;
-  export let onClickOutside: () => void = () => {};
-
-  export let render: (item: T) => string | RenderedOption = String;
+  let {
+    class: className = '',
+    options,
+    selectedOption = $bindable(options[0]),
+    showMenu = $bindable(false),
+    controlable = false,
+    hideTextOnSmallScreen = true,
+    title = undefined,
+    onSelect,
+    onClickOutside = () => {},
+    render = String,
+  }: Props = $props();
 
   const handleClickOutside = () => {
     if (!controlable) {
@@ -65,12 +77,12 @@
     }
   };
 
-  $: renderedSelectedOption = renderOption(selectedOption);
+  let renderedSelectedOption = $derived(renderOption(selectedOption));
 </script>
 
 <div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}>
   <!-- BUTTON TITLE -->
-  <LinkButton on:click={() => (showMenu = true)} fullwidth {title}>
+  <LinkButton onclick={() => (showMenu = true)} fullwidth {title}>
     <div class="flex place-items-center gap-2 text-sm">
       {#if renderedSelectedOption?.icon}
         <Icon path={renderedSelectedOption.icon} size="18" />
@@ -92,7 +104,7 @@
           type="button"
           class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
           disabled={renderedOption.disabled}
-          on:click={() => !renderedOption.disabled && handleSelectOption(option)}
+          onclick={() => !renderedOption.disabled && handleSelectOption(option)}
         >
           {#if isEqual(selectedOption, option)}
             <div class="text-immich-primary dark:text-immich-dark-primary">
diff --git a/web/src/lib/components/elements/group-tab.svelte b/web/src/lib/components/elements/group-tab.svelte
index f5e2f79350..021d5ca96f 100644
--- a/web/src/lib/components/elements/group-tab.svelte
+++ b/web/src/lib/components/elements/group-tab.svelte
@@ -1,10 +1,14 @@
 <script lang="ts">
   import { generateId } from '$lib/utils/generate-id';
 
-  export let filters: string[];
-  export let selected: string;
-  export let label: string;
-  export let onSelect: (selected: string) => void;
+  interface Props {
+    filters: string[];
+    selected: string;
+    label: string;
+    onSelect: (selected: string) => void;
+  }
+
+  let { filters, selected, label, onSelect }: Props = $props();
 
   const id = `group-tab-${generateId()}`;
 </script>
@@ -22,7 +26,7 @@
         class="peer sr-only"
         value={filter}
         checked={filter === selected}
-        on:change={() => onSelect(filter)}
+        onchange={() => onSelect(filter)}
       />
       <label
         for="{id}-{index}"
diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte
index 5965928718..4bc55b3247 100644
--- a/web/src/lib/components/elements/icon.svelte
+++ b/web/src/lib/components/elements/icon.svelte
@@ -1,22 +1,41 @@
 <script lang="ts">
   import type { AriaRole } from 'svelte/elements';
 
-  export let size: string | number = '1em';
-  export let color = 'currentColor';
-  export let path: string;
-  export let title: string | null = null;
-  export let desc = '';
-  export let flipped = false;
-  let className = '';
-  export { className as class };
-  export let viewBox = '0 0 24 24';
-  export let role: AriaRole = 'img';
-  export let ariaHidden: boolean | undefined = undefined;
-  export let ariaLabel: string | undefined = undefined;
-  export let ariaLabelledby: string | undefined = undefined;
-  export let strokeWidth: number = 0;
-  export let strokeColor: string = 'currentColor';
-  export let spin = false;
+  interface Props {
+    size?: string | number;
+    color?: string;
+    path: string;
+    title?: string | null;
+    desc?: string;
+    flipped?: boolean;
+    class?: string;
+    viewBox?: string;
+    role?: AriaRole;
+    ariaHidden?: boolean | undefined;
+    ariaLabel?: string | undefined;
+    ariaLabelledby?: string | undefined;
+    strokeWidth?: number;
+    strokeColor?: string;
+    spin?: boolean;
+  }
+
+  let {
+    size = '1em',
+    color = 'currentColor',
+    path,
+    title = null,
+    desc = '',
+    flipped = false,
+    class: className = '',
+    viewBox = '0 0 24 24',
+    role = 'img',
+    ariaHidden = undefined,
+    ariaLabel = undefined,
+    ariaLabelledby = undefined,
+    strokeWidth = 0,
+    strokeColor = 'currentColor',
+    spin = false,
+  }: Props = $props();
 </script>
 
 <svg
diff --git a/web/src/lib/components/elements/radio-button.svelte b/web/src/lib/components/elements/radio-button.svelte
index a3c47e5fbc..1d110ff644 100644
--- a/web/src/lib/components/elements/radio-button.svelte
+++ b/web/src/lib/components/elements/radio-button.svelte
@@ -1,9 +1,13 @@
 <script lang="ts">
-  export let id: string;
-  export let label: string;
-  export let name: string;
-  export let value: string;
-  export let group: string | undefined = undefined;
+  interface Props {
+    id: string;
+    label: string;
+    name: string;
+    value: string;
+    group?: string | undefined;
+  }
+
+  let { id, label, name, value, group = $bindable(undefined) }: Props = $props();
 </script>
 
 <div class="flex items-center space-x-2">
diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
index 7668152d35..c852be3b68 100644
--- a/web/src/lib/components/elements/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -5,14 +5,25 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let name: string;
-  export let roundedBottom = true;
-  export let showLoadingSpinner: boolean;
-  export let placeholder: string;
-  export let onSearch: (options: SearchOptions) => void = () => {};
-  export let onReset: () => void = () => {};
+  interface Props {
+    name: string;
+    roundedBottom?: boolean;
+    showLoadingSpinner: boolean;
+    placeholder: string;
+    onSearch?: (options: SearchOptions) => void;
+    onReset?: () => void;
+  }
 
-  let inputRef: HTMLElement;
+  let {
+    name = $bindable(),
+    roundedBottom = true,
+    showLoadingSpinner,
+    placeholder,
+    onSearch = () => {},
+    onReset = () => {},
+  }: Props = $props();
+
+  let inputRef = $state<HTMLElement>();
 
   const resetSearch = () => {
     name = '';
@@ -37,7 +48,7 @@
     title={$t('search')}
     size="16"
     padding="2"
-    on:click={() => onSearch({ force: true })}
+    onclick={() => onSearch({ force: true })}
   />
   <input
     class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
@@ -45,8 +56,8 @@
     {placeholder}
     bind:value={name}
     bind:this={inputRef}
-    on:keydown={handleSearch}
-    on:input={() => onSearch({ force: false })}
+    onkeydown={handleSearch}
+    oninput={() => onSearch({ force: false })}
   />
   {#if showLoadingSpinner}
     <div class="flex place-items-center">
@@ -54,6 +65,6 @@
     </div>
   {/if}
   {#if name}
-    <CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" on:click={resetSearch} />
+    <CircleIconButton icon={mdiClose} title={$t('clear_value')} size="16" padding="2" onclick={resetSearch} />
   {/if}
 </div>
diff --git a/web/src/lib/components/elements/slider.svelte b/web/src/lib/components/elements/slider.svelte
index 4c19696372..5c80eb2a9e 100644
--- a/web/src/lib/components/elements/slider.svelte
+++ b/web/src/lib/components/elements/slider.svelte
@@ -1,15 +1,25 @@
 <script lang="ts">
-  /**
-   * Unique identifier for the checkbox element, used to associate labels with the input element.
-   */
-  export let id: string;
-  /**
-   * Optional aria-describedby attribute to associate the checkbox with a description.
-   */
-  export let ariaDescribedBy: string | undefined = undefined;
-  export let checked = false;
-  export let disabled = false;
-  export let onToggle: ((checked: boolean) => void) | undefined = undefined;
+  interface Props {
+    /**
+     * Unique identifier for the checkbox element, used to associate labels with the input element.
+     */
+    id: string;
+    /**
+     * Optional aria-describedby attribute to associate the checkbox with a description.
+     */
+    ariaDescribedBy?: string | undefined;
+    checked?: boolean;
+    disabled?: boolean;
+    onToggle?: ((checked: boolean) => void) | undefined;
+  }
+
+  let {
+    id,
+    ariaDescribedBy = undefined,
+    checked = $bindable(false),
+    disabled = false,
+    onToggle = undefined,
+  }: Props = $props();
 
   const handleToggle = (event: Event) => onToggle?.((event.target as HTMLInputElement).checked);
 </script>
@@ -20,7 +30,7 @@
     class="disabled::cursor-not-allowed h-0 w-0 opacity-0 peer"
     type="checkbox"
     bind:checked
-    on:click={handleToggle}
+    onclick={handleToggle}
     {disabled}
     aria-describedby={ariaDescribedBy}
   />
diff --git a/web/src/lib/components/error.svelte b/web/src/lib/components/error.svelte
index cbc8c26bd8..54466b5a55 100644
--- a/web/src/lib/components/error.svelte
+++ b/web/src/lib/components/error.svelte
@@ -6,7 +6,11 @@
   import { mdiCodeTags, mdiContentCopy, mdiMessage, mdiPartyPopper } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let error: { message: string; code?: string | number; stack?: string } | undefined | null = undefined;
+  interface Props {
+    error?: { message: string; code?: string | number; stack?: string } | undefined | null;
+  }
+
+  let { error = undefined }: Props = $props();
 
   const handleCopy = async () => {
     if (!error) {
@@ -41,7 +45,7 @@
                 color="primary"
                 icon={mdiContentCopy}
                 title={$t('copy_error')}
-                on:click={() => handleCopy()}
+                onclick={() => handleCopy()}
               />
             </div>
           </div>
diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
index ce184321e3..b6c9beb43a 100644
--- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte
+++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte
@@ -14,24 +14,28 @@
   import { zoomImageToBase64 } from '$lib/utils/people-utils';
   import { t } from 'svelte-i18n';
 
-  export let allPeople: PersonResponseDto[];
-  export let editedFace: AssetFaceResponseDto;
-  export let assetId: string;
-  export let assetType: AssetTypeEnum;
-  export let onClose: () => void;
-  export let onCreatePerson: (featurePhoto: string | null) => void;
-  export let onReassign: (person: PersonResponseDto) => void;
+  interface Props {
+    allPeople: PersonResponseDto[];
+    editedFace: AssetFaceResponseDto;
+    assetId: string;
+    assetType: AssetTypeEnum;
+    onClose: () => void;
+    onCreatePerson: (featurePhoto: string | null) => void;
+    onReassign: (person: PersonResponseDto) => void;
+  }
+
+  let { allPeople, editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props();
 
   // loading spinners
-  let isShowLoadingNewPerson = false;
-  let isShowLoadingSearch = false;
+  let isShowLoadingNewPerson = $state(false);
+  let isShowLoadingSearch = $state(false);
 
   // search people
-  let searchedPeople: PersonResponseDto[] = [];
-  let searchFaces = false;
-  let searchName = '';
+  let searchedPeople: PersonResponseDto[] = $state([]);
+  let searchFaces = $state(false);
+  let searchName = $state('');
 
-  $: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
+  let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden));
 
   const handleCreatePerson = async () => {
     const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
@@ -53,19 +57,19 @@
   <div class="flex place-items-center justify-between gap-2">
     {#if !searchFaces}
       <div class="flex items-center gap-2">
-        <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} />
+        <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} />
         <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p>
       </div>
       <div class="flex justify-end gap-2">
         <CircleIconButton
           icon={mdiMagnify}
           title={$t('search_for_existing_person')}
-          on:click={() => {
+          onclick={() => {
             searchFaces = true;
           }}
         />
         {#if !isShowLoadingNewPerson}
-          <CircleIconButton icon={mdiPlus} title={$t('create_new_person')} on:click={handleCreatePerson} />
+          <CircleIconButton icon={mdiPlus} title={$t('create_new_person')} onclick={handleCreatePerson} />
         {:else}
           <div class="flex place-content-center place-items-center">
             <LoadingSpinner />
@@ -73,7 +77,7 @@
         {/if}
       </div>
     {:else}
-      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} />
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} />
       <div class="w-full flex">
         <SearchPeople
           type="input"
@@ -87,7 +91,7 @@
           </div>
         {/if}
       </div>
-      <CircleIconButton icon={mdiClose} title={$t('cancel_search')} on:click={() => (searchFaces = false)} />
+      <CircleIconButton icon={mdiClose} title={$t('cancel_search')} onclick={() => (searchFaces = false)} />
     {/if}
   </div>
   <div class="px-4 py-4 text-sm">
@@ -96,7 +100,7 @@
       {#each showPeople as person (person.id)}
         {#if !editedFace.person || person.id !== editedFace.person.id}
           <div class="w-fit">
-            <button type="button" class="w-[90px]" on:click={() => onReassign(person)}>
+            <button type="button" class="w-[90px]" onclick={() => onReassign(person)}>
               <div class="relative">
                 <ImageThumbnail
                   curve
diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte
index d9e961c13f..ebb44c4008 100644
--- a/web/src/lib/components/faces-page/edit-name-input.svelte
+++ b/web/src/lib/components/faces-page/edit-name-input.svelte
@@ -5,25 +5,37 @@
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
   import { t } from 'svelte-i18n';
 
-  export let person: PersonResponseDto;
-  export let name: string;
-  export let suggestedPeople: PersonResponseDto[];
-  export let thumbnailData: string;
-  export let isSearchingPeople: boolean;
-  export let onChange: (name: string) => void;
+  interface Props {
+    person: PersonResponseDto;
+    name: string;
+    suggestedPeople: PersonResponseDto[];
+    thumbnailData: string;
+    isSearchingPeople: boolean;
+    onChange: (name: string) => void;
+  }
+
+  let {
+    person,
+    name = $bindable(),
+    suggestedPeople = $bindable(),
+    thumbnailData,
+    isSearchingPeople = $bindable(),
+    onChange,
+  }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onChange(name);
+  };
 </script>
 
 <div
   class="flex w-full h-14 place-items-center {suggestedPeople.length > 0
     ? 'rounded-t-lg dark:border-immich-dark-gray'
-    : 'rounded-lg'}  bg-gray-100 p-2 dark:bg-gray-700"
+    : 'rounded-lg'}  bg-gray-100 p-2 dark:bg-gray-700 border border-gray-200 dark:border-immich-dark-gray"
 >
   <ImageThumbnail circle shadow url={thumbnailData} altText={person.name} widthStyle="2rem" heightStyle="2rem" />
-  <form
-    class="ml-4 flex w-full justify-between gap-16"
-    autocomplete="off"
-    on:submit|preventDefault={() => onChange(name)}
-  >
+  <form class="ml-4 flex w-full justify-between gap-16" autocomplete="off" {onsubmit}>
     <SearchPeople
       bind:searchName={name}
       bind:searchedPeopleLocal={suggestedPeople}
diff --git a/web/src/lib/components/faces-page/face-thumbnail.svelte b/web/src/lib/components/faces-page/face-thumbnail.svelte
index cce91b4669..cc3fffe5d7 100644
--- a/web/src/lib/components/faces-page/face-thumbnail.svelte
+++ b/web/src/lib/components/faces-page/face-thumbnail.svelte
@@ -3,19 +3,31 @@
   import { type PersonResponseDto } from '@immich/sdk';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
 
-  export let person: PersonResponseDto;
-  export let selectable = false;
-  export let selected = false;
-  export let thumbnailSize: number | null = null;
-  export let circle = false;
-  export let border = false;
-  export let onClick: (person: PersonResponseDto) => void = () => {};
+  interface Props {
+    person: PersonResponseDto;
+    selectable?: boolean;
+    selected?: boolean;
+    thumbnailSize?: number | null;
+    circle?: boolean;
+    border?: boolean;
+    onClick?: (person: PersonResponseDto) => void;
+  }
+
+  let {
+    person,
+    selectable = false,
+    selected = false,
+    thumbnailSize = null,
+    circle = false,
+    border = false,
+    onClick = () => {},
+  }: Props = $props();
 </script>
 
 <button
   type="button"
   class="relative rounded-lg transition-all"
-  on:click={() => onClick(person)}
+  onclick={() => onClick(person)}
   disabled={!selectable}
   style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'}
   style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'}
diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte
index 90e20a1e5b..295f629736 100644
--- a/web/src/lib/components/faces-page/manage-people-visibility.svelte
+++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   const enum ToggleVisibility {
     HIDE_ALL = 'hide-all',
     HIDE_UNNANEMD = 'hide-unnamed',
@@ -24,17 +24,18 @@
   import { t } from 'svelte-i18n';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
 
-  export let people: PersonResponseDto[];
-  export let totalPeopleCount: number;
-  export let titleId: string | undefined = undefined;
-  export let onClose: () => void;
-  export let loadNextPage: () => void;
+  interface Props {
+    people: PersonResponseDto[];
+    totalPeopleCount: number;
+    titleId?: string | undefined;
+    onClose: () => void;
+    loadNextPage: () => void;
+  }
 
-  let toggleVisibility = ToggleVisibility.SHOW_ALL;
-  let showLoadingSpinner = false;
+  let { people = $bindable(), totalPeopleCount, titleId = undefined, onClose, loadNextPage }: Props = $props();
 
-  $: personIsHidden = getPersonIsHidden(people);
-  $: toggleButton = toggleButtonOptions[getNextVisibility(toggleVisibility)];
+  let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
+  let showLoadingSpinner = $state(false);
 
   const getPersonIsHidden = (people: PersonResponseDto[]) => {
     const personIsHidden: Record<string, boolean> = {};
@@ -44,16 +45,6 @@
     return personIsHidden;
   };
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  // svelte-ignore reactive_declaration_module_script_dependency
-  $: toggleButtonOptions = ((): Record<ToggleVisibility, { icon: string; label: string }> => {
-    return {
-      [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
-      [ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
-      [ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') },
-    };
-  })();
-
   const getNextVisibility = (toggleVisibility: ToggleVisibility) => {
     if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
       return ToggleVisibility.HIDE_UNNANEMD;
@@ -115,6 +106,15 @@
       showLoadingSpinner = false;
     }
   };
+
+  let personIsHidden = $state(getPersonIsHidden(people));
+
+  let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
+    [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
+    [ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
+    [ToggleVisibility.SHOW_ALL]: { icon: mdiEye, label: $t('show_all_people') },
+  });
+  let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
@@ -123,7 +123,7 @@
   class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
 >
   <div class="flex items-center">
-    <CircleIconButton title={$t('close')} icon={mdiClose} on:click={onClose} />
+    <CircleIconButton title={$t('close')} icon={mdiClose} onclick={onClose} />
     <div class="flex gap-2 items-center">
       <p id={titleId} class="ml-2">{$t('show_and_hide_people')}</p>
       <p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
@@ -131,11 +131,11 @@
   </div>
   <div class="flex items-center justify-end">
     <div class="flex items-center md:mr-4">
-      <CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} on:click={handleResetVisibility} />
-      <CircleIconButton title={toggleButton.label} icon={toggleButton.icon} on:click={handleToggleVisibility} />
+      <CircleIconButton title={$t('reset_people_visibility')} icon={mdiRestart} onclick={handleResetVisibility} />
+      <CircleIconButton title={toggleButton.label} icon={toggleButton.icon} onclick={handleToggleVisibility} />
     </div>
     {#if !showLoadingSpinner}
-      <Button on:click={handleSaveVisibility} size="sm" rounded="lg">{$t('done')}</Button>
+      <Button onclick={handleSaveVisibility} size="sm" rounded="lg">{$t('done')}</Button>
     {:else}
       <LoadingSpinner />
     {/if}
@@ -143,29 +143,31 @@
 </div>
 
 <div class="flex flex-wrap gap-1 bg-immich-bg p-2 pb-8 dark:bg-immich-dark-bg md:px-8 mt-16">
-  <PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage} let:person let:index>
-    {@const hidden = personIsHidden[person.id]}
-    <button
-      type="button"
-      class="group relative w-full h-full"
-      on:click={() => (personIsHidden[person.id] = !hidden)}
-      aria-pressed={hidden}
-      aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
-    >
-      <ImageThumbnail
-        preload={index < 20}
-        {hidden}
-        shadow
-        url={getPeopleThumbnailUrl(person)}
-        altText={person.name}
-        widthStyle="100%"
-        hiddenIconClass="text-white group-hover:text-black transition-colors"
-      />
-      {#if person.name}
-        <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
-          {person.name}
-        </span>
-      {/if}
-    </button>
+  <PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
+    {#snippet children({ person, index })}
+      {@const hidden = personIsHidden[person.id]}
+      <button
+        type="button"
+        class="group relative w-full h-full"
+        onclick={() => (personIsHidden[person.id] = !hidden)}
+        aria-pressed={hidden}
+        aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
+      >
+        <ImageThumbnail
+          preload={index < 20}
+          {hidden}
+          shadow
+          url={getPeopleThumbnailUrl(person)}
+          altText={person.name}
+          widthStyle="100%"
+          hiddenIconClass="text-white group-hover:text-black transition-colors"
+        />
+        {#if person.name}
+          <span class="absolute bottom-2 left-0 w-full select-text px-1 text-center font-medium text-white">
+            {person.name}
+          </span>
+        {/if}
+      </button>
+    {/snippet}
   </PeopleInfiniteScroll>
 </div>
diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte
index 52daa36a99..c638691080 100644
--- a/web/src/lib/components/faces-page/merge-face-selector.svelte
+++ b/web/src/lib/components/faces-page/merge-face-selector.svelte
@@ -19,16 +19,20 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let person: PersonResponseDto;
-  export let onBack: () => void;
-  export let onMerge: (mergedPerson: PersonResponseDto) => void;
+  interface Props {
+    person: PersonResponseDto;
+    onBack: () => void;
+    onMerge: (mergedPerson: PersonResponseDto) => void;
+  }
 
-  let people: PersonResponseDto[] = [];
-  let selectedPeople: PersonResponseDto[] = [];
-  let screenHeight: number;
+  let { person = $bindable(), onBack, onMerge }: Props = $props();
 
-  $: hasSelection = selectedPeople.length > 0;
-  $: peopleToNotShow = [...selectedPeople, person];
+  let people: PersonResponseDto[] = $state([]);
+  let selectedPeople: PersonResponseDto[] = $state([]);
+  let screenHeight: number = $state(0);
+
+  let hasSelection = $derived(selectedPeople.length > 0);
+  let peopleToNotShow = $derived([...selectedPeople, person]);
 
   onMount(async () => {
     const data = await getAllPeople({ withHidden: false });
@@ -96,20 +100,20 @@
   class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
 >
   <ControlAppBar onClose={onBack}>
-    <svelte:fragment slot="leading">
+    {#snippet leading()}
       {#if hasSelection}
         {$t('selected_count', { values: { count: selectedPeople.length } })}
       {:else}
         {$t('merge_people')}
       {/if}
       <div></div>
-    </svelte:fragment>
-    <svelte:fragment slot="trailing">
-      <Button size={'sm'} disabled={!hasSelection} on:click={handleMerge}>
+    {/snippet}
+    {#snippet trailing()}
+      <Button size={'sm'} disabled={!hasSelection} onclick={handleMerge}>
         <Icon path={mdiMerge} size={18} />
         <span class="ml-2">{$t('merge')}</span></Button
       >
-    </svelte:fragment>
+    {/snippet}
   </ControlAppBar>
   <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
     <section id="merge-face-selector relative">
@@ -135,7 +139,7 @@
                       title={$t('swap_merge_direction')}
                       icon={mdiSwapHorizontal}
                       size="24"
-                      on:click={handleSwapPeople}
+                      onclick={handleSwapPeople}
                     />
                   </div>
                 {/if}
diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
index f869790eba..a4ac76f198 100644
--- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
+++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
@@ -9,14 +9,25 @@
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let personMerge1: PersonResponseDto;
-  export let personMerge2: PersonResponseDto;
-  export let potentialMergePeople: PersonResponseDto[];
-  export let onReject: () => void;
-  export let onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void;
-  export let onClose: () => void;
+  interface Props {
+    personMerge1: PersonResponseDto;
+    personMerge2: PersonResponseDto;
+    potentialMergePeople: PersonResponseDto[];
+    onReject: () => void;
+    onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void;
+    onClose: () => void;
+  }
 
-  let choosePersonToMerge = false;
+  let {
+    personMerge1 = $bindable(),
+    personMerge2 = $bindable(),
+    potentialMergePeople = $bindable(),
+    onReject,
+    onConfirm,
+    onClose,
+  }: Props = $props();
+
+  let choosePersonToMerge = $state(false);
 
   const title = personMerge2.name;
 
@@ -43,7 +54,7 @@
         <CircleIconButton
           title={$t('swap_merge_direction')}
           icon={mdiMerge}
-          on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
+          onclick={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
         />
       </div>
 
@@ -51,7 +62,7 @@
         type="button"
         disabled={potentialMergePeople.length === 0}
         class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
-        on:click={() => {
+        onclick={() => {
           if (potentialMergePeople.length > 0) {
             choosePersonToMerge = !choosePersonToMerge;
           }
@@ -69,13 +80,13 @@
     {:else}
       <div class="grid w-full grid-cols-1 gap-2">
         <div class="px-2">
-          <button type="button" on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
+          <button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
         </div>
         <div class="flex items-center justify-center">
           <div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
             {#each potentialMergePeople as person (person.id)}
               <div class="h-24 w-24 md:h-28 md:w-28">
-                <button type="button" class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
+                <button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}>
                   <ImageThumbnail
                     border={true}
                     circle
@@ -83,7 +94,7 @@
                     url={getPeopleThumbnailUrl(person)}
                     altText={person.name}
                     widthStyle="100%"
-                    on:click={() => changePersonToMerge(person)}
+                    onClick={() => changePersonToMerge(person)}
                   />
                 </button>
               </div>
@@ -100,8 +111,9 @@
   <div class="flex px-4 pt-2">
     <p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p>
   </div>
-  <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth color="gray" on:click={onReject}>{$t('no')}</Button>
-    <Button fullwidth on:click={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button>
-  </svelte:fragment>
+
+  {#snippet stickyBottom()}
+    <Button fullwidth color="gray" onclick={onReject}>{$t('no')}</Button>
+    <Button fullwidth onclick={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte
index 6791a26232..a83d1180f9 100644
--- a/web/src/lib/components/faces-page/people-card.svelte
+++ b/web/src/lib/components/faces-page/people-card.svelte
@@ -15,28 +15,32 @@
   import { focusOutside } from '$lib/actions/focus-outside';
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 
-  export let person: PersonResponseDto;
-  export let preload = false;
-  export let onChangeName: () => void;
-  export let onSetBirthDate: () => void;
-  export let onMergePeople: () => void;
-  export let onHidePerson: () => void;
+  interface Props {
+    person: PersonResponseDto;
+    preload?: boolean;
+    onChangeName: () => void;
+    onSetBirthDate: () => void;
+    onMergePeople: () => void;
+    onHidePerson: () => void;
+  }
 
-  let showVerticalDots = false;
+  let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
+
+  let showVerticalDots = $state(false);
 </script>
 
 <div
   id="people-card"
   class="relative"
-  on:mouseenter={() => (showVerticalDots = true)}
-  on:mouseleave={() => (showVerticalDots = false)}
+  onmouseenter={() => (showVerticalDots = true)}
+  onmouseleave={() => (showVerticalDots = false)}
   role="group"
   use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
 >
   <a
     href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
     draggable="false"
-    on:focus={() => (showVerticalDots = true)}
+    onfocus={() => (showVerticalDots = true)}
   >
     <div class="w-full h-full rounded-xl brightness-95 filter">
       <ImageThumbnail
diff --git a/web/src/lib/components/faces-page/people-infinite-scroll.svelte b/web/src/lib/components/faces-page/people-infinite-scroll.svelte
index aefd6fe957..0de084c4b2 100644
--- a/web/src/lib/components/faces-page/people-infinite-scroll.svelte
+++ b/web/src/lib/components/faces-page/people-infinite-scroll.svelte
@@ -1,11 +1,16 @@
 <script lang="ts">
   import type { PersonResponseDto } from '@immich/sdk';
 
-  export let people: PersonResponseDto[];
-  export let hasNextPage: boolean | undefined = undefined;
-  export let loadNextPage: () => void;
+  interface Props {
+    people: PersonResponseDto[];
+    hasNextPage?: boolean | undefined;
+    loadNextPage: () => void;
+    children?: import('svelte').Snippet<[{ person: PersonResponseDto; index: number }]>;
+  }
 
-  let lastPersonContainer: HTMLElement | undefined;
+  let { people, hasNextPage = undefined, loadNextPage, children }: Props = $props();
+
+  let lastPersonContainer: HTMLElement | undefined = $state();
 
   const intersectionObserver = new IntersectionObserver((entries) => {
     const entry = entries.find((entry) => entry.target === lastPersonContainer);
@@ -14,20 +19,22 @@
     }
   });
 
-  $: if (lastPersonContainer) {
-    intersectionObserver.disconnect();
-    intersectionObserver.observe(lastPersonContainer);
-  }
+  $effect(() => {
+    if (lastPersonContainer) {
+      intersectionObserver.disconnect();
+      intersectionObserver.observe(lastPersonContainer);
+    }
+  });
 </script>
 
 <div class="w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-9 gap-1">
   {#each people as person, index (person.id)}
     {#if hasNextPage && index === people.length - 1}
       <div bind:this={lastPersonContainer}>
-        <slot {person} {index} />
+        {@render children?.({ person, index })}
       </div>
     {:else}
-      <slot {person} {index} />
+      {@render children?.({ person, index })}
     {/if}
   {/each}
 </div>
diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte
index 10626a6a93..511792e536 100644
--- a/web/src/lib/components/faces-page/people-list.svelte
+++ b/web/src/lib/components/faces-page/people-list.svelte
@@ -4,22 +4,24 @@
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
   import { t } from 'svelte-i18n';
 
-  export let screenHeight: number;
-  export let people: PersonResponseDto[];
-  export let peopleToNotShow: PersonResponseDto[];
-  export let onSelect: (person: PersonResponseDto) => void;
-
-  let searchedPeopleLocal: PersonResponseDto[] = [];
-
-  let name = '';
-  let showPeople: PersonResponseDto[];
-
-  $: {
-    showPeople = name ? searchedPeopleLocal : people;
-    showPeople = showPeople.filter(
-      (person) => !peopleToNotShow.some((unselectedPerson) => unselectedPerson.id === person.id),
-    );
+  interface Props {
+    screenHeight: number;
+    people: PersonResponseDto[];
+    peopleToNotShow: PersonResponseDto[];
+    onSelect: (person: PersonResponseDto) => void;
   }
+
+  let { screenHeight, people, peopleToNotShow, onSelect }: Props = $props();
+
+  let searchedPeopleLocal: PersonResponseDto[] = $state([]);
+
+  let name = $state('');
+
+  const showPeople = $derived(
+    (name ? searchedPeopleLocal : people).filter(
+      (person) => !peopleToNotShow.some((unselectedPerson) => unselectedPerson.id === person.id),
+    ),
+  );
 </script>
 
 <div class=" w-40 sm:w-48 md:w-96 h-14 mb-8">
diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte
index 2a952b8145..835f4188c4 100644
--- a/web/src/lib/components/faces-page/people-search.svelte
+++ b/web/src/lib/components/faces-page/people-search.svelte
@@ -7,16 +7,6 @@
   import { searchPerson, type PersonResponseDto } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let searchName: string;
-  export let searchedPeopleLocal: PersonResponseDto[];
-  export let type: 'searchBar' | 'input';
-  export let numberPeopleToSearch: number = maximumLengthSearchPeople;
-  export let inputClass: string = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg';
-  export let showLoadingSpinner: boolean = false;
-  export let placeholder: string = $t('name_or_nickname');
-  export let onReset = () => {};
-  export let onSearch = () => {};
-
   let searchedPeople: PersonResponseDto[] = [];
   let searchWord: string;
   let abortController: AbortController | null = null;
@@ -43,7 +33,36 @@
     }
   };
 
-  export let handleSearch = async (force?: boolean, name?: string) => {
+  interface Props {
+    searchName: string;
+    searchedPeopleLocal: PersonResponseDto[];
+    type: 'searchBar' | 'input';
+    numberPeopleToSearch?: number;
+    inputClass?: string;
+    showLoadingSpinner?: boolean;
+    placeholder?: string;
+    onReset?: () => void;
+    onSearch?: () => void;
+  }
+
+  let {
+    searchName = $bindable(),
+    searchedPeopleLocal = $bindable(),
+    type,
+    numberPeopleToSearch = maximumLengthSearchPeople,
+    inputClass = 'w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg',
+    showLoadingSpinner = $bindable(false),
+    placeholder = $t('name_or_nickname'),
+    onReset = () => {},
+    onSearch = () => {},
+  }: Props = $props();
+
+  const handleReset = () => {
+    reset();
+    onReset();
+  };
+
+  export async function searchPeople(force?: boolean, name?: string) {
     searchName = name ?? searchName;
     onSearch();
     if (searchName === '') {
@@ -70,12 +89,7 @@
       showLoadingSpinner = false;
       search();
     }
-  };
-
-  const handleReset = () => {
-    reset();
-    onReset();
-  };
+  }
 </script>
 
 {#if type === 'searchBar'}
@@ -84,7 +98,7 @@
     {showLoadingSpinner}
     {placeholder}
     onReset={handleReset}
-    onSearch={({ force }) => handleSearch(force ?? false)}
+    onSearch={({ force }) => searchPeople(force ?? false)}
   />
 {:else}
   <input
@@ -92,7 +106,7 @@
     type="text"
     {placeholder}
     bind:value={searchName}
-    on:input={() => handleSearch(false)}
+    oninput={() => searchPeople(false)}
     use:initInput
   />
 {/if}
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte
index 13f356dfc0..8bbfaaafcf 100644
--- a/web/src/lib/components/faces-page/person-side-panel.svelte
+++ b/web/src/lib/components/faces-page/person-side-panel.svelte
@@ -28,28 +28,32 @@
   import { photoViewer } from '$lib/stores/assets.store';
   import { t } from 'svelte-i18n';
 
-  export let assetId: string;
-  export let assetType: AssetTypeEnum;
-  export let onClose: () => void;
-  export let onRefresh: () => void;
+  interface Props {
+    assetId: string;
+    assetType: AssetTypeEnum;
+    onClose: () => void;
+    onRefresh: () => void;
+  }
+
+  let { assetId, assetType, onClose, onRefresh }: Props = $props();
 
   // keep track of the changes
   let peopleToCreate: string[] = [];
   let assetFaceGenerated: string[] = [];
 
   // faces
-  let peopleWithFaces: AssetFaceResponseDto[] = [];
-  let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
-  let selectedPersonToCreate: Record<string, string> = {};
-  let editedFace: AssetFaceResponseDto;
+  let peopleWithFaces: AssetFaceResponseDto[] = $state([]);
+  let selectedPersonToReassign: Record<string, PersonResponseDto> = $state({});
+  let selectedPersonToCreate: Record<string, string> = $state({});
+  let editedFace: AssetFaceResponseDto | undefined = $state();
 
   // loading spinners
-  let isShowLoadingDone = false;
-  let isShowLoadingPeople = false;
+  let isShowLoadingDone = $state(false);
+  let isShowLoadingPeople = $state(false);
 
   // search people
-  let showSelectedFaces = false;
-  let allPeople: PersonResponseDto[] = [];
+  let showSelectedFaces = $state(false);
+  let allPeople: PersonResponseDto[] = $state([]);
 
   // timers
   let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
@@ -152,14 +156,14 @@
   };
 
   const handleCreatePerson = (newFeaturePhoto: string | null) => {
-    if (newFeaturePhoto) {
+    if (newFeaturePhoto && editedFace) {
       selectedPersonToCreate[editedFace.id] = newFeaturePhoto;
     }
     showSelectedFaces = false;
   };
 
   const handleReassignFace = (person: PersonResponseDto | null) => {
-    if (person) {
+    if (person && editedFace) {
       selectedPersonToReassign[editedFace.id] = person;
     }
     showSelectedFaces = false;
@@ -177,14 +181,14 @@
 >
   <div class="flex place-items-center justify-between gap-2">
     <div class="flex items-center gap-2">
-      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} />
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} onclick={onClose} />
       <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('edit_faces')}</p>
     </div>
     {#if !isShowLoadingDone}
       <button
         type="button"
         class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
-        on:click={() => handleEditFaces()}
+        onclick={() => handleEditFaces()}
       >
         {$t('done')}
       </button>
@@ -207,9 +211,9 @@
               role="button"
               tabindex={index}
               class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
-              on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
-              on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
-              on:mouseleave={() => ($boundingBoxesArray = [])}
+              onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
+              onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
+              onmouseleave={() => ($boundingBoxesArray = [])}
             >
               <div class="relative">
                 {#if selectedPersonToCreate[face.id]}
@@ -291,7 +295,7 @@
                     size="18"
                     padding="1"
                     class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
-                    on:click={() => handleReset(face.id)}
+                    onclick={() => handleReset(face.id)}
                   />
                 {:else}
                   <CircleIconButton
@@ -301,7 +305,7 @@
                     size="18"
                     padding="1"
                     class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
-                    on:click={() => handleFacePicker(face)}
+                    onclick={() => handleFacePicker(face)}
                   />
                 {/if}
               </div>
@@ -322,7 +326,7 @@
   </div>
 </section>
 
-{#if showSelectedFaces}
+{#if showSelectedFaces && editedFace}
   <AssignFaceSidePanel
     {allPeople}
     {editedFace}
diff --git a/web/src/lib/components/faces-page/set-birth-date-modal.svelte b/web/src/lib/components/faces-page/set-birth-date-modal.svelte
index d38c519911..f5ecbfabf0 100644
--- a/web/src/lib/components/faces-page/set-birth-date-modal.svelte
+++ b/web/src/lib/components/faces-page/set-birth-date-modal.svelte
@@ -5,11 +5,20 @@
   import DateInput from '../elements/date-input.svelte';
   import { t } from 'svelte-i18n';
 
-  export let birthDate: string;
-  export let onClose: () => void;
-  export let onUpdate: (birthDate: string) => void;
+  interface Props {
+    birthDate: string;
+    onClose: () => void;
+    onUpdate: (birthDate: string) => void;
+  }
+
+  let { birthDate = $bindable(), onClose, onUpdate }: Props = $props();
 
   const todayFormatted = new Date().toISOString().split('T')[0];
+
+  const onSubmit = (event: Event) => {
+    event.preventDefault();
+    onUpdate(birthDate);
+  };
 </script>
 
 <FullScreenModal title={$t('set_date_of_birth')} icon={mdiCake} {onClose}>
@@ -19,7 +28,7 @@
     </p>
   </div>
 
-  <form on:submit|preventDefault={() => onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form">
+  <form onsubmit={(e) => onSubmit(e)} autocomplete="off" id="set-birth-date-form">
     <div class="my-4 flex flex-col gap-2">
       <DateInput
         class="immich-form-input"
@@ -31,8 +40,9 @@
       />
     </div>
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onClose}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onClose}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth form="set-birth-date-form">{$t('set')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte
index 70a360bea0..06c53f3618 100644
--- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte
+++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte
@@ -10,7 +10,7 @@
     type PersonResponseDto,
   } from '@immich/sdk';
   import { mdiMerge, mdiPlus } from '@mdi/js';
-  import { onMount } from 'svelte';
+  import { onMount, type Snippet } from 'svelte';
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
@@ -21,20 +21,26 @@
   import PeopleList from './people-list.svelte';
   import { t } from 'svelte-i18n';
 
-  export let assetIds: string[];
-  export let personAssets: PersonResponseDto;
-  export let onConfirm: () => void;
-  export let onClose: () => void;
+  interface Props {
+    assetIds: string[];
+    personAssets: PersonResponseDto;
+    onConfirm: () => void;
+    onClose: () => void;
+    header?: Snippet;
+    merge?: Snippet;
+  }
 
-  let people: PersonResponseDto[] = [];
-  let selectedPerson: PersonResponseDto | null = null;
-  let disableButtons = false;
-  let showLoadingSpinnerCreate = false;
-  let showLoadingSpinnerReassign = false;
-  let hasSelection = false;
-  let screenHeight: number;
+  let { assetIds, personAssets, onConfirm, onClose, header, merge }: Props = $props();
 
-  $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets];
+  let people: PersonResponseDto[] = $state([]);
+  let selectedPerson: PersonResponseDto | null = $state(null);
+  let disableButtons = $state(false);
+  let showLoadingSpinnerCreate = $state(false);
+  let showLoadingSpinnerReassign = $state(false);
+  let hasSelection = $state(false);
+  let screenHeight: number = $state(0);
+
+  let peopleToNotShow = $derived(selectedPerson ? [personAssets, selectedPerson] : [personAssets]);
 
   const selectedPeople: AssetFaceUpdateItem[] = [];
 
@@ -117,17 +123,17 @@
   class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
 >
   <ControlAppBar {onClose}>
-    <svelte:fragment slot="leading">
-      <slot name="header" />
+    {#snippet leading()}
+      {@render header?.()}
       <div></div>
-    </svelte:fragment>
-    <svelte:fragment slot="trailing">
+    {/snippet}
+    {#snippet trailing()}
       <div class="flex gap-4">
         <Button
           title={$t('create_new_person_hint')}
           size={'sm'}
           disabled={disableButtons || hasSelection}
-          on:click={handleCreate}
+          onclick={handleCreate}
         >
           {#if !showLoadingSpinnerCreate}
             <Icon path={mdiPlus} size={18} />
@@ -140,7 +146,7 @@
           size={'sm'}
           title={$t('reassing_hint')}
           disabled={disableButtons || !hasSelection}
-          on:click={handleReassign}
+          onclick={handleReassign}
         >
           {#if !showLoadingSpinnerReassign}
             <div>
@@ -152,9 +158,9 @@
           <span class="ml-2"> {$t('reassign')}</span></Button
         >
       </div>
-    </svelte:fragment>
+    {/snippet}
   </ControlAppBar>
-  <slot name="merge" />
+  {@render merge?.()}
   <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
     <section id="merge-face-selector relative">
       {#if selectedPerson !== null}
diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte
index d49ab55439..b4ecd56283 100644
--- a/web/src/lib/components/forms/admin-registration-form.svelte
+++ b/web/src/lib/components/forms/admin-registration-form.svelte
@@ -8,15 +8,15 @@
   import { t } from 'svelte-i18n';
   import { retrieveServerConfig } from '$lib/stores/server-config.store';
 
-  let email = '';
-  let password = '';
-  let confirmPassword = '';
-  let name = '';
+  let email = $state('');
+  let password = $state('');
+  let confirmPassword = $state('');
+  let name = $state('');
 
-  let errorMessage: string;
-  let canRegister = false;
+  let errorMessage: string = $state('');
+  let canRegister = $state(false);
 
-  $: {
+  $effect(() => {
     if (password !== confirmPassword && confirmPassword.length > 0) {
       errorMessage = $t('password_does_not_match');
       canRegister = false;
@@ -24,7 +24,7 @@
       errorMessage = '';
       canRegister = true;
     }
-  }
+  });
 
   async function registerAdmin() {
     if (canRegister) {
@@ -40,9 +40,14 @@
       }
     }
   }
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await registerAdmin();
+  };
 </script>
 
-<form on:submit|preventDefault={registerAdmin} method="post" class="mt-5 flex flex-col gap-5">
+<form {onsubmit} method="post" class="mt-5 flex flex-col gap-5">
   <div class="flex flex-col gap-2">
     <label class="immich-form-label" for="email">{$t('admin_email')}</label>
     <input class="immich-form-input" id="email" bind:value={email} type="email" autocomplete="email" required />
diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte
index 5b1341db44..086d7708c3 100644
--- a/web/src/lib/components/forms/api-key-form.svelte
+++ b/web/src/lib/components/forms/api-key-form.svelte
@@ -5,13 +5,23 @@
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
 
-  export let apiKey: { name: string };
-  export let title: string;
-  export let cancelText = $t('cancel');
-  export let submitText = $t('save');
+  interface Props {
+    apiKey: { name: string };
+    title: string;
+    cancelText?: string;
+    submitText?: string;
+    onSubmit: (apiKey: { name: string }) => void;
+    onCancel: () => void;
+  }
 
-  export let onSubmit: (apiKey: { name: string }) => void;
-  export let onCancel: () => void;
+  let {
+    apiKey = $bindable(),
+    title,
+    cancelText = $t('cancel'),
+    submitText = $t('save'),
+    onSubmit,
+    onCancel,
+  }: Props = $props();
 
   const handleSubmit = () => {
     if (apiKey.name) {
@@ -23,17 +33,23 @@
       });
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    handleSubmit();
+  };
 </script>
 
 <FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}>
-  <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="api-key-form">
+  <form {onsubmit} autocomplete="off" id="api-key-form">
     <div class="mb-4 flex flex-col gap-2">
       <label class="immich-form-label" for="name">{$t('name')}</label>
       <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
     </div>
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => onCancel()}>{cancelText}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={() => onCancel()}>{cancelText}</Button>
     <Button type="submit" fullwidth form="api-key-form">{submitText}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte
index dbbefe0d71..fd0503e850 100644
--- a/web/src/lib/components/forms/api-key-secret.svelte
+++ b/web/src/lib/components/forms/api-key-secret.svelte
@@ -5,8 +5,12 @@
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { t } from 'svelte-i18n';
 
-  export let secret = '';
-  export let onDone: () => void;
+  interface Props {
+    secret?: string;
+    onDone: () => void;
+  }
+
+  let { secret = '', onDone }: Props = $props();
 </script>
 
 <FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={onDone}>
@@ -21,8 +25,8 @@
     <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea>
   </div>
 
-  <svelte:fragment slot="sticky-bottom">
-    <Button on:click={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button>
-    <Button on:click={onDone} fullwidth>{$t('done')}</Button>
-  </svelte:fragment>
+  {#snippet stickyBottom()}
+    <Button onclick={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button>
+    <Button onclick={onDone} fullwidth>{$t('done')}</Button>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte
index cbf2ff07f0..94dbb5841f 100644
--- a/web/src/lib/components/forms/change-password-form.svelte
+++ b/web/src/lib/components/forms/change-password-form.svelte
@@ -4,17 +4,21 @@
   import { updateMyUser } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let onSuccess: () => void;
+  interface Props {
+    onSuccess: () => void;
+  }
 
-  let errorMessage: string;
+  let { onSuccess }: Props = $props();
+
+  let errorMessage: string = $state('');
   let success: string;
 
-  let password = '';
-  let passwordConfirm = '';
+  let password = $state('');
+  let passwordConfirm = $state('');
 
-  let valid = false;
+  let valid = $state(false);
 
-  $: {
+  $effect(() => {
     if (password !== passwordConfirm && passwordConfirm.length > 0) {
       errorMessage = $t('password_does_not_match');
       valid = false;
@@ -22,7 +26,7 @@
       errorMessage = '';
       valid = true;
     }
-  }
+  });
 
   async function changePassword() {
     if (valid) {
@@ -33,9 +37,14 @@
       onSuccess();
     }
   }
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await changePassword();
+  };
 </script>
 
-<form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5">
+<form {onsubmit} method="post" class="mt-5 flex flex-col gap-5">
   <div class="flex flex-col gap-2">
     <label class="immich-form-label" for="password">{$t('new_password')}</label>
     <PasswordField id="password" bind:password autocomplete="new-password" />
diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte
index 0687912542..b1599a24b2 100644
--- a/web/src/lib/components/forms/create-user-form.svelte
+++ b/web/src/lib/components/forms/create-user-form.svelte
@@ -10,29 +10,33 @@
   import Slider from '../elements/slider.svelte';
   import PasswordField from '../shared-components/password-field.svelte';
 
-  export let onClose: () => void;
-  export let onSubmit: () => void;
-  export let onCancel: () => void;
-  export let oauthEnabled = false;
+  interface Props {
+    onClose: () => void;
+    onSubmit: () => void;
+    onCancel: () => void;
+    oauthEnabled?: boolean;
+  }
 
-  let error: string;
-  let success: string;
+  let { onClose, onSubmit, onCancel, oauthEnabled = false }: Props = $props();
 
-  let email = '';
-  let password = '';
-  let confirmPassword = '';
-  let name = '';
-  let shouldChangePassword = true;
-  let notify = true;
+  let error = $state('');
+  let success = $state('');
 
-  let canCreateUser = false;
-  let quotaSize: number | undefined;
-  let isCreatingUser = false;
+  let email = $state('');
+  let password = $state('');
+  let confirmPassword = $state('');
+  let name = $state('');
+  let shouldChangePassword = $state(true);
+  let notify = $state(true);
 
-  $: quotaSizeInBytes = quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null;
-  $: quotaSizeWarning = quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw;
+  let canCreateUser = $state(false);
+  let quotaSize: number | undefined = $state();
+  let isCreatingUser = $state(false);
 
-  $: {
+  let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null);
+  let quotaSizeWarning = $derived(quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw);
+
+  $effect(() => {
     if (password !== confirmPassword && confirmPassword.length > 0) {
       error = $t('password_does_not_match');
       canCreateUser = false;
@@ -40,7 +44,7 @@
       error = '';
       canCreateUser = true;
     }
-  }
+  });
 
   async function registerUser() {
     if (canCreateUser && !isCreatingUser) {
@@ -71,10 +75,15 @@
       }
     }
   }
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await registerUser();
+  };
 </script>
 
 <FullScreenModal title={$t('create_new_user')} showLogo {onClose}>
-  <form on:submit|preventDefault={registerUser} autocomplete="off" id="create-new-user-form">
+  <form {onsubmit} autocomplete="off" id="create-new-user-form">
     <div class="my-4 flex flex-col gap-2">
       <label class="immich-form-label" for="email">{$t('email')}</label>
       <input class="immich-form-input" id="email" bind:value={email} type="email" required />
@@ -134,8 +143,9 @@
       <p class="text-sm text-immich-primary">{success}</p>
     {/if}
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button>
     <Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">{$t('create')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/edit-album-form.svelte b/web/src/lib/components/forms/edit-album-form.svelte
index e33774245b..bd61cd2068 100644
--- a/web/src/lib/components/forms/edit-album-form.svelte
+++ b/web/src/lib/components/forms/edit-album-form.svelte
@@ -6,15 +6,19 @@
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let onEditSuccess: ((album: AlbumResponseDto) => unknown) | undefined = undefined;
-  export let onCancel: (() => unknown) | undefined = undefined;
-  export let onClose: () => void;
+  interface Props {
+    album: AlbumResponseDto;
+    onEditSuccess?: ((album: AlbumResponseDto) => unknown) | undefined;
+    onCancel?: (() => unknown) | undefined;
+    onClose: () => void;
+  }
 
-  let albumName = album.albumName;
-  let description = album.description;
+  let { album = $bindable(), onEditSuccess = undefined, onCancel = undefined, onClose }: Props = $props();
 
-  let isSubmitting = false;
+  let albumName = $state(album.albumName);
+  let description = $state(album.description);
+
+  let isSubmitting = $state(false);
 
   const handleUpdateAlbumInfo = async () => {
     isSubmitting = true;
@@ -35,10 +39,15 @@
       isSubmitting = false;
     }
   };
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleUpdateAlbumInfo();
+  };
 </script>
 
 <FullScreenModal title={$t('edit_album')} width="wide" {onClose}>
-  <form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off" id="edit-album-form">
+  <form {onsubmit} autocomplete="off" id="edit-album-form">
     <div class="flex items-center">
       <div class="hidden sm:flex">
         <AlbumCover {album} class="h-[200px] w-[200px] m-4 shadow-lg" />
@@ -57,8 +66,9 @@
       </div>
     </div>
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => onCancel?.()}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={() => onCancel?.()}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth disabled={isSubmitting} form="edit-album-form">{$t('ok')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte
index 0079a695bc..e95ed64135 100644
--- a/web/src/lib/components/forms/edit-user-form.svelte
+++ b/web/src/lib/components/forms/edit-user-form.svelte
@@ -10,23 +10,35 @@
   import { t } from 'svelte-i18n';
   import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
 
-  export let user: UserAdminResponseDto;
-  export let canResetPassword = true;
-  export let newPassword: string;
-  export let onClose: () => void;
-  export let onResetPasswordSuccess: () => void;
-  export let onEditSuccess: () => void;
+  interface Props {
+    user: UserAdminResponseDto;
+    canResetPassword?: boolean;
+    newPassword: string;
+    onClose: () => void;
+    onResetPasswordSuccess: () => void;
+    onEditSuccess: () => void;
+  }
+
+  let {
+    user,
+    canResetPassword = true,
+    newPassword = $bindable(),
+    onClose,
+    onResetPasswordSuccess,
+    onEditSuccess,
+  }: Props = $props();
 
   let error: string;
   let success: string;
-  let quotaSize = user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null;
+  let quotaSize = $state(user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null);
 
   const previousQutoa = user.quotaSizeInBytes;
 
-  $: quotaSizeWarning =
+  let quotaSizeWarning = $derived(
     previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) &&
-    !!quotaSize &&
-    convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw;
+      !!quotaSize &&
+      convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw,
+  );
 
   const editUser = async () => {
     try {
@@ -89,10 +101,15 @@
 
     return generatedPassword;
   }
+
+  const onSubmit = async (event: Event) => {
+    event.preventDefault();
+    await editUser();
+  };
 </script>
 
 <FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}>
-  <form on:submit|preventDefault={editUser} autocomplete="off" id="edit-user-form">
+  <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
     <div class="my-4 flex flex-col gap-2">
       <label class="immich-form-label" for="email">{$t('email')}</label>
       <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
@@ -140,10 +157,11 @@
       <p class="ml-4 text-sm text-immich-primary">{success}</p>
     {/if}
   </form>
-  <svelte:fragment slot="sticky-bottom">
+
+  {#snippet stickyBottom()}
     {#if canResetPassword}
-      <Button color="light-red" fullwidth on:click={resetPassword}>{$t('reset_password')}</Button>
+      <Button color="light-red" fullwidth onclick={resetPassword}>{$t('reset_password')}</Button>
     {/if}
     <Button type="submit" fullwidth form="edit-user-form">{$t('confirm')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
index 05d47c0a0f..e79b60d265 100644
--- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
+++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
@@ -5,13 +5,25 @@
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
 
-  export let exclusionPattern: string;
-  export let exclusionPatterns: string[] = [];
-  export let isEditing = false;
-  export let submitText = $t('submit');
-  export let onCancel: () => void;
-  export let onSubmit: (exclusionPattern: string) => void;
-  export let onDelete: () => void = () => {};
+  interface Props {
+    exclusionPattern: string;
+    exclusionPatterns?: string[];
+    isEditing?: boolean;
+    submitText?: string;
+    onCancel: () => void;
+    onSubmit: (exclusionPattern: string) => void;
+    onDelete?: () => void;
+  }
+
+  let {
+    exclusionPattern = $bindable(),
+    exclusionPatterns = $bindable([]),
+    isEditing = false,
+    submitText = $t('submit'),
+    onCancel,
+    onSubmit,
+    onDelete,
+  }: Props = $props();
 
   onMount(() => {
     if (isEditing) {
@@ -19,12 +31,19 @@
     }
   });
 
-  $: isDuplicate = exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern);
-  $: canSubmit = exclusionPattern && !exclusionPatterns.includes(exclusionPattern);
+  let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
+  let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    if (canSubmit) {
+      onSubmit(exclusionPattern);
+    }
+  };
 </script>
 
 <FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={onCancel}>
-  <form on:submit|preventDefault={() => onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form">
+  <form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
     <p class="py-5 text-sm">
       {$t('admin.exclusion_pattern_description')}
       <br /><br />
@@ -46,11 +65,12 @@
       {/if}
     </div>
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button>
+      <Button color="red" fullwidth onclick={onDelete}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="add-exclusion-pattern-form">{submitText}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte
index 8bfca80aec..33e763f0f0 100644
--- a/web/src/lib/components/forms/library-import-path-form.svelte
+++ b/web/src/lib/components/forms/library-import-path-form.svelte
@@ -5,15 +5,29 @@
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
 
-  export let importPath: string | null;
-  export let importPaths: string[] = [];
-  export let title = $t('import_path');
-  export let cancelText = $t('cancel');
-  export let submitText = $t('save');
-  export let isEditing = false;
-  export let onCancel: () => void;
-  export let onSubmit: (importPath: string | null) => void;
-  export let onDelete: () => void = () => {};
+  interface Props {
+    importPath: string | null;
+    importPaths?: string[];
+    title?: string;
+    cancelText?: string;
+    submitText?: string;
+    isEditing?: boolean;
+    onCancel: () => void;
+    onSubmit: (importPath: string | null) => void;
+    onDelete?: () => void;
+  }
+
+  let {
+    importPath = $bindable(),
+    importPaths = $bindable([]),
+    title = $t('import_path'),
+    cancelText = $t('cancel'),
+    submitText = $t('save'),
+    isEditing = false,
+    onCancel,
+    onSubmit,
+    onDelete,
+  }: Props = $props();
 
   onMount(() => {
     if (isEditing) {
@@ -21,12 +35,19 @@
     }
   });
 
-  $: isDuplicate = importPath !== null && importPaths.includes(importPath);
-  $: canSubmit = importPath !== '' && importPath !== null && !importPaths.includes(importPath);
+  let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath));
+  let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath));
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    if (canSubmit) {
+      onSubmit(importPath);
+    }
+  };
 </script>
 
 <FullScreenModal {title} icon={mdiFolderSync} onClose={onCancel}>
-  <form on:submit|preventDefault={() => onSubmit(importPath)} autocomplete="off" id="library-import-path-form">
+  <form {onsubmit} autocomplete="off" id="library-import-path-form">
     <p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
 
     <div class="my-4 flex flex-col gap-2">
@@ -40,11 +61,12 @@
       {/if}
     </div>
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onCancel}>{cancelText}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onCancel}>{cancelText}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button>
+      <Button color="red" fullwidth onclick={onDelete}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="library-import-path-form">{submitText}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte
index 9e7ae11a63..3acd46520f 100644
--- a/web/src/lib/components/forms/library-import-paths-form.svelte
+++ b/web/src/lib/components/forms/library-import-paths-form.svelte
@@ -11,19 +11,23 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let library: LibraryResponseDto;
-  export let onCancel: () => void;
-  export let onSubmit: (library: LibraryResponseDto) => void;
+  interface Props {
+    library: LibraryResponseDto;
+    onCancel: () => void;
+    onSubmit: (library: LibraryResponseDto) => void;
+  }
 
-  let addImportPath = false;
-  let editImportPath: number | null = null;
+  let { library = $bindable(), onCancel, onSubmit }: Props = $props();
 
-  let importPathToAdd: string | null = null;
-  let editedImportPath: string;
+  let addImportPath = $state(false);
+  let editImportPath: number | null = $state(null);
 
-  let validatedPaths: ValidateLibraryImportPathResponseDto[] = [];
+  let importPathToAdd: string | null = $state(null);
+  let editedImportPath: string = $state('');
 
-  $: importPaths = validatedPaths.map((validatedPath) => validatedPath.importPath);
+  let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
+
+  let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
 
   onMount(async () => {
     if (library.importPaths) {
@@ -134,6 +138,11 @@
       editImportPath = null;
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSubmit({ ...library });
+  };
 </script>
 
 {#if addImportPath}
@@ -163,7 +172,7 @@
   />
 {/if}
 
-<form on:submit|preventDefault={() => onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-4">
+<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
   <table class="text-left">
     <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
       {#each validatedPaths as validatedPath, listIndex}
@@ -199,7 +208,7 @@
               icon={mdiPencilOutline}
               title={$t('edit_import_path')}
               size="16"
-              on:click={() => {
+              onclick={() => {
                 editImportPath = listIndex;
                 editedImportPath = validatedPath.importPath;
               }}
@@ -223,7 +232,7 @@
           ><Button
             type="button"
             size="sm"
-            on:click={() => {
+            onclick={() => {
               addImportPath = true;
             }}>{$t('add_path')}</Button
           ></td
@@ -233,12 +242,12 @@
   </table>
   <div class="flex justify-between w-full">
     <div class="justify-end gap-2">
-      <Button size="sm" color="gray" on:click={() => revalidate()}
+      <Button size="sm" color="gray" onclick={() => revalidate()}
         ><Icon path={mdiRefresh} size={20} />{$t('validate')}</Button
       >
     </div>
     <div class="justify-end gap-2">
-      <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button>
+      <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button>
       <Button size="sm" type="submit">{$t('save')}</Button>
     </div>
   </div>
diff --git a/web/src/lib/components/forms/library-rename-form.svelte b/web/src/lib/components/forms/library-rename-form.svelte
index 1f93fb028b..3f20709474 100644
--- a/web/src/lib/components/forms/library-rename-form.svelte
+++ b/web/src/lib/components/forms/library-rename-form.svelte
@@ -3,18 +3,27 @@
   import Button from '../elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let library: Partial<LibraryResponseDto>;
-  export let onCancel: () => void;
-  export let onSubmit: (library: Partial<LibraryResponseDto>) => void;
+  interface Props {
+    library: Partial<LibraryResponseDto>;
+    onCancel: () => void;
+    onSubmit: (library: Partial<LibraryResponseDto>) => void;
+  }
+
+  let { library = $bindable(), onCancel, onSubmit }: Props = $props();
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSubmit({ ...library });
+  };
 </script>
 
-<form on:submit|preventDefault={() => onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-2">
+<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-2">
   <div class="flex flex-col gap-2">
     <label class="immich-form-label" for="path">{$t('name')}</label>
     <input class="immich-form-input" id="name" name="name" type="text" bind:value={library.name} />
   </div>
   <div class="flex w-full justify-end gap-2 pt-2">
-    <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button>
+    <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button>
     <Button size="sm" type="submit">{$t('save')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte
index a9a42c31f7..68e99641e8 100644
--- a/web/src/lib/components/forms/library-scan-settings-form.svelte
+++ b/web/src/lib/components/forms/library-scan-settings-form.svelte
@@ -8,17 +8,21 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let library: Partial<LibraryResponseDto>;
-  export let onCancel: () => void;
-  export let onSubmit: (library: Partial<LibraryResponseDto>) => void;
+  interface Props {
+    library: Partial<LibraryResponseDto>;
+    onCancel: () => void;
+    onSubmit: (library: Partial<LibraryResponseDto>) => void;
+  }
 
-  let addExclusionPattern = false;
-  let editExclusionPattern: number | null = null;
+  let { library = $bindable(), onCancel, onSubmit }: Props = $props();
 
-  let exclusionPatternToAdd: string;
-  let editedExclusionPattern: string;
+  let addExclusionPattern = $state(false);
+  let editExclusionPattern: number | null = $state(null);
 
-  let exclusionPatterns: string[] = [];
+  let exclusionPatternToAdd: string = $state('');
+  let editedExclusionPattern: string = $state('');
+
+  let exclusionPatterns: string[] = $state([]);
 
   onMount(() => {
     if (library.exclusionPatterns) {
@@ -89,6 +93,11 @@
       editExclusionPattern = null;
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSubmit(library);
+  };
 </script>
 
 {#if addExclusionPattern}
@@ -113,7 +122,7 @@
   />
 {/if}
 
-<form on:submit|preventDefault={() => onSubmit(library)} autocomplete="off" class="m-4 flex flex-col gap-4">
+<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
   <table class="w-full text-left">
     <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
       {#each exclusionPatterns as exclusionPattern, listIndex}
@@ -131,7 +140,7 @@
               icon={mdiPencilOutline}
               title={$t('edit_exclusion_pattern')}
               size="16"
-              on:click={() => {
+              onclick={() => {
                 editExclusionPattern = listIndex;
                 editedExclusionPattern = exclusionPattern;
               }}
@@ -154,7 +163,7 @@
         <td class="w-1/4 text-ellipsis px-4 text-sm"
           ><Button
             size="sm"
-            on:click={() => {
+            onclick={() => {
               addExclusionPattern = true;
             }}>{$t('add_exclusion_pattern')}</Button
           ></td
@@ -164,7 +173,7 @@
   </table>
 
   <div class="flex w-full justify-end gap-4">
-    <Button size="sm" color="gray" on:click={onCancel}>{$t('cancel')}</Button>
+    <Button size="sm" color="gray" onclick={onCancel}>{$t('cancel')}</Button>
     <Button size="sm" type="submit">{$t('save')}</Button>
   </div>
 </form>
diff --git a/web/src/lib/components/forms/library-user-picker-form.svelte b/web/src/lib/components/forms/library-user-picker-form.svelte
index e5334ff9e9..137a49921a 100644
--- a/web/src/lib/components/forms/library-user-picker-form.svelte
+++ b/web/src/lib/components/forms/library-user-picker-form.svelte
@@ -8,27 +8,37 @@
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
   import { t } from 'svelte-i18n';
 
-  export let onCancel: () => void;
-  export let onSubmit: (ownerId: string) => void;
+  interface Props {
+    onCancel: () => void;
+    onSubmit: (ownerId: string) => void;
+  }
 
-  let ownerId: string = $user.id;
+  let { onCancel, onSubmit }: Props = $props();
 
-  let userOptions: { value: string; text: string }[] = [];
+  let ownerId: string = $state($user.id);
+
+  let userOptions: { value: string; text: string }[] = $state([]);
 
   onMount(async () => {
     const users = await searchUsersAdmin({});
     userOptions = users.map((user) => ({ value: user.id, text: user.name }));
   });
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSubmit(ownerId);
+  };
 </script>
 
 <FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={onCancel}>
-  <form on:submit|preventDefault={() => onSubmit(ownerId)} autocomplete="off" id="select-library-owner-form">
+  <form {onsubmit} autocomplete="off" id="select-library-owner-form">
     <p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
 
     <SettingSelect bind:value={ownerId} options={userOptions} name="user" />
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth form="select-library-owner-form">{$t('create')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/login-form.svelte b/web/src/lib/components/forms/login-form.svelte
index b1af7a01f4..be40210e17 100644
--- a/web/src/lib/components/forms/login-form.svelte
+++ b/web/src/lib/components/forms/login-form.svelte
@@ -12,16 +12,20 @@
   import PasswordField from '../shared-components/password-field.svelte';
   import { t } from 'svelte-i18n';
 
-  export let onSuccess: () => unknown | Promise<unknown>;
-  export let onFirstLogin: () => unknown | Promise<unknown>;
-  export let onOnboarding: () => unknown | Promise<unknown>;
+  interface Props {
+    onSuccess: () => unknown | Promise<unknown>;
+    onFirstLogin: () => unknown | Promise<unknown>;
+    onOnboarding: () => unknown | Promise<unknown>;
+  }
 
-  let errorMessage: string;
-  let email = '';
-  let password = '';
-  let oauthError = '';
-  let loading = false;
-  let oauthLoading = true;
+  let { onSuccess, onFirstLogin, onOnboarding }: Props = $props();
+
+  let errorMessage: string = $state('');
+  let email = $state('');
+  let password = $state('');
+  let oauthError = $state('');
+  let loading = $state(false);
+  let oauthLoading = $state(true);
 
   onMount(async () => {
     if (!$featureFlags.oauth) {
@@ -87,10 +91,15 @@
       oauthError = $t('errors.unable_to_login_with_oauth');
     }
   };
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleLogin();
+  };
 </script>
 
 {#if !oauthLoading && $featureFlags.passwordLogin}
-  <form on:submit|preventDefault={handleLogin} class="mt-5 flex flex-col gap-5">
+  <form {onsubmit} class="mt-5 flex flex-col gap-5">
     {#if errorMessage}
       <p class="text-red-400" transition:fade>
         {errorMessage}
@@ -150,7 +159,7 @@
       size="lg"
       fullwidth
       color={$featureFlags.passwordLogin ? 'secondary' : 'primary'}
-      on:click={handleOAuthLogin}
+      onclick={handleOAuthLogin}
     >
       {#if oauthLoading}
         <span class="h-6">
diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte
index b5e358ec96..84a8c1a409 100644
--- a/web/src/lib/components/forms/tag-asset-form.svelte
+++ b/web/src/lib/components/forms/tag-asset-form.svelte
@@ -9,14 +9,19 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { AppRoute } from '$lib/constants';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
+  import { SvelteSet } from 'svelte/reactivity';
 
-  export let onTag: (tagIds: string[]) => void;
-  export let onCancel: () => void;
+  interface Props {
+    onTag: (tagIds: string[]) => void;
+    onCancel: () => void;
+  }
 
-  let allTags: TagResponseDto[] = [];
-  $: tagMap = Object.fromEntries(allTags.map((tag) => [tag.id, tag]));
-  let selectedIds = new Set<string>();
-  $: disabled = selectedIds.size === 0;
+  let { onTag, onCancel }: Props = $props();
+
+  let allTags: TagResponseDto[] = $state([]);
+  let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
+  let selectedIds = $state(new SvelteSet<string>());
+  let disabled = $derived(selectedIds.size === 0);
 
   onMount(async () => {
     allTags = await getAllTags();
@@ -37,19 +42,26 @@
     selectedIds.delete(tag);
     selectedIds = selectedIds;
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    handleSubmit();
+  };
 </script>
 
 <FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
   <div class="text-sm">
     <p>
-      <FormatMessage key="tag_not_found_question" let:message>
-        <a href={AppRoute.TAGS} class="text-immich-primary dark:text-immich-dark-primary underline">
-          {message}
-        </a>
+      <FormatMessage key="tag_not_found_question">
+        {#snippet children({ message })}
+          <a href={AppRoute.TAGS} class="text-immich-primary dark:text-immich-dark-primary underline">
+            {message}
+          </a>
+        {/snippet}
       </FormatMessage>
     </p>
   </div>
-  <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
+  <form {onsubmit} autocomplete="off" id="create-tag-form">
     <div class="my-4 flex flex-col gap-2">
       <Combobox
         onSelect={handleSelect}
@@ -77,7 +89,7 @@
             type="button"
             class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
             title="Remove tag"
-            on:click={() => handleRemove(tagId)}
+            onclick={() => handleRemove(tagId)}
           >
             <Icon path={mdiClose} />
           </button>
@@ -86,8 +98,8 @@
     {/each}
   </section>
 
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
+  {#snippet stickyBottom()}
+    <Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/i18n/__test__/format-tag-b.svelte b/web/src/lib/components/i18n/__test__/format-tag-b.svelte
index 122358c6b7..6e8b2412e1 100644
--- a/web/src/lib/components/i18n/__test__/format-tag-b.svelte
+++ b/web/src/lib/components/i18n/__test__/format-tag-b.svelte
@@ -3,12 +3,18 @@
   import FormatMessage from '../format-message.svelte';
   import type { ComponentProps } from 'svelte';
 
-  export let key: Translations;
-  export let values: ComponentProps<FormatMessage>['values'];
+  interface Props {
+    key: Translations;
+    values: ComponentProps<typeof FormatMessage>['values'];
+  }
+
+  let { key, values }: Props = $props();
 </script>
 
-<FormatMessage {key} {values} let:tag let:message>
-  {#if tag === 'b'}
-    <strong>{message}</strong>
-  {/if}
+<FormatMessage {key} {values}>
+  {#snippet children({ tag, message })}
+    {#if tag === 'b'}
+      <strong>{message}</strong>
+    {/if}
+  {/snippet}
 </FormatMessage>
diff --git a/web/src/lib/components/i18n/format-bold-message.svelte b/web/src/lib/components/i18n/format-bold-message.svelte
index 052b220edc..0381ec5e98 100644
--- a/web/src/lib/components/i18n/format-bold-message.svelte
+++ b/web/src/lib/components/i18n/format-bold-message.svelte
@@ -3,12 +3,18 @@
   import type { InterpolationValues } from '$lib/components/i18n/format-message.svelte';
   import type { Translations } from 'svelte-i18n';
 
-  export let key: Translations;
-  export let values: InterpolationValues = {};
+  interface Props {
+    key: Translations;
+    values?: InterpolationValues;
+  }
+
+  let { key, values = {} }: Props = $props();
 </script>
 
-<FormatMessage {key} {values} let:message let:tag>
-  {#if tag === 'b'}
-    <b>{message}</b>
-  {/if}
+<FormatMessage {key} {values}>
+  {#snippet children({ message, tag })}
+    {#if tag === 'b'}
+      <b>{message}</b>
+    {/if}
+  {/snippet}
 </FormatMessage>
diff --git a/web/src/lib/components/i18n/format-message.svelte b/web/src/lib/components/i18n/format-message.svelte
index 48c59478c6..b8909e34de 100644
--- a/web/src/lib/components/i18n/format-message.svelte
+++ b/web/src/lib/components/i18n/format-message.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   import type { FormatXMLElementFn, PrimitiveType } from 'intl-messageformat';
   export type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
 </script>
@@ -18,8 +18,13 @@
     tag?: string;
   };
 
-  export let key: Translations;
-  export let values: InterpolationValues = {};
+  interface Props {
+    key: Translations;
+    values?: InterpolationValues;
+    children?: import('svelte').Snippet<[{ tag?: string; message?: string }]>;
+  }
+
+  let { key, values = {}, children }: Props = $props();
 
   const getLocale = (locale?: string | null) => {
     if (locale == null) {
@@ -96,9 +101,9 @@
     }
   };
 
-  $: message = ($json(key) as string) || key;
-  $: locale = getLocale($i18nLocale);
-  $: parts = getParts(message, locale);
+  let message = $derived(($json(key) as string) || key);
+  let locale = $derived(getLocale($i18nLocale));
+  let parts = $derived(getParts(message, locale));
 </script>
 
 <!--
@@ -130,7 +135,7 @@ Result: Visit <a href="">docs</a> <strong>now</strong>
 -->
 {#each parts as { tag, message }}
   {#if tag}
-    <slot {tag} {message}>{message}</slot>
+    {#if children}{@render children({ tag, message })}{:else}{message}{/if}
   {:else}
     {message}
   {/if}
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte
index ed232b80cd..9be2db2691 100644
--- a/web/src/lib/components/layouts/user-page-layout.svelte
+++ b/web/src/lib/components/layouts/user-page-layout.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export const headerId = 'user-page-header';
 </script>
 
@@ -7,16 +7,36 @@
   import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
   import SideBar from '../shared-components/side-bar/side-bar.svelte';
   import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
+  import type { Snippet } from 'svelte';
 
-  export let hideNavbar = false;
-  export let showUploadButton = false;
-  export let title: string | undefined = undefined;
-  export let description: string | undefined = undefined;
-  export let scrollbar = true;
-  export let admin = false;
+  interface Props {
+    hideNavbar?: boolean;
+    showUploadButton?: boolean;
+    title?: string | undefined;
+    description?: string | undefined;
+    scrollbar?: boolean;
+    admin?: boolean;
+    header?: Snippet;
+    sidebar?: Snippet;
+    buttons?: Snippet;
+    children?: Snippet;
+  }
 
-  $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden';
-  $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full';
+  let {
+    hideNavbar = false,
+    showUploadButton = false,
+    title = undefined,
+    description = undefined,
+    scrollbar = true,
+    admin = false,
+    header,
+    sidebar,
+    buttons,
+    children,
+  }: Props = $props();
+
+  let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden');
+  let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full');
 </script>
 
 <header>
@@ -24,22 +44,20 @@
     <NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
   {/if}
 
-  <slot name="header" />
+  {@render header?.()}
 </header>
 <main
   tabindex="-1"
   class="relative grid h-screen grid-cols-[theme(spacing.18)_auto] overflow-hidden bg-immich-bg pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
 >
-  <slot name="sidebar">
-    {#if admin}
-      <AdminSideBar />
-    {:else}
-      <SideBar />
-    {/if}
-  </slot>
+  {#if sidebar}{@render sidebar()}{:else if admin}
+    <AdminSideBar />
+  {:else}
+    <SideBar />
+  {/if}
 
   <section class="relative">
-    {#if title || $$slots.buttons}
+    {#if title || buttons}
       <div
         class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg"
       >
@@ -51,12 +69,12 @@
             <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
           {/if}
         </div>
-        <slot name="buttons" />
+        {@render buttons?.()}
       </div>
     {/if}
 
     <div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto">
-      <slot />
+      {@render children?.()}
     </div>
   </section>
 </main>
diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte
index 35df9f2285..270978e120 100644
--- a/web/src/lib/components/map-page/map-settings-modal.svelte
+++ b/web/src/lib/components/map-page/map-settings-modal.svelte
@@ -10,19 +10,24 @@
   import LinkButton from '../elements/buttons/link-button.svelte';
   import DateInput from '../elements/date-input.svelte';
 
-  export let settings: MapSettings;
-  export let onClose: () => void;
-  export let onSave: (settings: MapSettings) => void;
+  interface Props {
+    settings: MapSettings;
+    onClose: () => void;
+    onSave: (settings: MapSettings) => void;
+  }
 
-  let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
+  let { settings = $bindable(), onClose, onSave }: Props = $props();
+
+  let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSave(settings);
+  };
 </script>
 
 <FullScreenModal title={$t('map_settings')} {onClose}>
-  <form
-    on:submit|preventDefault={() => onSave(settings)}
-    class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
-    id="map-settings-form"
-  >
+  <form {onsubmit} class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" id="map-settings-form">
     <SettingSwitch title={$t('allow_dark_mode')} bind:checked={settings.allowDarkMode} />
     <SettingSwitch title={$t('only_favorites')} bind:checked={settings.onlyFavorites} />
     <SettingSwitch title={$t('include_archived')} bind:checked={settings.includeArchived} />
@@ -46,7 +51,7 @@
         </div>
         <div class="flex justify-center text-xs">
           <LinkButton
-            on:click={() => {
+            onclick={() => {
               customDateRange = false;
               settings.dateAfter = '';
               settings.dateBefore = '';
@@ -91,7 +96,7 @@
         />
         <div class="text-xs">
           <LinkButton
-            on:click={() => {
+            onclick={() => {
               customDateRange = true;
               settings.relativeDate = '';
             }}
@@ -102,8 +107,9 @@
       </div>
     {/if}
   </form>
-  <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" size="sm" fullwidth on:click={onClose}>{$t('cancel')}</Button>
+
+  {#snippet stickyBottom()}
+    <Button color="gray" size="sm" fullwidth onclick={onClose}>{$t('cancel')}</Button>
     <Button type="submit" size="sm" fullwidth form="map-settings-form">{$t('save')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte
index f4715ce57c..bca3b2024d 100644
--- a/web/src/lib/components/memory-page/memory-viewer.svelte
+++ b/web/src/lib/components/memory-page/memory-viewer.svelte
@@ -42,8 +42,9 @@
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import { tweened } from 'svelte/motion';
-  import { derived } from 'svelte/store';
+  import { derived as storeDerived } from 'svelte/store';
   import { fade } from 'svelte/transition';
+  import { SvelteSet } from 'svelte/reactivity';
 
   type MemoryIndex = {
     memoryIndex: number;
@@ -59,19 +60,21 @@
     nextMemory?: MemoryLaneResponseDto;
   };
 
-  let memoryGallery: HTMLElement;
-  let memoryWrapper: HTMLElement;
-  let galleryInView = false;
-  let paused = false;
-  let selectedAssets: Set<AssetResponseDto> = new Set();
-  let current: MemoryAsset | undefined = undefined;
+  let memoryGallery: HTMLElement | undefined = $state();
+  let memoryWrapper: HTMLElement | undefined = $state();
+  let galleryInView = $state(false);
+  let paused = $state(false);
+  let selectedAssets: SvelteSet<AssetResponseDto> = $state(new SvelteSet());
+  let current: MemoryAsset | undefined = $state(undefined);
   // let memories: MemoryAsset[] = [];
-  let resetPromise = Promise.resolve();
+  let resetPromise = $state(Promise.resolve());
 
   const { isViewing } = assetViewingStore;
-  const viewport: Viewport = { width: 0, height: 0 };
-  const progress = tweened<number>(0, { duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0) });
-  const memories = derived(memoryStore, (memories) => {
+  const viewport: Viewport = $state({ width: 0, height: 0 });
+  const progressBarController = tweened<number>(0, {
+    duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
+  });
+  const memories = storeDerived(memoryStore, (memories) => {
     memories = memories ?? [];
     const memoryAssets: MemoryAsset[] = [];
     let previous: MemoryAsset | undefined;
@@ -100,13 +103,6 @@
     return memoryAssets;
   });
 
-  $: isMultiSelectionMode = selectedAssets.size > 0;
-  $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
-  $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
-  $: selectedAssets = galleryInView ? selectedAssets : new Set();
-  $: handlePromiseError(handleProgress($progress));
-  $: handlePromiseError(handleAction(galleryInView ? 'pause' : 'play'));
-
   const loadFromParams = (memories: MemoryAsset[], page: typeof $page | NavigationTarget | null) => {
     const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
     handlePromiseError(handleAction($isViewing ? 'pause' : 'reset'));
@@ -130,24 +126,24 @@
   const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
   const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
   const handleEscape = async () => goto(AppRoute.PHOTOS);
-  const handleSelectAll = () => (selectedAssets = new Set(current?.memory.assets || []));
+  const handleSelectAll = () => (selectedAssets = new SvelteSet(current?.memory.assets || []));
   const handleAction = async (action: 'reset' | 'pause' | 'play') => {
     switch (action) {
       case 'play': {
         paused = false;
-        await progress.set(1);
+        await progressBarController.set(1);
         break;
       }
 
       case 'pause': {
         paused = true;
-        await progress.set($progress);
+        await progressBarController.set($progressBarController);
         break;
       }
 
       case 'reset': {
         paused = false;
-        resetPromise = progress.set(0);
+        resetPromise = progressBarController.set(0);
         break;
       }
     }
@@ -159,6 +155,7 @@
     }
 
     if (progress === 1) {
+      await progressBarController.set(0);
       await (current?.next ? handleNextAsset() : handleAction('pause'));
     }
   };
@@ -210,6 +207,21 @@
 
     current = loadFromParams($memories, target);
   });
+  $effect(() => {
+    selectedAssets = galleryInView ? selectedAssets : new SvelteSet();
+  });
+
+  let isMultiSelectionMode = $derived(selectedAssets.size > 0);
+  let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived));
+  let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite));
+
+  $effect(() => {
+    handlePromiseError(handleProgress($progressBarController));
+  });
+
+  $effect(() => {
+    handlePromiseError(handleAction(galleryInView ? 'pause' : 'play'));
+  });
 </script>
 
 <svelte:window
@@ -226,9 +238,9 @@
 
 {#if isMultiSelectionMode}
   <div class="sticky top-0 z-[90]">
-    <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
+    <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new SvelteSet())}>
       <CreateSharedLink />
-      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
+      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
 
       <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
         <AddToAlbum />
@@ -251,17 +263,19 @@
 <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
   {#if current && current.memory.assets.length > 0}
     <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark>
-      <svelte:fragment slot="leading">
-        <p class="text-lg">
-          {$memoryLaneTitle(current.memory.yearsAgo)}
-        </p>
-      </svelte:fragment>
+      {#snippet leading()}
+        {#if current}
+          <p class="text-lg">
+            {$memoryLaneTitle(current.memory.yearsAgo)}
+          </p>
+        {/if}
+      {/snippet}
 
       <div class="flex place-content-center place-items-center gap-2 overflow-hidden">
         <CircleIconButton
           title={paused ? $t('play_memories') : $t('pause_memories')}
           icon={paused ? mdiPlay : mdiPause}
-          on:click={() => handleAction(paused ? 'play' : 'pause')}
+          onclick={() => handleAction(paused ? 'play' : 'pause')}
           class="hover:text-black"
         />
 
@@ -274,7 +288,7 @@
             {:then}
               <span
                 class="absolute left-0 h-[2px] bg-white"
-                style:width={`${index < current.assetIndex ? 100 : index > current.assetIndex ? 0 : $progress * 100}%`}
+                style:width={`${index < current.assetIndex ? 100 : index > current.assetIndex ? 0 : $progressBarController * 100}%`}
               ></span>
             {/await}
           </a>
@@ -296,10 +310,10 @@
       >
         <button
           type="button"
-          on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })}
+          onclick={() => memoryWrapper?.scrollIntoView({ behavior: 'smooth' })}
           disabled={!galleryInView}
         >
-          <CircleIconButton title={$t('hide_gallery')} icon={mdiChevronUp} color="light" />
+          <CircleIconButton title={$t('hide_gallery')} icon={mdiChevronUp} color="light" onclick={() => {}} />
         </button>
       </div>
     {/if}
@@ -314,7 +328,7 @@
             type="button"
             class="relative h-full w-full rounded-2xl"
             disabled={!current.previousMemory}
-            on:click={handlePreviousMemory}
+            onclick={handlePreviousMemory}
           >
             {#if current.previousMemory && current.previousMemory.assets.length > 0}
               <img
@@ -367,6 +381,7 @@
                 icon={mdiImageSearch}
                 title={$t('view_in_timeline')}
                 color="light"
+                onclick={() => {}}
               />
             </div>
             <!-- CONTROL BUTTONS -->
@@ -376,7 +391,7 @@
                   title={$t('previous_memory')}
                   icon={mdiChevronLeft}
                   color="dark"
-                  on:click={handlePreviousAsset}
+                  onclick={handlePreviousAsset}
                 />
               </div>
             {/if}
@@ -387,7 +402,7 @@
                   title={$t('next_memory')}
                   icon={mdiChevronRight}
                   color="dark"
-                  on:click={handleNextAsset}
+                  onclick={handleNextAsset}
                 />
               </div>
             {/if}
@@ -409,7 +424,7 @@
           <button
             type="button"
             class="relative h-full w-full rounded-2xl"
-            on:click={handleNextMemory}
+            onclick={handleNextMemory}
             disabled={!current.nextMemory}
           >
             {#if current.nextMemory && current.nextMemory.assets.length > 0}
@@ -451,7 +466,7 @@
           title={$t('show_gallery')}
           icon={mdiChevronDown}
           color="light"
-          on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}
+          onclick={() => memoryGallery?.scrollIntoView({ behavior: 'smooth' })}
         />
       </div>
 
diff --git a/web/src/lib/components/onboarding-page/onboarding-card.svelte b/web/src/lib/components/onboarding-page/onboarding-card.svelte
index 9b2378ccd8..54951dfa09 100644
--- a/web/src/lib/components/onboarding-page/onboarding-card.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-card.svelte
@@ -1,9 +1,15 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
+  import type { Snippet } from 'svelte';
   import { fade } from 'svelte/transition';
 
-  export let title: string | undefined = undefined;
-  export let icon: string | undefined = undefined;
+  interface Props {
+    title?: string | undefined;
+    icon?: string | undefined;
+    children?: Snippet;
+  }
+
+  let { title = undefined, icon = undefined, children }: Props = $props();
 </script>
 
 <div
@@ -23,5 +29,5 @@
       {/if}
     </div>
   {/if}
-  <slot />
+  {@render children?.()}
 </div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
index 466e1d29f7..102465f019 100644
--- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
@@ -7,7 +7,11 @@
   import { user } from '$lib/stores/user.store';
   import { t } from 'svelte-i18n';
 
-  export let onDone: () => void;
+  interface Props {
+    onDone: () => void;
+  }
+
+  let { onDone }: Props = $props();
 </script>
 
 <OnboardingCard>
@@ -18,7 +22,7 @@
   <p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p>
 
   <div class="w-full flex place-content-end">
-    <Button class="flex gap-2 place-content-center" on:click={() => onDone()}>
+    <Button class="flex gap-2 place-content-center" onclick={() => onDone()}>
       <p>{$t('theme')}</p>
       <Icon path={mdiArrowRight} size="18" />
     </Button>
diff --git a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte
index da36f741f1..8ff8a9200d 100644
--- a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte
@@ -10,10 +10,15 @@
   import { t } from 'svelte-i18n';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
 
-  export let onDone: () => void;
-  export let onPrevious: () => void;
+  interface Props {
+    onDone: () => void;
+    onPrevious: () => void;
+  }
 
-  let config: SystemConfigDto | null = null;
+  let { onDone, onPrevious }: Props = $props();
+
+  let config: SystemConfigDto | null = $state(null);
+  let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
 
   onMount(async () => {
     config = await getConfig();
@@ -26,38 +31,42 @@
   </p>
 
   {#if config && $user}
-    <AdminSettings bind:config let:handleSave>
-      <SettingSwitch
-        title={$t('admin.map_settings')}
-        subtitle={$t('admin.map_implications')}
-        bind:checked={config.map.enabled}
-      />
-      <SettingSwitch
-        title={$t('admin.version_check_settings')}
-        subtitle={$t('admin.version_check_implications')}
-        bind:checked={config.newVersionCheck.enabled}
-      />
-      <div class="flex pt-4">
-        <div class="w-full flex place-content-start">
-          <Button class="flex gap-2 place-content-center" on:click={() => onPrevious()}>
-            <Icon path={mdiArrowLeft} size="18" />
-            <p>{$t('theme')}</p>
-          </Button>
-        </div>
-        <div class="flex w-full place-content-end">
-          <Button
-            on:click={() => {
-              handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck });
-              onDone();
-            }}
-          >
-            <span class="flex place-content-center place-items-center gap-2">
-              {$t('admin.storage_template_settings')}
-              <Icon path={mdiArrowRight} size="18" />
-            </span>
-          </Button>
-        </div>
-      </div>
+    <AdminSettings bind:config bind:this={adminSettingsComponent}>
+      {#snippet children()}
+        {#if config}
+          <SettingSwitch
+            title={$t('admin.map_settings')}
+            subtitle={$t('admin.map_implications')}
+            bind:checked={config.map.enabled}
+          />
+          <SettingSwitch
+            title={$t('admin.version_check_settings')}
+            subtitle={$t('admin.version_check_implications')}
+            bind:checked={config.newVersionCheck.enabled}
+          />
+          <div class="flex pt-4">
+            <div class="w-full flex place-content-start">
+              <Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}>
+                <Icon path={mdiArrowLeft} size="18" />
+                <p>{$t('theme')}</p>
+              </Button>
+            </div>
+            <div class="flex w-full place-content-end">
+              <Button
+                onclick={() => {
+                  adminSettingsComponent?.handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck });
+                  onDone();
+                }}
+              >
+                <span class="flex place-content-center place-items-center gap-2">
+                  {$t('admin.storage_template_settings')}
+                  <Icon path={mdiArrowRight} size="18" />
+                </span>
+              </Button>
+            </div>
+          </div>
+        {/if}
+      {/snippet}
     </AdminSettings>
   {/if}
 </OnboardingCard>
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
index 69809dd39d..b692a6f2de 100644
--- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
@@ -12,10 +12,15 @@
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
 
-  export let onDone: () => void;
-  export let onPrevious: () => void;
+  interface Props {
+    onDone: () => void;
+    onPrevious: () => void;
+  }
 
-  let config: SystemConfigDto | null = null;
+  let { onDone, onPrevious }: Props = $props();
+
+  let config: SystemConfigDto | undefined = $state();
+  let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
 
   onMount(async () => {
     config = await getConfig();
@@ -24,45 +29,51 @@
 
 <OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}>
   <p>
-    <FormatMessage key="admin.storage_template_onboarding_description" let:message>
-      <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
+    <FormatMessage key="admin.storage_template_onboarding_description">
+      {#snippet children({ message })}
+        <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
+      {/snippet}
     </FormatMessage>
   </p>
 
   {#if config && $user}
-    <AdminSettings bind:config let:defaultConfig let:savedConfig let:handleSave let:handleReset>
-      <StorageTemplateSettings
-        minified
-        disabled={$featureFlags.configFile}
-        {config}
-        {defaultConfig}
-        {savedConfig}
-        onSave={(config) => handleSave(config)}
-        onReset={(options) => handleReset(options)}
-        duration={0}
-      >
-        <div class="flex pt-4">
-          <div class="w-full flex place-content-start">
-            <Button class="flex gap-2 place-content-center" on:click={() => onPrevious()}>
-              <Icon path={mdiArrowLeft} size="18" />
-              <p>{$t('theme')}</p>
-            </Button>
-          </div>
-          <div class="flex w-full place-content-end">
-            <Button
-              on:click={() => {
-                handleSave({ storageTemplate: config?.storageTemplate });
-                onDone();
-              }}
-            >
-              <span class="flex place-content-center place-items-center gap-2">
-                {$t('done')}
-                <Icon path={mdiCheck} size="18" />
-              </span>
-            </Button>
-          </div>
-        </div>
-      </StorageTemplateSettings>
+    <AdminSettings bind:config bind:this={adminSettingsComponent}>
+      {#snippet children({ defaultConfig, savedConfig })}
+        {#if config}
+          <StorageTemplateSettings
+            minified
+            disabled={$featureFlags.configFile}
+            {config}
+            {defaultConfig}
+            {savedConfig}
+            onSave={(config) => adminSettingsComponent?.handleSave(config)}
+            onReset={(options) => adminSettingsComponent?.handleReset(options)}
+            duration={0}
+          >
+            <div class="flex pt-4">
+              <div class="w-full flex place-content-start">
+                <Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}>
+                  <Icon path={mdiArrowLeft} size="18" />
+                  <p>{$t('theme')}</p>
+                </Button>
+              </div>
+              <div class="flex w-full place-content-end">
+                <Button
+                  onclick={() => {
+                    adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
+                    onDone();
+                  }}
+                >
+                  <span class="flex place-content-center place-items-center gap-2">
+                    {$t('done')}
+                    <Icon path={mdiCheck} size="18" />
+                  </span>
+                </Button>
+              </div>
+            </div>
+          </StorageTemplateSettings>
+        {/if}
+      {/snippet}
     </AdminSettings>
   {/if}
 </OnboardingCard>
diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
index 975dbd1ec3..4229cf9f67 100644
--- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
@@ -8,7 +8,11 @@
   import { Theme } from '$lib/constants';
   import { t } from 'svelte-i18n';
 
-  export let onDone: () => void;
+  interface Props {
+    onDone: () => void;
+  }
+
+  let { onDone }: Props = $props();
 </script>
 
 <OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}>
@@ -20,7 +24,7 @@
     <button
       type="button"
       class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
-      on:click={() => ($colorTheme.value = Theme.LIGHT)}
+      onclick={() => ($colorTheme.value = Theme.LIGHT)}
     >
       <div
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
@@ -32,7 +36,7 @@
     <button
       type="button"
       class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
-      on:click={() => ($colorTheme.value = Theme.DARK)}
+      onclick={() => ($colorTheme.value = Theme.DARK)}
     >
       <div
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
@@ -45,7 +49,7 @@
 
   <div class="flex">
     <div class="w-full flex place-content-end">
-      <Button class="flex gap-2 place-content-center" on:click={() => onDone()}>
+      <Button class="flex gap-2 place-content-center" onclick={() => onDone()}>
         <p>{$t('privacy')}</p>
         <Icon path={mdiArrowRight} size="18" />
       </Button>
diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte
index 8c46764408..10917a1d90 100644
--- a/web/src/lib/components/photos-page/actions/add-to-album.svelte
+++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte
@@ -8,10 +8,14 @@
   import { t } from 'svelte-i18n';
   import type { OnAddToAlbum } from '$lib/utils/actions';
 
-  export let shared = false;
-  export let onAddToAlbum: OnAddToAlbum = () => {};
+  interface Props {
+    shared?: boolean;
+    onAddToAlbum?: OnAddToAlbum;
+  }
 
-  let showAlbumPicker = false;
+  let { shared = false, onAddToAlbum = () => {} }: Props = $props();
+
+  let showAlbumPicker = $state(false);
 
   const { getAssets } = getAssetControlContext();
 
diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte
index 792b80b702..868a5ddd6d 100644
--- a/web/src/lib/components/photos-page/actions/archive-action.svelte
+++ b/web/src/lib/components/photos-page/actions/archive-action.svelte
@@ -7,15 +7,18 @@
   import { archiveAssets } from '$lib/utils/asset-utils';
   import { t } from 'svelte-i18n';
 
-  export let onArchive: OnArchive;
+  interface Props {
+    onArchive: OnArchive;
+    menuItem?: boolean;
+    unarchive?: boolean;
+  }
 
-  export let menuItem = false;
-  export let unarchive = false;
+  let { onArchive, menuItem = false, unarchive = false }: Props = $props();
 
-  $: text = unarchive ? $t('unarchive') : $t('to_archive');
-  $: icon = unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline;
+  let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
+  let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
 
-  let loading = false;
+  let loading = $state(false);
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
@@ -38,8 +41,8 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} />
   {:else}
-    <CircleIconButton title={text} {icon} on:click={handleArchive} />
+    <CircleIconButton title={text} {icon} onclick={handleArchive} />
   {/if}
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
index 13b51638f4..b383729ecd 100644
--- a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
+++ b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte
@@ -10,16 +10,16 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { t } from 'svelte-i18n';
 
-  export let jobs: AssetJobName[] = [
-    AssetJobName.RegenerateThumbnail,
-    AssetJobName.RefreshMetadata,
-    AssetJobName.TranscodeVideo,
-  ];
+  interface Props {
+    jobs?: AssetJobName[];
+  }
+
+  let { jobs = [AssetJobName.RegenerateThumbnail, AssetJobName.RefreshMetadata, AssetJobName.TranscodeVideo] }: Props =
+    $props();
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: isAllVideos = [...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video);
+  let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video));
 
   const handleRunJob = async (name: AssetJobName) => {
     try {
diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte
index 114315348d..3232cbd2b4 100644
--- a/web/src/lib/components/photos-page/actions/change-date-action.svelte
+++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte
@@ -9,10 +9,14 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { mdiCalendarEditOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
-  export let menuItem = false;
+  interface Props {
+    menuItem?: boolean;
+  }
+
+  let { menuItem = false }: Props = $props();
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
-  let isShowChangeDate = false;
+  let isShowChangeDate = $state(false);
 
   const handleConfirm = async (dateTimeOriginal: string) => {
     isShowChangeDate = false;
diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte
index 3fe1db4327..0ad93e5d81 100644
--- a/web/src/lib/components/photos-page/actions/change-location-action.svelte
+++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte
@@ -9,10 +9,14 @@
   import { mdiMapMarkerMultipleOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let menuItem = false;
+  interface Props {
+    menuItem?: boolean;
+  }
+
+  let { menuItem = false }: Props = $props();
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
-  let isShowChangeLocation = false;
+  let isShowChangeLocation = $state(false);
 
   async function handleConfirm(point: { lng: number; lat: number }) {
     isShowChangeLocation = false;
diff --git a/web/src/lib/components/photos-page/actions/create-shared-link.svelte b/web/src/lib/components/photos-page/actions/create-shared-link.svelte
index 7436ff2177..1b99627ea9 100644
--- a/web/src/lib/components/photos-page/actions/create-shared-link.svelte
+++ b/web/src/lib/components/photos-page/actions/create-shared-link.svelte
@@ -5,11 +5,11 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { t } from 'svelte-i18n';
 
-  let showModal = false;
+  let showModal = $state(false);
   const { getAssets } = getAssetControlContext();
 </script>
 
-<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} on:click={() => (showModal = true)} />
+<CircleIconButton title={$t('share')} icon={mdiShareVariantOutline} onclick={() => (showModal = true)} />
 
 {#if showModal}
   <CreateSharedLinkModal assetIds={[...getAssets()].map(({ id }) => id)} onClose={() => (showModal = false)} />
diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte
index 6d3275c74d..bdd442e50c 100644
--- a/web/src/lib/components/photos-page/actions/delete-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte
@@ -8,16 +8,20 @@
   import DeleteAssetDialog from '../delete-asset-dialog.svelte';
   import { t } from 'svelte-i18n';
 
-  export let onAssetDelete: OnDelete;
-  export let menuItem = false;
-  export let force = !$featureFlags.trash;
+  interface Props {
+    onAssetDelete: OnDelete;
+    menuItem?: boolean;
+    force?: boolean;
+  }
+
+  let { onAssetDelete, menuItem = false, force = !$featureFlags.trash }: Props = $props();
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
-  let isShowConfirmation = false;
-  let loading = false;
+  let isShowConfirmation = $state(false);
+  let loading = $state(false);
 
-  $: label = force ? $t('permanently_delete') : $t('delete');
+  let label = $derived(force ? $t('permanently_delete') : $t('delete'));
 
   const handleTrash = async () => {
     if (force) {
@@ -41,9 +45,9 @@
 {#if menuItem}
   <MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} />
 {:else if loading}
-  <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+  <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} />
 {:else}
-  <CircleIconButton title={label} icon={mdiDeleteForeverOutline} on:click={handleTrash} />
+  <CircleIconButton title={label} icon={mdiDeleteForeverOutline} onclick={handleTrash} />
 {/if}
 
 {#if isShowConfirmation}
diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte
index 7716fbe36d..89eca9c6a8 100644
--- a/web/src/lib/components/photos-page/actions/download-action.svelte
+++ b/web/src/lib/components/photos-page/actions/download-action.svelte
@@ -7,8 +7,12 @@
   import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let filename = 'immich.zip';
-  export let menuItem = false;
+  interface Props {
+    filename?: string;
+    menuItem?: boolean;
+  }
+
+  let { filename = 'immich.zip', menuItem = false }: Props = $props();
 
   const { getAssets, clearSelect } = getAssetControlContext();
 
@@ -24,7 +28,7 @@
     await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
   };
 
-  $: menuItemIcon = getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline;
+  let menuItemIcon = $derived(getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline);
 </script>
 
 <svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: handleDownloadFiles }} />
@@ -32,5 +36,5 @@
 {#if menuItem}
   <MenuOption text={$t('download')} icon={menuItemIcon} onClick={handleDownloadFiles} />
 {:else}
-  <CircleIconButton title={$t('download')} icon={mdiCloudDownloadOutline} on:click={handleDownloadFiles} />
+  <CircleIconButton title={$t('download')} icon={mdiCloudDownloadOutline} onclick={handleDownloadFiles} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/favorite-action.svelte b/web/src/lib/components/photos-page/actions/favorite-action.svelte
index 1d723b1a9d..1bc6764157 100644
--- a/web/src/lib/components/photos-page/actions/favorite-action.svelte
+++ b/web/src/lib/components/photos-page/actions/favorite-action.svelte
@@ -12,15 +12,18 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { t } from 'svelte-i18n';
 
-  export let onFavorite: OnFavorite;
+  interface Props {
+    onFavorite: OnFavorite;
+    menuItem?: boolean;
+    removeFavorite: boolean;
+  }
 
-  export let menuItem = false;
-  export let removeFavorite: boolean;
+  let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
 
-  $: text = removeFavorite ? $t('remove_from_favorites') : $t('to_favorite');
-  $: icon = removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline;
+  let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
+  let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
 
-  let loading = false;
+  let loading = $state(false);
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
@@ -65,8 +68,8 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} />
   {:else}
-    <CircleIconButton title={text} {icon} on:click={handleFavorite} />
+    <CircleIconButton title={text} {icon} onclick={handleFavorite} />
   {/if}
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte
index 24107b9f88..27ac6cf042 100644
--- a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte
+++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte
@@ -8,15 +8,19 @@
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 
-  export let onLink: OnLink;
-  export let onUnlink: OnUnlink;
-  export let menuItem = false;
-  export let unlink = false;
+  interface Props {
+    onLink: OnLink;
+    onUnlink: OnUnlink;
+    menuItem?: boolean;
+    unlink?: boolean;
+  }
 
-  let loading = false;
+  let { onLink, onUnlink, menuItem = false, unlink = false }: Props = $props();
 
-  $: text = unlink ? $t('unlink_motion_video') : $t('link_motion_video');
-  $: icon = unlink ? mdiLinkOff : mdiMotionPlayOutline;
+  let loading = $state(false);
+
+  let text = $derived(unlink ? $t('unlink_motion_video') : $t('link_motion_video'));
+  let icon = $derived(unlink ? mdiLinkOff : mdiMotionPlayOutline);
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
@@ -68,8 +72,8 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} />
   {:else}
-    <CircleIconButton title={text} {icon} on:click={onClick} />
+    <CircleIconButton title={text} {icon} onclick={onClick} />
   {/if}
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte
index 2384f95d2e..19c1e54cfa 100644
--- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte
+++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte
@@ -11,9 +11,13 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let album: AlbumResponseDto;
-  export let onRemove: ((assetIds: string[]) => void) | undefined;
-  export let menuItem = false;
+  interface Props {
+    album: AlbumResponseDto;
+    onRemove: ((assetIds: string[]) => void) | undefined;
+    menuItem?: boolean;
+  }
+
+  let { album = $bindable(), onRemove, menuItem = false }: Props = $props();
 
   const { getAssets, clearSelect } = getAssetControlContext();
 
@@ -57,5 +61,5 @@
 {#if menuItem}
   <MenuOption text={$t('remove_from_album')} icon={mdiImageRemoveOutline} onClick={removeFromAlbum} />
 {:else}
-  <CircleIconButton title={$t('remove_from_album')} icon={mdiDeleteOutline} on:click={removeFromAlbum} />
+  <CircleIconButton title={$t('remove_from_album')} icon={mdiDeleteOutline} onclick={removeFromAlbum} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
index e838f0813d..e884a929a3 100644
--- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
+++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte
@@ -9,7 +9,11 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let sharedLink: SharedLinkResponseDto;
+  interface Props {
+    sharedLink: SharedLinkResponseDto;
+  }
+
+  let { sharedLink = $bindable() }: Props = $props();
 
   const { getAssets, clearSelect } = getAssetControlContext();
 
@@ -55,4 +59,4 @@
   };
 </script>
 
-<CircleIconButton title={$t('remove_from_shared_link')} on:click={handleRemove} icon={mdiDeleteOutline} />
+<CircleIconButton title={$t('remove_from_shared_link')} onclick={handleRemove} icon={mdiDeleteOutline} />
diff --git a/web/src/lib/components/photos-page/actions/restore-assets.svelte b/web/src/lib/components/photos-page/actions/restore-assets.svelte
index 19e1c206fd..037e3239ef 100644
--- a/web/src/lib/components/photos-page/actions/restore-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/restore-assets.svelte
@@ -12,11 +12,15 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import { t } from 'svelte-i18n';
 
-  export let onRestore: OnRestore | undefined;
+  interface Props {
+    onRestore: OnRestore | undefined;
+  }
+
+  let { onRestore }: Props = $props();
 
   const { getAssets, clearSelect } = getAssetControlContext();
 
-  let loading = false;
+  let loading = $state(false);
 
   const handleRestore = async () => {
     loading = true;
@@ -40,7 +44,7 @@
   };
 </script>
 
-<Button disabled={loading} size="sm" color="transparent-gray" shadow={false} rounded="lg" on:click={handleRestore}>
+<Button disabled={loading} size="sm" color="transparent-gray" shadow={false} rounded="lg" onclick={handleRestore}>
   <Icon path={mdiHistory} size="24" />
   <span class="ml-2">{$t('restore')}</span>
 </Button>
diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
index 93df51a6a0..cc27f3ebbe 100644
--- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte
@@ -6,8 +6,12 @@
   import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
   import { t } from 'svelte-i18n';
 
-  export let assetStore: AssetStore;
-  export let assetInteractionStore: AssetInteractionStore;
+  interface Props {
+    assetStore: AssetStore;
+    assetInteractionStore: AssetInteractionStore;
+  }
+
+  let { assetStore, assetInteractionStore }: Props = $props();
 
   const handleSelectAll = async () => {
     await selectAllAssets(assetStore, assetInteractionStore);
@@ -19,7 +23,7 @@
 </script>
 
 {#if $isSelectingAllAssets}
-  <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} on:click={handleCancel} />
+  <CircleIconButton title={$t('unselect_all')} icon={mdiSelectRemove} onclick={handleCancel} />
 {:else}
-  <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
+  <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
 {/if}
diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte
index c1f2bf212f..fe4f066a0e 100644
--- a/web/src/lib/components/photos-page/actions/stack-action.svelte
+++ b/web/src/lib/components/photos-page/actions/stack-action.svelte
@@ -6,9 +6,13 @@
   import type { OnStack, OnUnstack } from '$lib/utils/actions';
   import { t } from 'svelte-i18n';
 
-  export let unstack = false;
-  export let onStack: OnStack | undefined;
-  export let onUnstack: OnUnstack | undefined;
+  interface Props {
+    unstack?: boolean;
+    onStack: OnStack | undefined;
+    onUnstack: OnUnstack | undefined;
+  }
+
+  let { unstack = false, onStack, onUnstack }: Props = $props();
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte
index 77e91d7235..32cdaec16a 100644
--- a/web/src/lib/components/photos-page/actions/tag-action.svelte
+++ b/web/src/lib/components/photos-page/actions/tag-action.svelte
@@ -7,13 +7,17 @@
   import { getAssetControlContext } from '../asset-select-control-bar.svelte';
   import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
 
-  export let menuItem = false;
+  interface Props {
+    menuItem?: boolean;
+  }
+
+  let { menuItem = false }: Props = $props();
 
   const text = $t('tag');
   const icon = mdiTagMultipleOutline;
 
-  let loading = false;
-  let isOpen = false;
+  let loading = $state(false);
+  let isOpen = $state(false);
 
   const { clearSelect, getOwnedAssets } = getAssetControlContext();
 
@@ -36,9 +40,9 @@
 
 {#if !menuItem}
   {#if loading}
-    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
+    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} onclick={() => {}} />
   {:else}
-    <CircleIconButton title={text} {icon} on:click={handleOpen} />
+    <CircleIconButton title={text} {icon} onclick={handleOpen} />
   {/if}
 {/if}
 
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index 6c534e5116..775b2a9282 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -22,7 +22,7 @@
   import { TUNABLES } from '$lib/utils/tunables';
   import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
   import { throttle } from 'lodash-es';
-  import { onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount, type Snippet } from 'svelte';
   import Portal from '../shared-components/portal/portal.svelte';
   import Scrubber from '../shared-components/scrubber/scrubber.svelte';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
@@ -38,80 +38,70 @@
   import { generateId } from '$lib/utils/generate-id';
   import { isTimelineScrolling } from '$lib/stores/timeline.store';
 
-  export let isSelectionMode = false;
-  export let singleSelect = false;
-
-  /** `true` if this asset grid is responds to navigation events; if `true`, then look at the
+  interface Props {
+    isSelectionMode?: boolean;
+    singleSelect?: boolean;
+    /** `true` if this asset grid is responds to navigation events; if `true`, then look at the
    `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
    additionally, update the page location/url with the asset as the asset-grid is scrolled */
-  export let enableRouting: boolean;
+    enableRouting: boolean;
+    assetStore: AssetStore;
+    assetInteractionStore: AssetInteractionStore;
+    removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null;
+    withStacked?: boolean;
+    showArchiveIcon?: boolean;
+    isShared?: boolean;
+    album?: AlbumResponseDto | null;
+    isShowDeleteConfirmation?: boolean;
+    onSelect?: (asset: AssetResponseDto) => void;
+    onEscape?: () => void;
+    children?: Snippet;
+    empty?: Snippet;
+  }
 
-  export let assetStore: AssetStore;
-  export let assetInteractionStore: AssetInteractionStore;
-  export let removeAction:
-    | AssetAction.UNARCHIVE
-    | AssetAction.ARCHIVE
-    | AssetAction.FAVORITE
-    | AssetAction.UNFAVORITE
-    | null = null;
-  export let withStacked = false;
-  export let showArchiveIcon = false;
-  export let isShared = false;
-  export let album: AlbumResponseDto | null = null;
-  export let isShowDeleteConfirmation = false;
-  export let onSelect: (asset: AssetResponseDto) => void = () => {};
-  export let onEscape: () => void = () => {};
+  let {
+    isSelectionMode = false,
+    singleSelect = false,
+    enableRouting,
+    assetStore = $bindable(),
+    assetInteractionStore,
+    removeAction = null,
+    withStacked = false,
+    showArchiveIcon = false,
+    isShared = false,
+    album = null,
+    isShowDeleteConfirmation = $bindable(false),
+    onSelect = () => {},
+    onEscape = () => {},
+    children,
+    empty,
+  }: Props = $props();
 
   let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
   const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
     assetInteractionStore;
 
-  const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
-  const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
+  const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
+  const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
 
   const componentId = generateId();
-  let element: HTMLElement;
-  let timelineElement: HTMLElement;
-  let showShortcuts = false;
-  let showSkeleton = true;
+  let element: HTMLElement | undefined = $state();
+  let timelineElement: HTMLElement | undefined = $state();
+  let showShortcuts = $state(false);
+  let showSkeleton = $state(true);
   let internalScroll = false;
   let navigating = false;
-  let preMeasure: AssetBucket[] = [];
+  let preMeasure: AssetBucket[] = $state([]);
   let lastIntersectedBucketDate: string | undefined;
-  let scrubBucketPercent = 0;
-  let scrubBucket: { bucketDate: string | undefined } | undefined;
-  let scrubOverallPercent: number = 0;
-  let topSectionHeight = 0;
-  let topSectionOffset = 0;
+  let scrubBucketPercent = $state(0);
+  let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
+  let scrubOverallPercent: number = $state(0);
+  let topSectionHeight = $state(0);
+  let topSectionOffset = $state(0);
   // 60 is the bottom spacer element at 60px
   let bottomSectionHeight = 60;
-  let leadout = false;
+  let leadout = $state(false);
 
-  $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
-  $: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
-  $: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
-  $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
-  $: {
-    if (isEmpty) {
-      assetInteractionStore.clearMultiselect();
-    }
-  }
-  $: {
-    if (element && isViewportOrigin()) {
-      const rect = element.getBoundingClientRect();
-      viewport.height = rect.height;
-      viewport.width = rect.width;
-      viewport.x = rect.x;
-      viewport.y = rect.y;
-    }
-    if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
-      safeViewport.height = viewport.height;
-      safeViewport.width = viewport.width;
-      safeViewport.x = viewport.x;
-      safeViewport.y = viewport.y;
-      updateViewport();
-    }
-  }
   const {
     ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
     BUCKET: {
@@ -141,11 +131,11 @@
 
     if ($gridScrollTarget?.at) {
       void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
-        element.scrollTo({ top: 0 });
+        element?.scrollTo({ top: 0 });
         showSkeleton = false;
       });
     } else {
-      element.scrollTo({ top: 0 });
+      element?.scrollTo({ top: 0 });
       showSkeleton = false;
     }
   };
@@ -185,7 +175,7 @@
                 { replaceState: true, forceNavigate: true },
               );
             } else {
-              element.scrollTo({ top: 0 });
+              element?.scrollTo({ top: 0 });
               showSkeleton = false;
             }
           }, 500);
@@ -276,14 +266,24 @@
     ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
     ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
 
-  const getMaxScroll = () =>
-    topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
+  const getMaxScroll = () => {
+    if (!element || !timelineElement) {
+      return 0;
+    }
+
+    return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
+  };
 
   const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
     const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
     const maxScrollPercent = getMaxScrollPercent();
     const delta = bucket.bucketHeight * bucketScrollPercent;
     const scrollTop = (topOffset + delta) * maxScrollPercent;
+
+    if (!element) {
+      return;
+    }
+
     element.scrollTop = scrollTop;
   };
 
@@ -297,6 +297,11 @@
 
       const maxScroll = getMaxScroll();
       const offset = maxScroll * scrollPercent;
+
+      if (!element) {
+        return;
+      }
+
       element.scrollTop = offset;
     } else {
       const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
@@ -344,6 +349,11 @@
     }, 1000);
 
     leadout = false;
+
+    if (!element) {
+      return;
+    }
+
     if ($assetStore.timelineHeight < safeViewport.height * 2) {
       // edge case - scroll limited due to size of content, must adjust -  use the overall percent instead
       const maxScroll = getMaxScroll();
@@ -409,7 +419,7 @@
     : () => void 0;
 
   const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
-    element.scrollTo({ top: offset });
+    element?.scrollTo({ top: offset });
     if (!bucket.measured) {
       preMeasure.push(bucket);
     }
@@ -466,37 +476,10 @@
 
   const focusElement = () => {
     if (document.activeElement === document.body) {
-      element.focus();
+      element?.focus();
     }
   };
 
-  $: shortcutList = (() => {
-    if ($isSearchEnabled || $showAssetViewer) {
-      return [];
-    }
-
-    const shortcuts: ShortcutOptions[] = [
-      { shortcut: { key: 'Escape' }, onShortcut: onEscape },
-      { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
-      { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
-      { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
-      { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
-      { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
-    ];
-
-    if ($isMultiSelectState) {
-      shortcuts.push(
-        { shortcut: { key: 'Delete' }, onShortcut: onDelete },
-        { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
-        { shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
-        { shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
-        { shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
-      );
-    }
-
-    return shortcuts;
-  })();
-
   const handleSelectAsset = (asset: AssetResponseDto) => {
     if (!$assetStore.albumAssets.has(asset.id)) {
       assetInteractionStore.selectAsset(asset);
@@ -585,13 +568,9 @@
     }
   };
 
-  let lastAssetMouseEvent: AssetResponseDto | null = null;
+  let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
 
-  $: if (!lastAssetMouseEvent) {
-    assetInteractionStore.clearAssetSelectionCandidates();
-  }
-
-  let shiftKeyIsDown = false;
+  let shiftKeyIsDown = $state(false);
 
   const deselectAllAssets = () => {
     cancelMultiselect(assetInteractionStore);
@@ -619,14 +598,6 @@
     }
   };
 
-  $: if (!shiftKeyIsDown) {
-    assetInteractionStore.clearAssetSelectionCandidates();
-  }
-
-  $: if (shiftKeyIsDown && lastAssetMouseEvent) {
-    selectAssetCandidates(lastAssetMouseEvent);
-  }
-
   const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
     if (asset) {
       selectAssetCandidates(asset);
@@ -655,7 +626,7 @@
 
     onSelect(asset);
 
-    if (singleSelect) {
+    if (singleSelect && element) {
       element.scrollTop = 0;
       return;
     }
@@ -723,18 +694,18 @@
     assetInteractionStore.setAssetSelectionStart(deselect ? null : asset);
   };
 
-  const selectAssetCandidates = (asset: AssetResponseDto) => {
+  const selectAssetCandidates = (endAsset: AssetResponseDto) => {
     if (!shiftKeyIsDown) {
       return;
     }
 
-    const rangeStart = $assetSelectionStart;
-    if (!rangeStart) {
+    const startAsset = $assetSelectionStart;
+    if (!startAsset) {
       return;
     }
 
-    let start = $assetStore.assets.indexOf(rangeStart);
-    let end = $assetStore.assets.indexOf(asset);
+    let start = $assetStore.assets.findIndex((a) => a.id === startAsset.id);
+    let end = $assetStore.assets.findIndex((a) => a.id === endAsset.id);
 
     if (start > end) {
       [start, end] = [end, start];
@@ -751,9 +722,83 @@
   onDestroy(() => {
     assetStore.taskManager.removeAllTasksForComponent(componentId);
   });
+  let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
+  let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0);
+  let idsSelectedAssets = $derived([...$selectedAssets].map(({ id }) => id));
+  let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived));
+
+  $effect(() => {
+    if (isEmpty) {
+      assetInteractionStore.clearMultiselect();
+    }
+  });
+
+  $effect(() => {
+    if (element && isViewportOrigin()) {
+      const rect = element.getBoundingClientRect();
+      viewport.height = rect.height;
+      viewport.width = rect.width;
+      viewport.x = rect.x;
+      viewport.y = rect.y;
+    }
+    if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
+      safeViewport.height = viewport.height;
+      safeViewport.width = viewport.width;
+      safeViewport.x = viewport.x;
+      safeViewport.y = viewport.y;
+      updateViewport();
+    }
+  });
+
+  let shortcutList = $derived(
+    (() => {
+      if ($isSearchEnabled || $showAssetViewer) {
+        return [];
+      }
+
+      const shortcuts: ShortcutOptions[] = [
+        { shortcut: { key: 'Escape' }, onShortcut: onEscape },
+        { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
+        { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
+        { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
+        { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
+        { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
+      ];
+
+      if ($isMultiSelectState) {
+        shortcuts.push(
+          { shortcut: { key: 'Delete' }, onShortcut: onDelete },
+          { shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
+          { shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
+          { shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
+          { shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
+        );
+      }
+
+      return shortcuts;
+    })(),
+  );
+
+  $effect(() => {
+    if (!lastAssetMouseEvent) {
+      assetInteractionStore.clearAssetSelectionCandidates();
+    }
+  });
+
+  $effect(() => {
+    if (!shiftKeyIsDown) {
+      assetInteractionStore.clearAssetSelectionCandidates();
+    }
+  });
+
+  $effect(() => {
+    if (shiftKeyIsDown && lastAssetMouseEvent) {
+      selectAssetCandidates(lastAssetMouseEvent);
+    }
+  });
 </script>
 
-<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
+<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
 
 {#if isShowDeleteConfirmation}
   <DeleteAssetDialog
@@ -789,16 +834,16 @@
   tabindex="-1"
   use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
   bind:this={element}
-  on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
+  onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
 >
   <section
     use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
     class:invisible={showSkeleton}
   >
-    <slot />
+    {@render children?.()}
     {#if isEmpty}
       <!-- (optional) empty placeholder -->
-      <slot name="empty" />
+      {@render empty?.()}
     {/if}
   </section>
 
diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte
index 79a0ea75e6..2ab8f1e9c2 100644
--- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte
+++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   import { createContext } from '$lib/utils/context';
   import { t } from 'svelte-i18n';
 
@@ -17,10 +17,16 @@
   import type { AssetResponseDto } from '@immich/sdk';
   import { mdiClose } from '@mdi/js';
   import ControlAppBar from '../shared-components/control-app-bar.svelte';
+  import type { Snippet } from 'svelte';
 
-  export let assets: Set<AssetResponseDto>;
-  export let clearSelect: () => void;
-  export let ownerId: string | undefined = undefined;
+  interface Props {
+    assets: Set<AssetResponseDto>;
+    clearSelect: () => void;
+    ownerId?: string | undefined;
+    children?: Snippet;
+  }
+
+  let { assets, clearSelect, ownerId = undefined, children }: Props = $props();
 
   setContext({
     getAssets: () => assets,
@@ -31,9 +37,13 @@
 </script>
 
 <ControlAppBar onClose={clearSelect} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
-  <div class="font-medium text-immich-primary dark:text-immich-dark-primary" slot="leading">
-    <p class="block sm:hidden">{assets.size}</p>
-    <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.size } })}</p>
-  </div>
-  <slot slot="trailing" />
+  {#snippet leading()}
+    <div class="font-medium text-immich-primary dark:text-immich-dark-primary">
+      <p class="block sm:hidden">{assets.size}</p>
+      <p class="hidden sm:block">{$t('selected_count', { values: { count: assets.size } })}</p>
+    </div>
+  {/snippet}
+  {#snippet trailing()}
+    {@render children?.()}
+  {/snippet}
 </ControlAppBar>
diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
index 3eff428a7b..3053600a47 100644
--- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte
+++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
@@ -5,11 +5,15 @@
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
 
-  export let size: number;
-  export let onConfirm: () => void;
-  export let onCancel: () => void;
+  interface Props {
+    size: number;
+    onConfirm: () => void;
+    onCancel: () => void;
+  }
 
-  let checked = false;
+  let { size, onConfirm, onCancel }: Props = $props();
+
+  let checked = $state(false);
 
   const handleConfirm = () => {
     if (checked) {
@@ -25,10 +29,12 @@
   onConfirm={handleConfirm}
   {onCancel}
 >
-  <svelte:fragment slot="prompt">
+  {#snippet promptSnippet()}
     <p>
-      <FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }} let:message>
-        <b>{message}</b>
+      <FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}>
+        {#snippet children({ message })}
+          <b>{message}</b>
+        {/snippet}
       </FormatMessage>
     </p>
     <p><b>{$t('cannot_undo_this_action')}</b></p>
@@ -36,5 +42,5 @@
     <div class="pt-4 flex justify-center items-center">
       <Checkbox id="confirm-deletion-input" label={$t('do_not_show_again')} bind:checked />
     </div>
-  </svelte:fragment>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte
index f458fe40dd..80ad7640fb 100644
--- a/web/src/lib/components/photos-page/measure-date-group.svelte
+++ b/web/src/lib/components/photos-page/measure-date-group.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   const recentTimes: number[] = [];
   // TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -20,9 +20,13 @@
   import { resizeObserver } from '$lib/actions/resize-observer';
   import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store';
 
-  export let assetStore: AssetStore;
-  export let bucket: AssetBucket;
-  export let onMeasured: () => void;
+  interface Props {
+    assetStore: AssetStore;
+    bucket: AssetBucket;
+    onMeasured: () => void;
+  }
+
+  let { assetStore, bucket, onMeasured }: Props = $props();
 
   async function _measure(element: Element) {
     try {
diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte
index ed6ef4f3a7..3a6ac7e8cf 100644
--- a/web/src/lib/components/photos-page/memory-lane.svelte
+++ b/web/src/lib/components/photos-page/memory-lane.svelte
@@ -11,27 +11,29 @@
   import { fade } from 'svelte/transition';
   import { t } from 'svelte-i18n';
 
-  $: shouldRender = $memoryStore?.length > 0;
+  let shouldRender = $derived($memoryStore?.length > 0);
 
   onMount(async () => {
     const localTime = new Date();
     $memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() });
   });
 
-  let memoryLaneElement: HTMLElement;
-  let offsetWidth = 0;
-  let innerWidth = 0;
+  let memoryLaneElement: HTMLElement | undefined = $state();
+  let offsetWidth = $state(0);
+  let innerWidth = $state(0);
 
-  let scrollLeftPosition = 0;
+  let scrollLeftPosition = $state(0);
 
-  const onScroll = () => (scrollLeftPosition = memoryLaneElement?.scrollLeft);
+  const onScroll = () => {
+    scrollLeftPosition = memoryLaneElement?.scrollLeft ?? 0;
+  };
 
-  $: canScrollLeft = scrollLeftPosition > 0;
-  $: canScrollRight = Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth;
+  let canScrollLeft = $derived(scrollLeftPosition > 0);
+  let canScrollRight = $derived(Math.ceil(scrollLeftPosition) < innerWidth - offsetWidth);
 
   const scrollBy = 400;
-  const scrollLeft = () => memoryLaneElement.scrollBy({ left: -scrollBy, behavior: 'smooth' });
-  const scrollRight = () => memoryLaneElement.scrollBy({ left: scrollBy, behavior: 'smooth' });
+  const scrollLeft = () => memoryLaneElement?.scrollBy({ left: -scrollBy, behavior: 'smooth' });
+  const scrollRight = () => memoryLaneElement?.scrollBy({ left: scrollBy, behavior: 'smooth' });
 </script>
 
 {#if shouldRender}
@@ -40,7 +42,7 @@
     bind:this={memoryLaneElement}
     class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
     use:resizeObserver={({ width }) => (offsetWidth = width)}
-    on:scroll={onScroll}
+    onscroll={onScroll}
   >
     {#if canScrollLeft || canScrollRight}
       <div class="sticky left-0 z-20">
@@ -49,7 +51,7 @@
             <button
               type="button"
               class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
-              on:click={scrollLeft}
+              onclick={scrollLeft}
             >
               <Icon path={mdiChevronLeft} size="36" /></button
             >
@@ -60,7 +62,7 @@
             <button
               type="button"
               class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
-              on:click={scrollRight}
+              onclick={scrollRight}
             >
               <Icon path={mdiChevronRight} size="36" /></button
             >
diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte
index 07836eb4db..601a40cce2 100644
--- a/web/src/lib/components/photos-page/skeleton.svelte
+++ b/web/src/lib/components/photos-page/skeleton.svelte
@@ -1,6 +1,10 @@
 <script lang="ts">
-  export let title: string | null = null;
-  export let height: string | null = null;
+  interface Props {
+    title?: string | null;
+    height?: string | null;
+  }
+
+  let { title = null, height = null }: Props = $props();
 </script>
 
 <div class="overflow-clip" style={`height: ${height}`}>
diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte
index 1b5368b133..245a90f9f3 100644
--- a/web/src/lib/components/share-page/individual-shared-viewer.svelte
+++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte
@@ -19,15 +19,19 @@
   import type { Viewport } from '$lib/stores/assets.store';
   import { t } from 'svelte-i18n';
 
-  export let sharedLink: SharedLinkResponseDto;
-  export let isOwned: boolean;
+  interface Props {
+    sharedLink: SharedLinkResponseDto;
+    isOwned: boolean;
+  }
 
-  const viewport: Viewport = { width: 0, height: 0 };
-  let selectedAssets: Set<AssetResponseDto> = new Set();
-  let innerWidth: number;
+  let { sharedLink = $bindable(), isOwned }: Props = $props();
 
-  $: assets = sharedLink.assets;
-  $: isMultiSelectionMode = selectedAssets.size > 0;
+  const viewport: Viewport = $state({ width: 0, height: 0 });
+  let selectedAssets: Set<AssetResponseDto> = $state(new Set());
+  let innerWidth: number = $state(0);
+
+  let assets = $derived(sharedLink.assets);
+  let isMultiSelectionMode = $derived(selectedAssets.size > 0);
 
   dragAndDropFilesStore.subscribe((value) => {
     if (value.isDragging && value.files.length > 0) {
@@ -75,7 +79,7 @@
 <section class="bg-immich-bg dark:bg-immich-dark-bg">
   {#if isMultiSelectionMode}
     <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
-      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
+      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
       {#if sharedLink?.allowDownload}
         <DownloadAction filename="immich-shared.zip" />
       {/if}
@@ -85,23 +89,23 @@
     </AssetSelectControlBar>
   {:else}
     <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}>
-      <svelte:fragment slot="leading">
+      {#snippet leading()}
         <ImmichLogoSmallLink width={innerWidth} />
-      </svelte:fragment>
+      {/snippet}
 
-      <svelte:fragment slot="trailing">
+      {#snippet trailing()}
         {#if sharedLink?.allowUpload}
           <CircleIconButton
             title={$t('add_photos')}
-            on:click={() => handleUploadAssets()}
+            onclick={() => handleUploadAssets()}
             icon={mdiFileImagePlusOutline}
           />
         {/if}
 
         {#if sharedLink?.allowDownload}
-          <CircleIconButton title={$t('download')} on:click={downloadAssets} icon={mdiFolderDownloadOutline} />
+          <CircleIconButton title={$t('download')} onclick={downloadAssets} icon={mdiFolderDownloadOutline} />
         {/if}
-      </svelte:fragment>
+      {/snippet}
     </ControlAppBar>
   {/if}
   <section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte
index 65f39ccb16..3400864efd 100644
--- a/web/src/lib/components/shared-components/album-selection-modal.svelte
+++ b/web/src/lib/components/shared-components/album-selection-modal.svelte
@@ -11,17 +11,19 @@
   import { sortAlbums } from '$lib/utils/album-utils';
   import { albumViewSettings } from '$lib/stores/preferences.store';
 
-  export let onNewAlbum: (search: string) => void;
-  export let onAlbumClick: (album: AlbumResponseDto) => void;
+  let albums: AlbumResponseDto[] = $state([]);
+  let recentAlbums: AlbumResponseDto[] = $state([]);
+  let loading = $state(true);
+  let search = $state('');
 
-  let albums: AlbumResponseDto[] = [];
-  let recentAlbums: AlbumResponseDto[] = [];
-  let filteredAlbums: AlbumResponseDto[] = [];
-  let loading = true;
-  let search = '';
+  interface Props {
+    onNewAlbum: (search: string) => void;
+    onAlbumClick: (album: AlbumResponseDto) => void;
+    shared: boolean;
+    onClose: () => void;
+  }
 
-  export let shared: boolean;
-  export let onClose: () => void;
+  let { onNewAlbum, onAlbumClick, shared, onClose }: Props = $props();
 
   onMount(async () => {
     albums = await getAllAlbums({ shared: shared || undefined });
@@ -29,13 +31,15 @@
     loading = false;
   });
 
-  $: filteredAlbums = sortAlbums(
-    search.length > 0 && albums.length > 0
-      ? albums.filter((album) => {
-          return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
-        })
-      : albums,
-    { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder },
+  let filteredAlbums = $derived(
+    sortAlbums(
+      search.length > 0 && albums.length > 0
+        ? albums.filter((album) => {
+            return normalizeSearchString(album.albumName).includes(normalizeSearchString(search));
+          })
+        : albums,
+      { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder },
+    ),
   );
 
   const getTitle = () => {
@@ -71,7 +75,7 @@
       <div class="immich-scrollbar overflow-y-auto">
         <button
           type="button"
-          on:click={() => onNewAlbum(search)}
+          onclick={() => onNewAlbum(search)}
           class="flex w-full items-center gap-4 px-6 py-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
         >
           <div class="flex h-12 w-12 items-center justify-center">
diff --git a/web/src/lib/components/shared-components/autogrow-textarea.svelte b/web/src/lib/components/shared-components/autogrow-textarea.svelte
index efbcf218e6..5bb4637e05 100644
--- a/web/src/lib/components/shared-components/autogrow-textarea.svelte
+++ b/web/src/lib/components/shared-components/autogrow-textarea.svelte
@@ -3,23 +3,23 @@
   import { shortcut } from '$lib/actions/shortcut';
   import { tick } from 'svelte';
 
-  export let content: string = '';
-  let className: string = '';
-  export { className as class };
-  export let onContentUpdate: (newContent: string) => void = () => null;
-  export let placeholder: string = '';
+  interface Props {
+    content?: string;
+    class?: string;
+    onContentUpdate?: (newContent: string) => void;
+    placeholder?: string;
+  }
 
-  let textarea: HTMLTextAreaElement;
-  $: newContent = content;
+  let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
 
-  $: {
-    // re-visit with svelte 5. runes will make this better.
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    newContent;
+  let textarea: HTMLTextAreaElement | undefined = $state();
+  let newContent = $state(content);
+
+  $effect(() => {
     if (textarea && newContent.length > 0) {
       void tick().then(() => autoGrowHeight(textarea));
     }
-  }
+  });
 
   const updateContent = () => {
     if (content === newContent) {
@@ -32,8 +32,8 @@
 <textarea
   bind:this={textarea}
   class="resize-none {className}"
-  on:focusout={updateContent}
-  on:input={(e) => (newContent = e.currentTarget.value)}
+  onfocusout={updateContent}
+  oninput={(e) => (newContent = e.currentTarget.value)}
   {placeholder}
   use:shortcut={{
     shortcut: { key: 'Enter', ctrl: true },
diff --git a/web/src/lib/components/shared-components/change-date.spec.ts b/web/src/lib/components/shared-components/change-date.spec.ts
index 112e900c02..815acac5ab 100644
--- a/web/src/lib/components/shared-components/change-date.spec.ts
+++ b/web/src/lib/components/shared-components/change-date.spec.ts
@@ -16,6 +16,16 @@ describe('ChangeDate component', () => {
 
   beforeEach(() => {
     vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
+
+    vi.stubGlobal('visualViewport', {
+      height: window.innerHeight,
+      width: window.innerWidth,
+      scale: 1,
+      offsetLeft: 0,
+      offsetTop: 0,
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+    });
   });
 
   afterEach(() => {
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte
index 8ceda5f1d6..13b2752f0c 100644
--- a/web/src/lib/components/shared-components/change-date.svelte
+++ b/web/src/lib/components/shared-components/change-date.svelte
@@ -1,14 +1,18 @@
 <script lang="ts">
   import { DateTime } from 'luxon';
   import ConfirmDialog from './dialog/confirm-dialog.svelte';
-  import Combobox from './combobox.svelte';
+  import Combobox, { type ComboBoxOption } from './combobox.svelte';
   import DateInput from '../elements/date-input.svelte';
   import { t } from 'svelte-i18n';
 
-  export let initialDate: DateTime = DateTime.now();
-  export let initialTimeZone: string = '';
-  export let onCancel: () => void;
-  export let onConfirm: (date: string) => void;
+  interface Props {
+    initialDate?: DateTime;
+    initialTimeZone?: string;
+    onCancel: () => void;
+    onConfirm: (date: string) => void;
+  }
+
+  let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
 
   type ZoneOption = {
     /**
@@ -49,21 +53,15 @@
 
   const knownTimezones = Intl.supportedValuesOf('timeZone');
 
-  let timezones: ZoneOption[];
-  $: timezones = knownTimezones
+  const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+  let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
+  let timezones: ZoneOption[] = knownTimezones
     .map((zone) => zoneOptionForDate(zone, selectedDate))
     .filter((zone) => zone.valid)
     .sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
-
-  const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
   // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
-  let selectedOption: ZoneOption | undefined;
-  $: selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, selectedOption);
-
-  let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
-
-  // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
-  $: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true });
+  let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
 
   function zoneOptionForDate(zone: string, date: string) {
     const dateAtZone: DateTime = DateTime.fromISO(date, { zone });
@@ -125,6 +123,14 @@
       onConfirm(value);
     }
   };
+
+  const handleOnSelect = (option?: ComboBoxOption) => {
+    if (option) {
+      selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption);
+    }
+  };
+  // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
+  let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
 </script>
 
 <ConfirmDialog
@@ -135,13 +141,23 @@
   onConfirm={handleConfirm}
   {onCancel}
 >
-  <div class="flex flex-col text-left gap-2" slot="prompt">
-    <div class="flex flex-col">
-      <label for="datetime">{$t('date_and_time')}</label>
-      <DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
+  <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
+  <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component -->
+  {#snippet promptSnippet()}
+    <div class="flex flex-col text-left gap-2">
+      <div class="flex flex-col">
+        <label for="datetime">{$t('date_and_time')}</label>
+        <DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
+      </div>
+      <div>
+        <Combobox
+          bind:selectedOption
+          label={$t('timezone')}
+          options={timezones}
+          placeholder={$t('search_timezone')}
+          onSelect={(option) => handleOnSelect(option)}
+        />
+      </div>
     </div>
-    <div>
-      <Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
-    </div>
-  </div>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte
index 573c9ab38b..fa050d39c2 100644
--- a/web/src/lib/components/shared-components/change-location.svelte
+++ b/web/src/lib/components/shared-components/change-location.svelte
@@ -12,39 +12,44 @@
   import { listNavigation } from '$lib/actions/list-navigation';
   import { t } from 'svelte-i18n';
   import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
+  import Map from '$lib/components/shared-components/map/map.svelte';
 
   interface Point {
     lng: number;
     lat: number;
   }
 
-  export let asset: AssetResponseDto | undefined = undefined;
-  export let onCancel: () => void;
-  export let onConfirm: (point: Point) => void;
+  interface Props {
+    asset?: AssetResponseDto | undefined;
+    onCancel: () => void;
+    onConfirm: (point: Point) => void;
+  }
 
-  let places: PlacesResponseDto[] = [];
-  let suggestedPlaces: PlacesResponseDto[] = [];
-  let searchWord: string;
+  let { asset = undefined, onCancel, onConfirm }: Props = $props();
+
+  let places: PlacesResponseDto[] = $state([]);
+  let suggestedPlaces: PlacesResponseDto[] = $state([]);
+  let searchWord: string = $state('');
   let latestSearchTimeout: number;
-  let showLoadingSpinner = false;
-  let suggestionContainer: HTMLDivElement;
-  let hideSuggestion = false;
-  let addClipMapMarker: (long: number, lat: number) => void;
+  let showLoadingSpinner = $state(false);
+  let suggestionContainer: HTMLDivElement | undefined = $state();
+  let hideSuggestion = $state(false);
+  let mapElement = $state<ReturnType<typeof Map>>();
 
-  $: lat = asset?.exifInfo?.latitude ?? undefined;
-  $: lng = asset?.exifInfo?.longitude ?? undefined;
-  $: zoom = lat !== undefined && lng !== undefined ? 12.5 : 1;
+  let lat = $derived(asset?.exifInfo?.latitude ?? undefined);
+  let lng = $derived(asset?.exifInfo?.longitude ?? undefined);
+  let zoom = $derived(lat !== undefined && lng !== undefined ? 12.5 : 1);
 
-  $: {
+  $effect(() => {
     if (places) {
       suggestedPlaces = places.slice(0, 5);
     }
     if (searchWord === '') {
       suggestedPlaces = [];
     }
-  }
+  });
 
-  let point: Point | null = null;
+  let point: Point | null = $state(null);
 
   const handleConfirm = () => {
     if (point) {
@@ -94,88 +99,95 @@
   const handleUseSuggested = (latitude: number, longitude: number) => {
     hideSuggestion = true;
     point = { lng: longitude, lat: latitude };
-    addClipMapMarker(longitude, latitude);
+    mapElement?.addClipMapMarker(longitude, latitude);
   };
 </script>
 
 <ConfirmDialog confirmColor="primary" title={$t('change_location')} width="wide" onConfirm={handleConfirm} {onCancel}>
-  <div slot="prompt" class="flex flex-col w-full h-full gap-2">
-    <div
-      class="relative w-64 sm:w-96"
-      use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
-      use:listNavigation={suggestionContainer}
-    >
-      <button type="button" class="w-full" on:click={() => (hideSuggestion = false)}>
-        <SearchBar
-          placeholder={$t('search_places')}
-          bind:name={searchWord}
-          {showLoadingSpinner}
-          onReset={() => (suggestedPlaces = [])}
-          onSearch={handleSearchPlaces}
-          roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
-        />
-      </button>
-      <div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}>
-        {#if !hideSuggestion}
-          {#each suggestedPlaces as place, index}
-            <button
-              type="button"
-              class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
-              suggestedPlaces.length - 1
-                ? 'rounded-b-lg border-b'
-                : ''}"
-              on:click={() => handleUseSuggested(place.latitude, place.longitude)}
-            >
-              <p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
-                {getLocation(place.name, place.admin1name, place.admin2name)}
-              </p>
+  {#snippet promptSnippet()}
+    <div class="flex flex-col w-full h-full gap-2">
+      <div class="relative w-64 sm:w-96">
+        {#if suggestionContainer}
+          <div
+            use:clickOutside={{ onOutclick: () => (hideSuggestion = true) }}
+            use:listNavigation={suggestionContainer}
+          >
+            <button type="button" class="w-full" onclick={() => (hideSuggestion = false)}>
+              <SearchBar
+                placeholder={$t('search_places')}
+                bind:name={searchWord}
+                {showLoadingSpinner}
+                onReset={() => (suggestedPlaces = [])}
+                onSearch={handleSearchPlaces}
+                roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
+              />
             </button>
-          {/each}
+          </div>
         {/if}
+
+        <div class="absolute z-[99] w-full" id="suggestion" bind:this={suggestionContainer}>
+          {#if !hideSuggestion}
+            {#each suggestedPlaces as place, index}
+              <button
+                type="button"
+                class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
+                suggestedPlaces.length - 1
+                  ? 'rounded-b-lg border-b'
+                  : ''}"
+                onclick={() => handleUseSuggested(place.latitude, place.longitude)}
+              >
+                <p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
+                  {getLocation(place.name, place.admin1name, place.admin2name)}
+                </p>
+              </button>
+            {/each}
+          {/if}
+        </div>
+      </div>
+
+      <span>{$t('pick_a_location')}</span>
+      <div class="h-[500px] min-h-[300px] w-full">
+        {#await import('../shared-components/map/map.svelte')}
+          {#await delay(timeToLoadTheMap) then}
+            <!-- show the loading spinner only if loading the map takes too much time -->
+            <div class="flex items-center justify-center h-full w-full">
+              <LoadingSpinner />
+            </div>
+          {/await}
+        {:then { default: Map }}
+          <Map
+            bind:this={mapElement}
+            mapMarkers={lat !== undefined && lng !== undefined && asset
+              ? [
+                  {
+                    id: asset.id,
+                    lat,
+                    lon: lng,
+                    city: asset.exifInfo?.city ?? null,
+                    state: asset.exifInfo?.state ?? null,
+                    country: asset.exifInfo?.country ?? null,
+                  },
+                ]
+              : []}
+            {zoom}
+            center={lat && lng ? { lat, lng } : undefined}
+            simplified={true}
+            clickable={true}
+            onClickPoint={(selected) => (point = selected)}
+          />
+        {/await}
+      </div>
+
+      <div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4">
+        <CoordinatesInput
+          lat={point ? point.lat : lat}
+          lng={point ? point.lng : lng}
+          onUpdate={(lat, lng) => {
+            point = { lat, lng };
+            mapElement?.addClipMapMarker(lng, lat);
+          }}
+        />
       </div>
     </div>
-    <span>{$t('pick_a_location')}</span>
-    <div class="h-[500px] min-h-[300px] w-full">
-      {#await import('../shared-components/map/map.svelte')}
-        {#await delay(timeToLoadTheMap) then}
-          <!-- show the loading spinner only if loading the map takes too much time -->
-          <div class="flex items-center justify-center h-full w-full">
-            <LoadingSpinner />
-          </div>
-        {/await}
-      {:then { default: Map }}
-        <Map
-          mapMarkers={lat !== undefined && lng !== undefined && asset
-            ? [
-                {
-                  id: asset.id,
-                  lat,
-                  lon: lng,
-                  city: asset.exifInfo?.city ?? null,
-                  state: asset.exifInfo?.state ?? null,
-                  country: asset.exifInfo?.country ?? null,
-                },
-              ]
-            : []}
-          {zoom}
-          bind:addClipMapMarker
-          center={lat && lng ? { lat, lng } : undefined}
-          simplified={true}
-          clickable={true}
-          onClickPoint={(selected) => (point = selected)}
-        />
-      {/await}
-    </div>
-
-    <div class="grid sm:grid-cols-2 gap-4 text-sm text-left mt-4">
-      <CoordinatesInput
-        lat={point ? point.lat : lat}
-        lng={point ? point.lng : lng}
-        onUpdate={(lat, lng) => {
-          point = { lat, lng };
-          addClipMapMarker(lng, lat);
-        }}
-      />
-    </div>
-  </div>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index c89d0d34f2..b17644f137 100644
--- a/web/src/lib/components/shared-components/combobox.svelte
+++ b/web/src/lib/components/shared-components/combobox.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type ComboBoxOption = {
     id?: string;
     label: string;
@@ -30,12 +30,23 @@
   import { t } from 'svelte-i18n';
   import { get } from 'svelte/store';
 
-  export let label: string;
-  export let hideLabel = false;
-  export let options: ComboBoxOption[] = [];
-  export let selectedOption: ComboBoxOption | undefined = undefined;
-  export let placeholder = '';
-  export let onSelect: (option: ComboBoxOption | undefined) => void = () => {};
+  interface Props {
+    label: string;
+    hideLabel?: boolean;
+    options?: ComboBoxOption[];
+    selectedOption?: ComboBoxOption | undefined;
+    placeholder?: string;
+    onSelect?: (option: ComboBoxOption | undefined) => void;
+  }
+
+  let {
+    label,
+    hideLabel = false,
+    options = [],
+    selectedOption = $bindable(),
+    placeholder = '',
+    onSelect = () => {},
+  }: Props = $props();
 
   /**
    * Unique identifier for the combobox.
@@ -44,17 +55,16 @@
   /**
    * Indicates whether or not the dropdown autocomplete list should be visible.
    */
-  let isOpen = false;
+  let isOpen = $state(false);
   /**
    * Keeps track of whether the combobox is actively being used.
    */
-  let isActive = false;
-  let searchQuery = selectedOption?.label || '';
-  let selectedIndex: number | undefined;
-  let optionRefs: HTMLElement[] = [];
-  let input: HTMLInputElement;
-  let bounds: DOMRect | undefined;
-  let dropdownDirection: 'bottom' | 'top' = 'bottom';
+  let isActive = $state(false);
+  let searchQuery = $state(selectedOption?.label || '');
+  let selectedIndex: number | undefined = $state();
+  let optionRefs: HTMLElement[] = $state([]);
+  let input = $state<HTMLInputElement>();
+  let bounds: DOMRect | undefined = $state();
 
   const inputId = `combobox-${id}`;
   const listboxId = `listbox-${id}`;
@@ -76,17 +86,12 @@
     { threshold: 0.5 },
   );
 
-  $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
-
-  $: {
-    searchQuery = selectedOption ? selectedOption.label : '';
-  }
-
-  $: position = calculatePosition(bounds);
-
   onMount(() => {
+    if (!input) {
+      return;
+    }
     observer.observe(input);
-    const scrollableAncestor = input.closest('.overflow-y-auto, .overflow-y-scroll');
+    const scrollableAncestor = input?.closest('.overflow-y-auto, .overflow-y-scroll');
     scrollableAncestor?.addEventListener('scroll', onPositionChange);
     window.visualViewport?.addEventListener('resize', onPositionChange);
     window.visualViewport?.addEventListener('scroll', onPositionChange);
@@ -157,7 +162,6 @@
 
   const calculatePosition = (boundary: DOMRect | undefined) => {
     const visualViewport = window.visualViewport;
-    dropdownDirection = getComboboxDirection(boundary, visualViewport);
 
     if (!boundary) {
       return;
@@ -212,9 +216,19 @@
   };
 
   const getInputPosition = () => input?.getBoundingClientRect();
+
+  $effect(() => {
+    // searchQuery = selectedOption ? selectedOption.label : '';
+  });
+
+  let filteredOptions = $derived(
+    options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())),
+  );
+  let position = $derived(calculatePosition(bounds));
+  let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport));
 </script>
 
-<svelte:window on:resize={onPositionChange} />
+<svelte:window onresize={onPositionChange} />
 <label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
 <div
   class="relative w-full dark:text-gray-300 text-gray-700 text-base"
@@ -252,9 +266,9 @@
       class:cursor-pointer={!isActive}
       class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
       id={inputId}
-      on:click={activate}
-      on:focus={activate}
-      on:input={onInput}
+      onclick={activate}
+      onfocus={activate}
+      oninput={onInput}
       role="combobox"
       type="text"
       value={searchQuery}
@@ -304,7 +318,7 @@
       class:pointer-events-none={!selectedOption}
     >
       {#if selectedOption}
-        <CircleIconButton on:click={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" />
+        <CircleIconButton onclick={onClear} title={$t('clear_value')} icon={mdiClose} size="16" padding="2" />
       {:else if !isOpen}
         <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
       {/if}
@@ -329,26 +343,26 @@
   >
     {#if isOpen}
       {#if filteredOptions.length === 0}
-        <!-- svelte-ignore a11y-click-events-have-key-events -->
+        <!-- svelte-ignore a11y_click_events_have_key_events -->
         <li
           role="option"
           aria-selected={selectedIndex === 0}
           aria-disabled={true}
           class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700"
           id={`${listboxId}-${0}`}
-          on:click={() => closeDropdown()}
+          onclick={() => closeDropdown()}
         >
           {$t('no_results')}
         </li>
       {/if}
       {#each filteredOptions as option, index (option.id || option.label)}
-        <!-- svelte-ignore a11y-click-events-have-key-events -->
+        <!-- svelte-ignore a11y_click_events_have_key_events -->
         <li
           aria-selected={index === selectedIndex}
           bind:this={optionRefs[index]}
           class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
           id={`${listboxId}-${index}`}
-          on:click={() => handleSelect(option)}
+          onclick={() => handleSelect(option)}
           role="option"
         >
           {option.label}
diff --git a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte
index f1ee93cc50..46dc17b9ad 100644
--- a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte
+++ b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte
@@ -14,41 +14,52 @@
   import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
   import { clickOutside } from '$lib/actions/click-outside';
   import { shortcuts } from '$lib/actions/shortcut';
+  import type { Snippet } from 'svelte';
 
-  export let icon: string;
-  export let title: string;
-  /**
-   * The alignment of the context menu relative to the button.
-   */
-  export let align: Align = 'top-left';
-  /**
-   * The direction in which the context menu should open.
-   */
-  export let direction: 'left' | 'right' = 'right';
-  export let color: Color = 'transparent';
-  export let size: string | undefined = undefined;
-  export let padding: Padding | undefined = undefined;
-  /**
-   * Additional classes to apply to the button.
-   */
-  export let buttonClass: string | undefined = undefined;
-  export let hideContent = false;
+  interface Props {
+    icon: string;
+    title: string;
+    /**
+     * The alignment of the context menu relative to the button.
+     */
+    align?: Align;
+    /**
+     * The direction in which the context menu should open.
+     */
+    direction?: 'left' | 'right';
+    color?: Color;
+    size?: string | undefined;
+    padding?: Padding | undefined;
+    /**
+     * Additional classes to apply to the button.
+     */
+    buttonClass?: string | undefined;
+    hideContent?: boolean;
+    children?: Snippet;
+  }
 
-  let isOpen = false;
-  let contextMenuPosition = { x: 0, y: 0 };
-  let menuContainer: HTMLUListElement;
-  let buttonContainer: HTMLDivElement;
+  let {
+    icon,
+    title,
+    align = 'top-left',
+    direction = 'right',
+    color = 'transparent',
+    size = undefined,
+    padding = undefined,
+    buttonClass = undefined,
+    hideContent = false,
+    children,
+  }: Props = $props();
+
+  let isOpen = $state(false);
+  let contextMenuPosition = $state({ x: 0, y: 0 });
+  let menuContainer: HTMLUListElement | undefined = $state();
+  let buttonContainer: HTMLDivElement | undefined = $state();
 
   const id = generateId();
   const buttonId = `context-menu-button-${id}`;
   const menuId = `context-menu-${id}`;
 
-  $: {
-    if (isOpen) {
-      $optionClickCallbackStore = handleOptionClick;
-    }
-  }
-
   const openDropdown = (event: KeyboardEvent | MouseEvent) => {
     contextMenuPosition = getContextMenuPositionFromEvent(event, align);
     isOpen = true;
@@ -72,9 +83,10 @@
   };
 
   const onResize = () => {
-    if (!isOpen) {
+    if (!isOpen || !buttonContainer) {
       return;
     }
+
     contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
   };
 
@@ -92,12 +104,19 @@
   };
 
   const focusButton = () => {
-    const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
+    const button = buttonContainer?.querySelector(`#${buttonId}`) as HTMLButtonElement | null;
     button?.focus();
   };
+
+  $effect(() => {
+    if (isOpen) {
+      $optionClickCallbackStore = handleOptionClick;
+    }
+  });
 </script>
 
-<svelte:window on:resize={onResize} />
+<svelte:window onresize={onResize} />
+
 <div
   use:contextMenuNavigation={{
     closeDropdown,
@@ -109,7 +128,7 @@
     selectionChanged: (id) => ($selectedIdStore = id),
   }}
   use:clickOutside={{ onOutclick: closeDropdown }}
-  on:resize={onResize}
+  onresize={onResize}
 >
   <div bind:this={buttonContainer}>
     <CircleIconButton
@@ -123,7 +142,7 @@
       aria-haspopup={true}
       class={buttonClass}
       id={buttonId}
-      on:click={handleClick}
+      onclick={handleClick}
     />
   </div>
   {#if isOpen || !hideContent}
@@ -150,7 +169,7 @@
         id={menuId}
         isVisible={isOpen}
       >
-        <slot />
+        {@render children?.()}
       </ContextMenu>
     </div>
   {/if}
diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte
index 8f5ebfa2cf..aff583d1fc 100644
--- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte
+++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte
@@ -2,27 +2,44 @@
   import { quintOut } from 'svelte/easing';
   import { slide } from 'svelte/transition';
   import { clickOutside } from '$lib/actions/click-outside';
+  import type { Snippet } from 'svelte';
 
-  export let isVisible: boolean = false;
-  export let direction: 'left' | 'right' = 'right';
-  export let x = 0;
-  export let y = 0;
-  export let id: string | undefined = undefined;
-  export let ariaLabel: string | undefined = undefined;
-  export let ariaLabelledBy: string | undefined = undefined;
-  export let ariaActiveDescendant: string | undefined = undefined;
+  interface Props {
+    isVisible?: boolean;
+    direction?: 'left' | 'right';
+    x?: number;
+    y?: number;
+    id?: string | undefined;
+    ariaLabel?: string | undefined;
+    ariaLabelledBy?: string | undefined;
+    ariaActiveDescendant?: string | undefined;
+    menuElement?: HTMLUListElement | undefined;
+    onClose?: (() => void) | undefined;
+    children?: Snippet;
+  }
 
-  export let menuElement: HTMLUListElement | undefined = undefined;
-  export let onClose: (() => void) | undefined = undefined;
+  let {
+    isVisible = false,
+    direction = 'right',
+    x = 0,
+    y = 0,
+    id = undefined,
+    ariaLabel = undefined,
+    ariaLabelledBy = undefined,
+    ariaActiveDescendant = undefined,
+    menuElement = $bindable(),
+    onClose = undefined,
+    children,
+  }: Props = $props();
 
-  let left: number;
-  let top: number;
+  let left: number = $state(0);
+  let top: number = $state(0);
 
   // We need to bind clientHeight since the bounding box may return a height
   // of zero when starting the 'slide' animation.
-  let height: number;
+  let height: number = $state(0);
 
-  $: {
+  $effect(() => {
     if (menuElement) {
       const rect = menuElement.getBoundingClientRect();
       const directionWidth = direction === 'left' ? rect.width : 0;
@@ -31,7 +48,7 @@
       left = Math.min(window.innerWidth - rect.width, x - directionWidth);
       top = Math.min(window.innerHeight - menuHeight, y);
     }
-  }
+  });
 </script>
 
 <div
@@ -54,6 +71,6 @@
     role="menu"
     tabindex="-1"
   >
-    <slot />
+    {@render children?.()}
   </ul>
 </div>
diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte
index e7ff4c626e..5d3c29dc3c 100644
--- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte
+++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte
@@ -3,16 +3,27 @@
   import { generateId } from '$lib/utils/generate-id';
   import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
 
-  export let text: string;
-  export let subtitle = '';
-  export let icon = '';
-  export let activeColor = 'bg-slate-300';
-  export let textColor = 'text-immich-fg dark:text-immich-dark-bg';
-  export let onClick: () => void;
+  interface Props {
+    text: string;
+    subtitle?: string;
+    icon?: string;
+    activeColor?: string;
+    textColor?: string;
+    onClick: () => void;
+  }
+
+  let {
+    text,
+    subtitle = '',
+    icon = '',
+    activeColor = 'bg-slate-300',
+    textColor = 'text-immich-fg dark:text-immich-dark-bg',
+    onClick,
+  }: Props = $props();
 
   let id: string = generateId();
 
-  $: isActive = $selectedIdStore === id;
+  let isActive = $derived($selectedIdStore === id);
 
   const handleClick = () => {
     $optionClickCallbackStore?.();
@@ -20,13 +31,13 @@
   };
 </script>
 
-<!-- svelte-ignore a11y-click-events-have-key-events -->
-<!-- svelte-ignore a11y-mouse-events-have-key-events -->
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_mouse_events_have_key_events -->
 <li
   {id}
-  on:click={handleClick}
-  on:mouseover={() => ($selectedIdStore = id)}
-  on:mouseleave={() => ($selectedIdStore = undefined)}
+  onclick={handleClick}
+  onmouseover={() => ($selectedIdStore = id)}
+  onmouseleave={() => ($selectedIdStore = undefined)}
   class="w-full p-4 text-left text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive
     ? activeColor
     : 'bg-slate-100'}"
diff --git a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
index f0b0408ff9..f0d8f0213a 100644
--- a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
+++ b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
@@ -1,33 +1,30 @@
 <script lang="ts">
-  import { tick } from 'svelte';
+  import { tick, type Snippet } from 'svelte';
   import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
   import { shortcuts } from '$lib/actions/shortcut';
   import { generateId } from '$lib/utils/generate-id';
   import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
   import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
 
-  export let title: string;
-  export let direction: 'left' | 'right' = 'right';
-  export let x = 0;
-  export let y = 0;
-  export let isOpen = false;
-  export let onClose: (() => unknown) | undefined;
+  interface Props {
+    title: string;
+    direction?: 'left' | 'right';
+    x?: number;
+    y?: number;
+    isOpen?: boolean;
+    onClose: (() => unknown) | undefined;
+    children?: Snippet;
+  }
 
-  let uniqueKey = {};
-  let menuContainer: HTMLUListElement;
-  let triggerElement: HTMLElement | undefined = undefined;
+  let { title, direction = 'right', x = 0, y = 0, isOpen = false, onClose, children }: Props = $props();
+
+  let uniqueKey = $state({});
+  let menuContainer: HTMLUListElement | undefined = $state();
+  let triggerElement: HTMLElement | undefined = $state(undefined);
 
   const id = generateId();
   const menuId = `context-menu-${id}`;
 
-  $: {
-    if (isOpen && menuContainer) {
-      triggerElement = document.activeElement as HTMLElement;
-      menuContainer.focus();
-      $optionClickCallbackStore = closeContextMenu;
-    }
-  }
-
   const reopenContextMenu = async (event: MouseEvent) => {
     const contextMenuEvent = new MouseEvent('contextmenu', {
       bubbles: true,
@@ -39,7 +36,7 @@
 
     const elements = document.elementsFromPoint(event.x, event.y);
 
-    if (elements.includes(menuContainer)) {
+    if (menuContainer && elements.includes(menuContainer)) {
       // User right-clicked on the context menu itself, we keep the context
       // menu as is
       return;
@@ -58,6 +55,18 @@
     triggerElement?.focus();
     onClose?.();
   };
+  $effect(() => {
+    if (isOpen && menuContainer) {
+      triggerElement = document.activeElement as HTMLElement;
+      menuContainer.focus();
+      $optionClickCallbackStore = closeContextMenu;
+    }
+  });
+
+  const oncontextmenu = async (event: MouseEvent) => {
+    event.preventDefault();
+    await reopenContextMenu(event);
+  };
 </script>
 
 {#key uniqueKey}
@@ -81,11 +90,7 @@
         },
       ]}
     >
-      <section
-        class="fixed left-0 top-0 z-10 flex h-screen w-screen"
-        on:contextmenu|preventDefault={reopenContextMenu}
-        role="presentation"
-      >
+      <section class="fixed left-0 top-0 z-10 flex h-screen w-screen" {oncontextmenu} role="presentation">
         <ContextMenu
           {direction}
           {x}
@@ -97,7 +102,7 @@
           isVisible
           onClose={closeContextMenu}
         >
-          <slot />
+          {@render children?.()}
         </ContextMenu>
       </section>
     </div>
diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte
index 228cd88a86..c78edaa601 100644
--- a/web/src/lib/components/shared-components/control-app-bar.svelte
+++ b/web/src/lib/components/shared-components/control-app-bar.svelte
@@ -1,23 +1,39 @@
 <script lang="ts">
   import { browser } from '$app/environment';
 
-  import { onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount, type Snippet } from 'svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { fly } from 'svelte/transition';
   import { mdiClose } from '@mdi/js';
   import { isSelectingAllAssets } from '$lib/stores/assets.store';
   import { t } from 'svelte-i18n';
 
-  export let showBackButton = true;
-  export let backIcon = mdiClose;
-  export let tailwindClasses = '';
-  export let forceDark = false;
-  export let onClose: () => void = () => {};
+  interface Props {
+    showBackButton?: boolean;
+    backIcon?: string;
+    tailwindClasses?: string;
+    forceDark?: boolean;
+    onClose?: () => void;
+    leading?: Snippet;
+    children?: Snippet;
+    trailing?: Snippet;
+  }
 
-  let appBarBorder = 'bg-immich-bg border border-transparent';
+  let {
+    showBackButton = true,
+    backIcon = mdiClose,
+    tailwindClasses = '',
+    forceDark = false,
+    onClose = () => {},
+    leading,
+    children,
+    trailing,
+  }: Props = $props();
+
+  let appBarBorder = $state('bg-immich-bg border border-transparent');
 
   const onScroll = () => {
-    if (window.pageYOffset > 80) {
+    if (window.scrollY > 80) {
       appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
 
       if (forceDark) {
@@ -45,7 +61,7 @@
     }
   });
 
-  $: buttonClass = forceDark ? 'hover:text-immich-dark-gray' : undefined;
+  let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined);
 </script>
 
 <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
@@ -57,17 +73,17 @@
   >
     <div class="flex place-items-center sm:gap-6 justify-self-start dark:text-immich-dark-fg">
       {#if showBackButton}
-        <CircleIconButton title={$t('close')} on:click={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
+        <CircleIconButton title={$t('close')} onclick={handleClose} icon={backIcon} size={'24'} class={buttonClass} />
       {/if}
-      <slot name="leading" />
+      {@render leading?.()}
     </div>
 
     <div class="w-full">
-      <slot />
+      {@render children?.()}
     </div>
 
     <div class="mr-4 flex place-items-center gap-1 justify-self-end">
-      <slot name="trailing" />
+      {@render trailing?.()}
     </div>
   </div>
 </div>
diff --git a/web/src/lib/components/shared-components/coordinates-input.svelte b/web/src/lib/components/shared-components/coordinates-input.svelte
index f5ad120a7b..d39cea2fd1 100644
--- a/web/src/lib/components/shared-components/coordinates-input.svelte
+++ b/web/src/lib/components/shared-components/coordinates-input.svelte
@@ -3,9 +3,13 @@
   import { generateId } from '$lib/utils/generate-id';
   import { t } from 'svelte-i18n';
 
-  export let lat: number | null | undefined = undefined;
-  export let lng: number | null | undefined = undefined;
-  export let onUpdate: (lat: number, lng: number) => void;
+  interface Props {
+    lat?: number;
+    lng?: number;
+    onUpdate: (lat: number, lng: number) => void;
+  }
+
+  let { lat = $bindable(), lng = $bindable(), onUpdate }: Props = $props();
 
   const id = generateId();
 
diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
index ea5f801e29..443e8f06b1 100644
--- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
+++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
@@ -8,29 +8,40 @@
   import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
   import { mdiContentCopy, mdiLink } from '@mdi/js';
   import { NotificationType, notificationController } from '../notification/notification';
-  import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
+  import SettingInputField from '../settings/setting-input-field.svelte';
   import SettingSwitch from '../settings/setting-switch.svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { t } from 'svelte-i18n';
   import { locale } from '$lib/stores/preferences.store';
   import { DateTime, Duration } from 'luxon';
   import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let onClose: () => void;
-  export let albumId: string | undefined = undefined;
-  export let assetIds: string[] = [];
-  export let editingLink: SharedLinkResponseDto | undefined = undefined;
-  export let onCreated: () => void = () => {};
+  interface Props {
+    onClose: () => void;
+    albumId?: string | undefined;
+    assetIds?: string[];
+    editingLink?: SharedLinkResponseDto | undefined;
+    onCreated?: () => void;
+  }
 
-  let sharedLink: string | null = null;
-  let description = '';
-  let allowDownload = true;
-  let allowUpload = false;
-  let showMetadata = true;
-  let expirationOption: number = 0;
-  let password = '';
-  let shouldChangeExpirationTime = false;
-  let enablePassword = false;
+  let {
+    onClose,
+    albumId = $bindable(undefined),
+    assetIds = $bindable([]),
+    editingLink = undefined,
+    onCreated = () => {},
+  }: Props = $props();
+
+  let sharedLink: string | null = $state(null);
+  let description = $state('');
+  let allowDownload = $state(true);
+  let allowUpload = $state(false);
+  let showMetadata = $state(true);
+  let expirationOption: number = $state(0);
+  let password = $state('');
+  let shouldChangeExpirationTime = $state(false);
+  let enablePassword = $state(false);
 
   const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
     [30, 'minutes'],
@@ -43,22 +54,23 @@
     [1, 'year'],
   ];
 
-  $: relativeTime = new Intl.RelativeTimeFormat($locale);
-  $: expiredDateOptions = [
+  let relativeTime = $derived(new Intl.RelativeTimeFormat($locale));
+  let expiredDateOptions = $derived([
     { text: $t('never'), value: 0 },
     ...expirationOptions.map(([value, unit]) => ({
       text: relativeTime.format(value, unit),
       value: Duration.fromObject({ [unit]: value }).toMillis(),
     })),
-  ];
+  ]);
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: shareType = albumId ? SharedLinkType.Album : SharedLinkType.Individual;
-  $: {
+  let shareType = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
+
+  $effect(() => {
     if (!showMetadata) {
       allowDownload = false;
     }
-  }
+  });
+
   if (editingLink) {
     if (editingLink.description) {
       description = editingLink.description;
@@ -223,22 +235,22 @@
     </div>
   </section>
 
-  <svelte:fragment slot="sticky-bottom">
+  {#snippet stickyBottom()}
     {#if !sharedLink}
       {#if editingLink}
-        <Button size="sm" fullwidth on:click={handleEditLink}>{$t('confirm')}</Button>
+        <Button size="sm" fullwidth onclick={handleEditLink}>{$t('confirm')}</Button>
       {:else}
-        <Button size="sm" fullwidth on:click={handleCreateSharedLink}>{$t('create_link')}</Button>
+        <Button size="sm" fullwidth onclick={handleCreateSharedLink}>{$t('create_link')}</Button>
       {/if}
     {:else}
       <div class="flex w-full gap-2">
         <input class="immich-form-input w-full" bind:value={sharedLink} disabled />
-        <LinkButton on:click={() => (sharedLink ? copyToClipboard(sharedLink) : '')}>
+        <LinkButton onclick={() => (sharedLink ? copyToClipboard(sharedLink) : '')}>
           <div class="flex place-items-center gap-2 text-sm">
             <Icon path={mdiContentCopy} ariaLabel={$t('copy_link_to_clipboard')} size="18" />
           </div>
         </LinkButton>
       </div>
     {/if}
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
index 50d5fe56ce..3efc56dc41 100644
--- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
+++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte
@@ -3,18 +3,37 @@
   import Button from '../../elements/buttons/button.svelte';
   import type { Color } from '$lib/components/elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let title = $t('confirm');
-  export let prompt = $t('are_you_sure_to_do_this');
-  export let confirmText = $t('confirm');
-  export let confirmColor: Color = 'red';
-  export let cancelText = $t('cancel');
-  export let cancelColor: Color = 'secondary';
-  export let hideCancelButton = false;
-  export let disabled = false;
-  export let width: 'wide' | 'narrow' = 'narrow';
-  export let onCancel: () => void;
-  export let onConfirm: () => void;
+  interface Props {
+    title?: string;
+    prompt?: string;
+    confirmText?: string;
+    confirmColor?: Color;
+    cancelText?: string;
+    cancelColor?: Color;
+    hideCancelButton?: boolean;
+    disabled?: boolean;
+    width?: 'wide' | 'narrow';
+    onCancel: () => void;
+    onConfirm: () => void;
+    promptSnippet?: Snippet;
+  }
+
+  let {
+    title = $t('confirm'),
+    prompt = $t('are_you_sure_to_do_this'),
+    confirmText = $t('confirm'),
+    confirmColor = 'red',
+    cancelText = $t('cancel'),
+    cancelColor = 'secondary',
+    hideCancelButton = false,
+    disabled = false,
+    width = 'narrow',
+    onCancel,
+    onConfirm,
+    promptSnippet,
+  }: Props = $props();
 
   const handleConfirm = () => {
     onConfirm();
@@ -23,19 +42,19 @@
 
 <FullScreenModal {title} onClose={onCancel} {width}>
   <div class="text-md py-5 text-center">
-    <slot name="prompt">
+    {#if promptSnippet}{@render promptSnippet()}{:else}
       <p>{prompt}</p>
-    </slot>
+    {/if}
   </div>
 
-  <svelte:fragment slot="sticky-bottom">
+  {#snippet stickyBottom()}
     {#if !hideCancelButton}
-      <Button color={cancelColor} fullwidth on:click={onCancel}>
+      <Button color={cancelColor} fullwidth onclick={onCancel}>
         {cancelText}
       </Button>
     {/if}
-    <Button color={confirmColor} fullwidth on:click={handleConfirm} {disabled}>
+    <Button color={confirmColor} fullwidth onclick={handleConfirm} {disabled}>
       {confirmText}
     </Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
index 6f92d81886..620064ca1e 100644
--- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
+++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte
@@ -8,10 +8,10 @@
   import { fade } from 'svelte/transition';
   import ImmichLogo from './immich-logo.svelte';
 
-  $: albumId = isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined;
-  $: isShare = isSharedLinkRoute($page.route?.id);
+  let albumId = $derived(isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined);
+  let isShare = $derived(isSharedLinkRoute($page.route?.id));
 
-  let dragStartTarget: EventTarget | null = null;
+  let dragStartTarget: EventTarget | null = $state(null);
 
   const onDragEnter = (e: DragEvent) => {
     if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
@@ -117,26 +117,41 @@
       await fileUploadHandler(filesArray, albumId);
     }
   };
+
+  const ondragenter = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    onDragEnter(e);
+  };
+
+  const ondragleave = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    onDragLeave(e);
+  };
+
+  const ondrop = async (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    await onDrop(e);
+  };
+
+  const onDragOver = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+  };
 </script>
 
-<svelte:window on:paste={onPaste} />
+<svelte:window onpaste={onPaste} />
 
-<svelte:body
-  on:dragenter|stopPropagation|preventDefault={onDragEnter}
-  on:dragleave|stopPropagation|preventDefault={onDragLeave}
-  on:drop|stopPropagation|preventDefault={onDrop}
-/>
+<svelte:body {ondragenter} {ondragleave} {ondrop} />
 
 {#if dragStartTarget}
-  <!-- svelte-ignore a11y-no-static-element-interactions -->
+  <!-- svelte-ignore a11y_no_static_element_interactions -->
   <div
     class="fixed inset-0 z-[1000] flex h-full w-full flex-col items-center justify-center bg-gray-100/90 text-immich-dark-gray dark:bg-immich-dark-bg/90 dark:text-immich-gray"
     transition:fade={{ duration: 250 }}
-    on:dragover={(e) => {
-      // Prevent browser from opening the dropped file.
-      e.stopPropagation();
-      e.preventDefault();
-    }}
+    ondragover={onDragOver}
   >
     <ImmichLogo noText class="m-16 w-48 animate-bounce" />
     <div class="text-2xl">{$t('drop_files_to_upload')}</div>
diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte
index 781f7821f1..922d7ad92f 100644
--- a/web/src/lib/components/shared-components/empty-placeholder.svelte
+++ b/web/src/lib/components/shared-components/empty-placeholder.svelte
@@ -1,22 +1,26 @@
 <script lang="ts">
   import empty1Url from '$lib/assets/empty-1.svg';
 
-  export let onClick: undefined | (() => unknown) = undefined;
-  export let text: string;
-  export let fullWidth = false;
-  export let src = empty1Url;
+  interface Props {
+    onClick?: undefined | (() => unknown);
+    text: string;
+    fullWidth?: boolean;
+    src?: string;
+  }
 
-  $: width = fullWidth ? 'w-full' : 'w-1/2';
+  let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props();
+
+  let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
 
   const hoverClasses = onClick
     ? `border dark:border-immich-dark-gray hover:bg-immich-primary/5 dark:hover:bg-immich-dark-primary/25`
     : '';
 </script>
 
-<!-- svelte-ignore a11y-no-static-element-interactions -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <svelte:element
   this={onClick ? 'button' : 'div'}
-  on:click={onClick}
+  onclick={onClick}
   class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
 >
   <img {src} alt="" width="500" draggable="false" />
diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte
index ab85155276..1263aed03b 100644
--- a/web/src/lib/components/shared-components/full-screen-modal.svelte
+++ b/web/src/lib/components/shared-components/full-screen-modal.svelte
@@ -4,36 +4,52 @@
   import { fade } from 'svelte/transition';
   import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
   import { generateId } from '$lib/utils/generate-id';
+  import type { Snippet } from 'svelte';
 
-  export let onClose: () => void;
-  export let title: string;
-  /**
-   * If true, the logo will be displayed next to the modal title.
-   */
-  export let showLogo = false;
-  /**
-   * Optional icon to display next to the modal title, if `showLogo` is false.
-   */
-  export let icon: string | undefined = undefined;
-  /**
-   * Sets the width of the modal.
-   *
-   * - `wide`: 48rem
-   * - `narrow`: 28rem
-   * - `auto`: fits the width of the modal content, up to a maximum of 32rem
-   */
-  export let width: 'extra-wide' | 'wide' | 'narrow' | 'auto' = 'narrow';
+  interface Props {
+    onClose: () => void;
+    title: string;
+    /**
+     * If true, the logo will be displayed next to the modal title.
+     */
+    showLogo?: boolean;
+    /**
+     * Optional icon to display next to the modal title, if `showLogo` is false.
+     */
+    icon?: string | undefined;
+    /**
+     * Sets the width of the modal.
+     *
+     * - `wide`: 48rem
+     * - `narrow`: 28rem
+     * - `auto`: fits the width of the modal content, up to a maximum of 32rem
+     */
+    width?: 'extra-wide' | 'wide' | 'narrow' | 'auto';
+    stickyBottom?: Snippet;
+    children?: Snippet;
+  }
+
+  let {
+    onClose,
+    title,
+    showLogo = false,
+    icon = undefined,
+    width = 'narrow',
+    stickyBottom,
+    children,
+  }: Props = $props();
 
   /**
    * Unique identifier for the modal.
    */
   let id: string = generateId();
 
-  $: titleId = `${id}-title`;
-  $: isStickyBottom = !!$$slots['sticky-bottom'];
+  let titleId = $derived(`${id}-title`);
+  let isStickyBottom = $derived(!!stickyBottom);
 
-  let modalWidth: string;
-  $: {
+  let modalWidth = $state<string>();
+
+  $effect(() => {
     switch (width) {
       case 'extra-wide': {
         modalWidth = 'w-[56rem]';
@@ -54,7 +70,7 @@
         modalWidth = 'sm:max-w-4xl';
       }
     }
-  }
+  });
 </script>
 
 <section
@@ -62,7 +78,7 @@
   in:fade={{ duration: 100 }}
   out:fade={{ duration: 100 }}
   class="fixed left-0 top-0 z-[9999] flex h-dvh w-screen place-content-center place-items-center bg-black/40"
-  on:keydown={(event) => {
+  onkeydown={(event) => {
     event.stopPropagation();
   }}
   use:focusTrap
@@ -77,14 +93,14 @@
     <div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}>
       <ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
       <div class="px-5 pt-0 mb-5">
-        <slot />
+        {@render children?.()}
       </div>
     </div>
     {#if isStickyBottom}
       <div
         class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500"
       >
-        <slot name="sticky-bottom" />
+        {@render stickyBottom?.()}
       </div>
     {/if}
   </div>
diff --git a/web/src/lib/components/shared-components/fullscreen-container.svelte b/web/src/lib/components/shared-components/fullscreen-container.svelte
index 6d577f60bd..64ee41a225 100644
--- a/web/src/lib/components/shared-components/fullscreen-container.svelte
+++ b/web/src/lib/components/shared-components/fullscreen-container.svelte
@@ -1,8 +1,15 @@
 <script lang="ts">
+  import type { Snippet } from 'svelte';
   import ImmichLogo from './immich-logo.svelte';
 
-  export let title: string;
-  export let showMessage = $$slots.message;
+  interface Props {
+    title: string;
+    message?: Snippet;
+    showMessage?: boolean;
+    children?: Snippet;
+  }
+
+  let { title, message, showMessage = message != undefined, children }: Props = $props();
 </script>
 
 <section class="min-w-screen flex min-h-screen place-content-center place-items-center p-4">
@@ -20,10 +27,10 @@
       <div
         class="w-full rounded-xl border-2 border-immich-primary bg-immich-primary/5 p-4 text-sm font-medium text-immich-primary dark:border-immich-dark-bg dark:text-immich-dark-primary"
       >
-        <slot name="message" />
+        {@render message?.()}
       </div>
     {/if}
 
-    <slot />
+    {@render children?.()}
   </div>
 </section>
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
index b595a6bb62..35eaf45d53 100644
--- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
+++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
@@ -17,20 +17,34 @@
   import Portal from '../portal/portal.svelte';
   import { handlePromiseError } from '$lib/utils';
 
-  export let assets: AssetResponseDto[];
-  export let selectedAssets: Set<AssetResponseDto> = new Set();
-  export let disableAssetSelect = false;
-  export let showArchiveIcon = false;
-  export let viewport: Viewport;
-  export let onIntersected: (() => void) | undefined = undefined;
-  export let showAssetName = false;
-  export let onPrevious: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined;
-  export let onNext: (() => Promise<AssetResponseDto | undefined>) | undefined = undefined;
+  interface Props {
+    assets: AssetResponseDto[];
+    selectedAssets?: Set<AssetResponseDto>;
+    disableAssetSelect?: boolean;
+    showArchiveIcon?: boolean;
+    viewport: Viewport;
+    onIntersected?: (() => void) | undefined;
+    showAssetName?: boolean;
+    onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined;
+    onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined;
+  }
+
+  let {
+    assets = $bindable(),
+    selectedAssets = $bindable(new Set()),
+    disableAssetSelect = false,
+    showArchiveIcon = false,
+    viewport,
+    onIntersected = undefined,
+    showAssetName = false,
+    onPrevious = undefined,
+    onNext = undefined,
+  }: Props = $props();
 
   let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
 
   let currentViewAssetIndex = 0;
-  $: isMultiSelectionMode = selectedAssets.size > 0;
+  let isMultiSelectionMode = $derived(selectedAssets.size > 0);
 
   const viewAssetHandler = async (asset: AssetResponseDto) => {
     currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
@@ -52,11 +66,15 @@
 
   const handleNext = async () => {
     try {
-      const asset = onNext ? await onNext() : assets[++currentViewAssetIndex];
-      if (asset) {
-        setAsset(asset);
-        await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
+      let asset: AssetResponseDto | undefined;
+      if (onNext) {
+        asset = await onNext();
+      } else {
+        currentViewAssetIndex = Math.min(currentViewAssetIndex + 1, assets.length - 1);
+        asset = assets[currentViewAssetIndex];
       }
+
+      await navigateToAsset(asset);
     } catch (error) {
       handleError(error, $t('errors.cannot_navigate_next_asset'));
     }
@@ -64,16 +82,27 @@
 
   const handlePrevious = async () => {
     try {
-      const asset = onPrevious ? await onPrevious() : assets[--currentViewAssetIndex];
-      if (asset) {
-        setAsset(asset);
-        await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
+      let asset: AssetResponseDto | undefined;
+      if (onPrevious) {
+        asset = await onPrevious();
+      } else {
+        currentViewAssetIndex = Math.max(currentViewAssetIndex - 1, 0);
+        asset = assets[currentViewAssetIndex];
       }
+
+      await navigateToAsset(asset);
     } catch (error) {
       handleError(error, $t('errors.cannot_navigate_previous_asset'));
     }
   };
 
+  const navigateToAsset = async (asset?: AssetResponseDto) => {
+    if (asset && asset.id !== $viewingAsset.id) {
+      setAsset(asset);
+      await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
+    }
+  };
+
   const handleAction = async (action: Action) => {
     switch (action.type) {
       case AssetAction.ARCHIVE:
@@ -100,23 +129,25 @@
     $isViewerOpen = false;
   });
 
-  $: geometry = (() => {
-    const justifiedLayoutResult = justifiedLayout(
-      assets.map((asset) => getAssetRatio(asset)),
-      {
-        boxSpacing: 2,
-        containerWidth: Math.floor(viewport.width),
-        containerPadding: 0,
-        targetRowHeightTolerance: 0.15,
-        targetRowHeight: 235,
-      },
-    );
+  let geometry = $derived(
+    (() => {
+      const justifiedLayoutResult = justifiedLayout(
+        assets.map((asset) => getAssetRatio(asset)),
+        {
+          boxSpacing: 2,
+          containerWidth: Math.floor(viewport.width),
+          containerPadding: 0,
+          targetRowHeightTolerance: 0.15,
+          targetRowHeight: 235,
+        },
+      );
 
-    return {
-      ...justifiedLayoutResult,
-      containerWidth: calculateWidth(justifiedLayoutResult.boxes),
-    };
-  })();
+      return {
+        ...justifiedLayoutResult,
+        containerWidth: calculateWidth(justifiedLayoutResult.boxes),
+      };
+    })(),
+  );
 </script>
 
 {#if assets.length > 0}
diff --git a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte b/web/src/lib/components/shared-components/help-and-feedback-modal.svelte
index 19e12a51f9..c122e0f23e 100644
--- a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte
+++ b/web/src/lib/components/shared-components/help-and-feedback-modal.svelte
@@ -7,9 +7,12 @@
   import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
   import { discordPath } from '$lib/assets/svg-paths';
 
-  export let onClose: () => void;
+  interface Props {
+    onClose: () => void;
+    info: ServerAboutResponseDto;
+  }
 
-  export let info: ServerAboutResponseDto;
+  let { onClose, info }: Props = $props();
 </script>
 
 <Portal>
diff --git a/web/src/lib/components/shared-components/immich-logo-small-link.svelte b/web/src/lib/components/shared-components/immich-logo-small-link.svelte
index 9f1dd9714e..cd3149e6de 100644
--- a/web/src/lib/components/shared-components/immich-logo-small-link.svelte
+++ b/web/src/lib/components/shared-components/immich-logo-small-link.svelte
@@ -1,7 +1,11 @@
 <script lang="ts">
   import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
 
-  export let width: number;
+  interface Props {
+    width: number;
+  }
+
+  let { width }: Props = $props();
 </script>
 
 <a data-sveltekit-preload-data="hover" class="ml-4" href="/">
diff --git a/web/src/lib/components/shared-components/immich-logo.svelte b/web/src/lib/components/shared-components/immich-logo.svelte
index 952960ef3f..7046ea689e 100644
--- a/web/src/lib/components/shared-components/immich-logo.svelte
+++ b/web/src/lib/components/shared-components/immich-logo.svelte
@@ -9,14 +9,12 @@
   import type { HTMLImgAttributes } from 'svelte/elements';
   import { t } from 'svelte-i18n';
 
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  interface $$Props extends HTMLImgAttributes {
+  interface Props extends HTMLImgAttributes {
     noText?: boolean;
     draggable?: boolean;
   }
 
-  export let noText = false;
-  export let draggable = false;
+  let { noText = false, draggable = false, ...rest }: Props = $props();
 
   const today = DateTime.now().toLocal();
 </script>
@@ -28,6 +26,6 @@
     src={noText ? logoNoText : $colorTheme.value == Theme.LIGHT ? logoLightUrl : logoDarkUrl}
     alt={$t('immich_logo')}
     {draggable}
-    {...$$restProps}
+    {...rest}
   />
 {/if}
diff --git a/web/src/lib/components/shared-components/loading-spinner.svelte b/web/src/lib/components/shared-components/loading-spinner.svelte
index 48626a50f4..e81d2225b7 100644
--- a/web/src/lib/components/shared-components/loading-spinner.svelte
+++ b/web/src/lib/components/shared-components/loading-spinner.svelte
@@ -1,5 +1,9 @@
 <script lang="ts">
-  export let size: string = '24';
+  interface Props {
+    size?: string;
+  }
+
+  let { size = '24' }: Props = $props();
 </script>
 
 <div>
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index 85d927d166..7644064d9d 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   void maplibregl.setRTLTextPlugin(mapboxRtlUrl, true);
 </script>
 
@@ -6,12 +6,13 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { Theme } from '$lib/constants';
   import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
+  import { serverConfig } from '$lib/stores/server-config.store';
   import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
-  import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk';
+  import { type MapMarkerResponseDto } from '@immich/sdk';
   import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
   import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
   import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
-  import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
+  import type { GeoJSONSource, LngLatLike } from 'maplibre-gl';
   import maplibregl from 'maplibre-gl';
   import { t } from 'svelte-i18n';
   import {
@@ -30,14 +31,43 @@
     type Map,
   } from 'svelte-maplibre';
 
-  export let mapMarkers: MapMarkerResponseDto[];
-  export let showSettingsModal: boolean | undefined = undefined;
-  export let zoom: number | undefined = undefined;
-  export let center: LngLatLike | undefined = undefined;
-  export let hash = false;
-  export let simplified = false;
-  export let clickable = false;
-  export let useLocationPin = false;
+  interface Props {
+    mapMarkers: MapMarkerResponseDto[];
+    showSettingsModal?: boolean | undefined;
+    zoom?: number | undefined;
+    center?: LngLatLike | undefined;
+    hash?: boolean;
+    simplified?: boolean;
+    clickable?: boolean;
+    useLocationPin?: boolean;
+    onOpenInMapView?: (() => Promise<void> | void) | undefined;
+    onSelect?: (assetIds: string[]) => void;
+    onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
+    popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
+  }
+
+  let {
+    mapMarkers = $bindable(),
+    showSettingsModal = $bindable(undefined),
+    zoom = undefined,
+    center = $bindable(undefined),
+    hash = false,
+    simplified = false,
+    clickable = false,
+    useLocationPin = false,
+    onOpenInMapView = undefined,
+    onSelect = () => {},
+    onClickPoint = () => {},
+    popup,
+  }: Props = $props();
+
+  let map: maplibregl.Map | undefined = $state();
+  let marker: maplibregl.Marker | null = null;
+
+  const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT);
+  const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
+  const style = $derived(fetch(styleUrl).then((response) => response.json()));
+
   export function addClipMapMarker(lng: number, lat: number) {
     if (map) {
       if (marker) {
@@ -46,26 +76,9 @@
 
       center = { lng, lat };
       marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
-      map.setZoom(15);
     }
   }
 
-  export let onOpenInMapView: (() => Promise<void> | void) | undefined = undefined;
-  export let onSelect: (assetIds: string[]) => void = () => {};
-  export let onClickPoint: ({ lat, lng }: { lat: number; lng: number }) => void = () => {};
-
-  let map: maplibregl.Map;
-  let marker: maplibregl.Marker | null = null;
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: style = (async () => {
-    const config = await getServerConfig();
-    const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT;
-    const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl;
-    const style = await fetch(styleUrl).then((response) => response.json());
-    return style as StyleSpecification;
-  })();
-
   function handleAssetClick(assetId: string, map: Map | null) {
     if (!map) {
       return;
@@ -93,7 +106,9 @@
         marker.remove();
       }
 
-      marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
+      if (map) {
+        marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
+      }
     }
   }
 
@@ -135,92 +150,96 @@
     {zoom}
     attributionControl={false}
     diffStyleUpdates={true}
-    let:map
     on:load={(event) => event.detail.setMaxZoom(18)}
     on:load={(event) => event.detail.on('click', handleMapClick)}
     bind:map
   >
-    <NavigationControl position="top-left" showCompass={!simplified} />
+    {#snippet children({ map }: { map: maplibregl.Map })}
+      <NavigationControl position="top-left" showCompass={!simplified} />
 
-    {#if !simplified}
-      <GeolocateControl position="top-left" />
-      <FullscreenControl position="top-left" />
-      <ScaleControl />
-      <AttributionControl compact={false} />
-    {/if}
+      {#if !simplified}
+        <GeolocateControl position="top-left" />
+        <FullscreenControl position="top-left" />
+        <ScaleControl />
+        <AttributionControl compact={false} />
+      {/if}
 
-    {#if showSettingsModal !== undefined}
-      <Control>
-        <ControlGroup>
-          <ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton>
-        </ControlGroup>
-      </Control>
-    {/if}
+      {#if showSettingsModal !== undefined}
+        <Control>
+          <ControlGroup>
+            <ControlButton on:click={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton
+            >
+          </ControlGroup>
+        </Control>
+      {/if}
 
-    {#if onOpenInMapView}
-      <Control position="top-right">
-        <ControlGroup>
-          <ControlButton on:click={() => onOpenInMapView()}>
-            <Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" />
-          </ControlButton>
-        </ControlGroup>
-      </Control>
-    {/if}
+      {#if onOpenInMapView}
+        <Control position="top-right">
+          <ControlGroup>
+            <ControlButton on:click={() => onOpenInMapView()}>
+              <Icon title={$t('open_in_map_view')} path={mdiMap} size="100%" />
+            </ControlButton>
+          </ControlGroup>
+        </Control>
+      {/if}
 
-    <GeoJSON
-      data={{
-        type: 'FeatureCollection',
-        features: mapMarkers.map((marker) => asFeature(marker)),
-      }}
-      id="geojson"
-      cluster={{ radius: 500, maxZoom: 24 }}
-    >
-      <MarkerLayer
-        applyToClusters
-        asButton
-        let:feature
-        on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))}
-      >
-        <div
-          class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
-        >
-          {feature.properties?.point_count}
-        </div>
-      </MarkerLayer>
-      <MarkerLayer
-        applyToClusters={false}
-        asButton
-        let:feature
-        on:click={(event) => {
-          if (!$$slots.popup) {
-            handleAssetClick(event.detail.feature.properties?.id, map);
-          }
+      <GeoJSON
+        data={{
+          type: 'FeatureCollection',
+          features: mapMarkers.map((marker) => asFeature(marker)),
         }}
+        id="geojson"
+        cluster={{ radius: 500, maxZoom: 24 }}
       >
-        {#if useLocationPin}
-          <Icon
-            path={mdiMapMarker}
-            size="50px"
-            class="location-pin dark:text-immich-dark-primary text-immich-primary"
-          />
-        {:else}
-          <img
-            src={getAssetThumbnailUrl(feature.properties?.id)}
-            class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
-            alt={feature.properties?.city && feature.properties.country
-              ? $t('map_marker_for_images', {
-                  values: { city: feature.properties.city, country: feature.properties.country },
-                })
-              : $t('map_marker_with_image')}
-          />
-        {/if}
-        {#if $$slots.popup}
-          <Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
-            <slot name="popup" marker={asMarker(feature)} />
-          </Popup>
-        {/if}
-      </MarkerLayer>
-    </GeoJSON>
+        <MarkerLayer
+          applyToClusters
+          asButton
+          on:click={(event) => handlePromiseError(handleClusterClick(event.detail.feature.properties?.cluster_id, map))}
+        >
+          {#snippet children({ feature }: { feature: maplibregl.Feature })}
+            <div
+              class="rounded-full w-[40px] h-[40px] bg-immich-primary text-immich-gray flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
+            >
+              {feature.properties?.point_count}
+            </div>
+          {/snippet}
+        </MarkerLayer>
+        <MarkerLayer
+          applyToClusters={false}
+          asButton
+          on:click={(event) => {
+            if (!popup) {
+              handleAssetClick(event.detail.feature.properties?.id, map);
+            }
+          }}
+        >
+          {#snippet children({ feature }: { feature: Feature<Geometry, GeoJsonProperties> })}
+            {#if useLocationPin}
+              <Icon
+                path={mdiMapMarker}
+                size="50px"
+                class="location-pin dark:text-immich-dark-primary text-immich-primary"
+              />
+            {:else}
+              <img
+                src={getAssetThumbnailUrl(feature.properties?.id)}
+                class="rounded-full w-[60px] h-[60px] border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
+                alt={feature.properties?.city && feature.properties.country
+                  ? $t('map_marker_for_images', {
+                      values: { city: feature.properties.city, country: feature.properties.country },
+                    })
+                  : $t('map_marker_with_image')}
+              />
+            {/if}
+            {#if popup}
+              <Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
+                {@render popup?.({ marker: asMarker(feature) })}
+              </Popup>
+            {/if}
+          {/snippet}
+        </MarkerLayer>
+      </GeoJSON>
+    {/snippet}
   </MapLibre>
   <style>
     .location-pin {
diff --git a/web/src/lib/components/shared-components/modal-header.svelte b/web/src/lib/components/shared-components/modal-header.svelte
index efd87b476c..53f3fbdabb 100644
--- a/web/src/lib/components/shared-components/modal-header.svelte
+++ b/web/src/lib/components/shared-components/modal-header.svelte
@@ -5,20 +5,24 @@
   import { mdiClose } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  /**
-   * Unique identifier for the header text.
-   */
-  export let id: string;
-  export let title: string;
-  export let onClose: () => void;
-  /**
-   * If true, the logo will be displayed next to the modal title.
-   */
-  export let showLogo = false;
-  /**
-   * Optional icon to display next to the modal title, if `showLogo` is false.
-   */
-  export let icon: string | undefined = undefined;
+  interface Props {
+    /**
+     * Unique identifier for the header text.
+     */
+    id: string;
+    title: string;
+    onClose: () => void;
+    /**
+     * If true, the logo will be displayed next to the modal title.
+     */
+    showLogo?: boolean;
+    /**
+     * Optional icon to display next to the modal title, if `showLogo` is false.
+     */
+    icon?: string;
+  }
+
+  let { id, title, onClose, showLogo = false, icon = undefined }: Props = $props();
 </script>
 
 <div class="flex place-items-center justify-between px-5 pb-3">
@@ -33,5 +37,5 @@
     </h1>
   </div>
 
-  <CircleIconButton on:click={onClose} icon={mdiClose} size={'20'} title={$t('close')} />
+  <CircleIconButton onclick={onClose} icon={mdiClose} size={'20'} title={$t('close')} />
 </div>
diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
index bf0ca26d61..478b43b190 100644
--- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte
@@ -15,10 +15,14 @@
   import UserAvatar from '../user-avatar.svelte';
   import AvatarSelector from './avatar-selector.svelte';
 
-  export let onLogout: () => void;
-  export let onClose: () => void = () => {};
+  interface Props {
+    onLogout: () => void;
+    onClose?: () => void;
+  }
 
-  let isShowSelectAvatar = false;
+  let { onLogout, onClose = () => {} }: Props = $props();
+
+  let isShowSelectAvatar = $state(false);
 
   const handleSaveProfile = async (color: UserAvatarColor) => {
     try {
@@ -60,7 +64,7 @@
           class="border"
           size="12"
           padding="2"
-          on:click={() => (isShowSelectAvatar = true)}
+          onclick={() => (isShowSelectAvatar = true)}
         />
       </div>
     </div>
@@ -72,7 +76,7 @@
     </div>
 
     <div class="flex flex-col gap-1">
-      <Button href={AppRoute.USER_SETTINGS} on:click={onClose} color="dark-gray" size="sm" shadow={false} border>
+      <Button href={AppRoute.USER_SETTINGS} onclick={onClose} color="dark-gray" size="sm" shadow={false} border>
         <div class="flex place-content-center place-items-center text-center gap-2 px-2">
           <Icon path={mdiCog} size="18" ariaHidden />
           {$t('account_settings')}
@@ -81,7 +85,7 @@
       {#if $user.isAdmin}
         <Button
           href={AppRoute.ADMIN_USER_MANAGEMENT}
-          on:click={onClose}
+          onclick={onClose}
           color="dark-gray"
           size="sm"
           shadow={false}
@@ -101,7 +105,7 @@
     <button
       type="button"
       class="flex w-full place-content-center place-items-center gap-2 py-3 font-medium text-gray-500 hover:bg-immich-primary/10 dark:text-gray-300"
-      on:click={onLogout}
+      onclick={onLogout}
     >
       <Icon path={mdiLogout} size={24} />
       {$t('sign_out')}</button
diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
index 77a6e3a2d0..d762c7ba88 100644
--- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte
@@ -4,9 +4,13 @@
   import FullScreenModal from '../full-screen-modal.svelte';
   import UserAvatar from '../user-avatar.svelte';
 
-  export let user: UserResponseDto;
-  export let onClose: () => void;
-  export let onChoose: (color: UserAvatarColor) => void;
+  interface Props {
+    user: UserResponseDto;
+    onClose: () => void;
+    onChoose: (color: UserAvatarColor) => void;
+  }
+
+  let { user, onClose, onChoose }: Props = $props();
 
   const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
 </script>
@@ -15,7 +19,7 @@
   <div class="flex items-center justify-center mt-4">
     <div class="grid grid-cols-2 md:grid-cols-5 gap-4">
       {#each colors as color}
-        <button type="button" on:click={() => onChoose(color)}>
+        <button type="button" onclick={() => onChoose(color)}>
           <UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
         </button>
       {/each}
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index 2f8d0e2574..1bbf34316c 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -21,20 +21,24 @@
   import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte';
   import { onMount } from 'svelte';
 
-  export let showUploadButton = true;
-  export let onUploadClick: () => void;
+  interface Props {
+    showUploadButton?: boolean;
+    onUploadClick: () => void;
+  }
 
-  let shouldShowAccountInfo = false;
-  let shouldShowAccountInfoPanel = false;
-  let shouldShowHelpPanel = false;
-  let innerWidth: number;
+  let { showUploadButton = true, onUploadClick }: Props = $props();
+
+  let shouldShowAccountInfo = $state(false);
+  let shouldShowAccountInfoPanel = $state(false);
+  let shouldShowHelpPanel = $state(false);
+  let innerWidth: number = $state(0);
 
   const onLogout = async () => {
     const { redirectUri } = await logout();
     await handleLogout(redirectUri);
   };
 
-  let aboutInfo: ServerAboutResponseDto;
+  let aboutInfo: ServerAboutResponseDto | undefined = $state();
 
   onMount(async () => {
     aboutInfo = await getAboutInfo();
@@ -43,7 +47,7 @@
 
 <svelte:window bind:innerWidth />
 
-{#if shouldShowHelpPanel}
+{#if shouldShowHelpPanel && aboutInfo}
   <HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} info={aboutInfo} />
 {/if}
 
@@ -71,6 +75,7 @@
             title={$t('go_to_search')}
             icon={mdiMagnify}
             padding="2"
+            onclick={() => {}}
           />
         {/if}
 
@@ -85,20 +90,20 @@
             id="support-feedback-button"
             title={$t('support_and_feedback')}
             icon={mdiHelpCircleOutline}
-            on:click={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
+            onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
             padding="1"
           />
         </div>
 
         {#if !$page.url.pathname.includes('/admin') && showUploadButton}
-          <LinkButton on:click={onUploadClick} class="hidden lg:block">
+          <LinkButton onclick={onUploadClick} class="hidden lg:block">
             <div class="flex gap-2">
               <Icon path={mdiTrayArrowUp} size="1.5em" />
               <span>{$t('upload')}</span>
             </div>
           </LinkButton>
           <CircleIconButton
-            on:click={onUploadClick}
+            onclick={onUploadClick}
             title={$t('upload')}
             icon={mdiTrayArrowUp}
             class="lg:hidden"
@@ -115,11 +120,11 @@
           <button
             type="button"
             class="flex pl-2"
-            on:mouseover={() => (shouldShowAccountInfo = true)}
-            on:focus={() => (shouldShowAccountInfo = true)}
-            on:blur={() => (shouldShowAccountInfo = false)}
-            on:mouseleave={() => (shouldShowAccountInfo = false)}
-            on:click={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
+            onmouseover={() => (shouldShowAccountInfo = true)}
+            onfocus={() => (shouldShowAccountInfo = true)}
+            onblur={() => (shouldShowAccountInfo = false)}
+            onmouseleave={() => (shouldShowAccountInfo = false)}
+            onclick={() => (shouldShowAccountInfoPanel = !shouldShowAccountInfoPanel)}
           >
             {#key $user}
               <UserAvatar user={$user} size="md" showTitle={false} interactive />
diff --git a/web/src/lib/components/shared-components/navigation-loading-bar.svelte b/web/src/lib/components/shared-components/navigation-loading-bar.svelte
index b6913ae025..f5879cf0a1 100644
--- a/web/src/lib/components/shared-components/navigation-loading-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-loading-bar.svelte
@@ -3,7 +3,7 @@
   import { cubicOut } from 'svelte/easing';
   import { tweened } from 'svelte/motion';
 
-  let showing = false;
+  let showing = $state(false);
 
   // delay showing any progress for a little bit so very fast loads
   // do not cause flicker
diff --git a/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte b/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte
index dfa305a19d..4dea370952 100644
--- a/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte
+++ b/web/src/lib/components/shared-components/notification/__tests__/notification-component-test.svelte
@@ -1,5 +1,9 @@
 <script lang="ts">
-  export let href: string;
+  interface Props {
+    href: string;
+  }
+
+  let { href }: Props = $props();
 </script>
 
 Notification <b>message</b> with <a {href}>link</a>
diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte
index f5e70d856a..5054c18695 100644
--- a/web/src/lib/components/shared-components/notification/notification-card.svelte
+++ b/web/src/lib/components/shared-components/notification/notification-card.svelte
@@ -13,11 +13,14 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let notification: Notification | ComponentNotification;
+  interface Props {
+    notification: Notification | ComponentNotification;
+  }
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
-  $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : '';
+  let { notification }: Props = $props();
+
+  let icon = $derived(notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline);
+  let hoverStyle = $derived(notification.action.type === 'discard' ? 'hover:cursor-pointer' : '');
 
   const backgroundColor: Record<NotificationType, string> = {
     [NotificationType.Info]: '#E0E2F0',
@@ -67,14 +70,14 @@
   };
 </script>
 
-<!-- svelte-ignore a11y-no-static-element-interactions -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 <div
   transition:fade={{ duration: 250 }}
   style:background-color={backgroundColor[notification.type]}
   style:border-color={borderColor[notification.type]}
   class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}"
-  on:click={handleClick}
-  on:keydown={handleClick}
+  onclick={handleClick}
+  onkeydown={handleClick}
 >
   <div class="flex justify-between">
     <div class="flex place-items-center gap-2">
@@ -91,15 +94,15 @@
       class="dark:text-immich-dark-gray"
       size="20"
       padding="2"
-      on:click={discard}
-      aria-hidden="true"
+      onclick={discard}
+      aria-hidden={true}
       tabindex={-1}
     />
   </div>
 
   <p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
     {#if isComponentNotification(notification)}
-      <svelte:component this={notification.component.type} {...notification.component.props} />
+      <notification.component.type {...notification.component.props} />
     {:else}
       {notification.message}
     {/if}
@@ -110,7 +113,7 @@
       <button
         type="button"
         class="{buttonStyle[notification.type]} rounded px-3 pt-1.5 pb-1 transition-all duration-200"
-        on:click={handleButtonClick}
+        onclick={handleButtonClick}
         aria-hidden="true"
         tabindex={-1}
       >
diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte
index 2e7dca8781..6ee993cf88 100644
--- a/web/src/lib/components/shared-components/number-range-input.svelte
+++ b/web/src/lib/components/shared-components/number-range-input.svelte
@@ -2,14 +2,38 @@
   import { clamp } from 'lodash-es';
   import type { ClipboardEventHandler } from 'svelte/elements';
 
-  export let id: string;
-  export let min: number;
-  export let max: number;
-  export let step: number | string = 'any';
-  export let required = true;
-  export let value: number | null = null;
-  export let onInput: (value: number | null) => void;
-  export let onPaste: ClipboardEventHandler<HTMLInputElement> | undefined = undefined;
+  interface Props {
+    id: string;
+    min: number;
+    max: number;
+    step?: number | string;
+    required?: boolean;
+    value?: number;
+    onInput: (value: number | null) => void;
+    onPaste?: ClipboardEventHandler<HTMLInputElement>;
+  }
+
+  let {
+    id,
+    min,
+    max,
+    step = 'any',
+    required = true,
+    value = $bindable(),
+    onInput,
+    onPaste = undefined,
+  }: Props = $props();
+
+  const oninput = () => {
+    if (!value) {
+      return;
+    }
+
+    if (value !== null && (value < min || value > max)) {
+      value = clamp(value, min, max);
+    }
+    onInput(value);
+  };
 </script>
 
 <input
@@ -21,11 +45,6 @@
   {step}
   {required}
   bind:value
-  on:input={() => {
-    if (value !== null && (value < min || value > max)) {
-      value = clamp(value, min, max);
-    }
-    onInput(value);
-  }}
-  on:paste={onPaste}
+  {oninput}
+  onpaste={onPaste}
 />
diff --git a/web/src/lib/components/shared-components/password-field.svelte b/web/src/lib/components/shared-components/password-field.svelte
index e623d08423..8519f84134 100644
--- a/web/src/lib/components/shared-components/password-field.svelte
+++ b/web/src/lib/components/shared-components/password-field.svelte
@@ -4,28 +4,26 @@
   import Icon from '../elements/icon.svelte';
   import { t } from 'svelte-i18n';
 
-  interface $$Props extends HTMLInputAttributes {
+  interface Props extends HTMLInputAttributes {
     password: string;
     autocomplete: AutoFill;
     required?: boolean;
     onInput?: (value: string) => void;
   }
 
-  export let password: $$Props['password'];
-  export let required = true;
-  export let onInput: $$Props['onInput'] = undefined;
+  let { password = $bindable(), required = true, onInput = undefined, ...rest }: Props = $props();
 
-  let showPassword = false;
+  let showPassword = $state(false);
 </script>
 
 <div class="relative w-full">
   <input
-    {...$$restProps}
+    {...rest}
     class="immich-form-input w-full !pr-12"
     type={showPassword ? 'text' : 'password'}
     {required}
     value={password}
-    on:input={(e) => {
+    oninput={(e) => {
       password = e.currentTarget.value;
       onInput?.(password);
     }}
@@ -36,7 +34,7 @@
       type="button"
       tabindex="-1"
       class="absolute inset-y-0 end-0 px-4 text-gray-700 dark:text-gray-200"
-      on:click={() => (showPassword = !showPassword)}
+      onclick={() => (showPassword = !showPassword)}
       title={showPassword ? $t('hide_password') : $t('show_password')}
     >
       <Icon path={showPassword ? mdiEyeOffOutline : mdiEyeOutline} size="1.25em" />
diff --git a/web/src/lib/components/shared-components/portal/portal.svelte b/web/src/lib/components/shared-components/portal/portal.svelte
index 7a9e577083..60ccc993af 100644
--- a/web/src/lib/components/shared-components/portal/portal.svelte
+++ b/web/src/lib/components/shared-components/portal/portal.svelte
@@ -1,6 +1,6 @@
-<script context="module" lang="ts">
+<script module lang="ts">
   import { handlePromiseError } from '$lib/utils';
-  import { tick } from 'svelte';
+  import { tick, type Snippet } from 'svelte';
 
   /**
    * Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
@@ -64,12 +64,17 @@ Used for every occurrence of an HTML tag in a message
 ```
 -->
 <script lang="ts">
-  /**
-   * DOM Element or CSS Selector
-   */
-  export let target: HTMLElement | string = 'body';
+  interface Props {
+    /**
+     * DOM Element or CSS Selector
+     */
+    target?: HTMLElement | string;
+    children?: Snippet;
+  }
+
+  let { target = 'body', children }: Props = $props();
 </script>
 
 <div use:portal={target} hidden>
-  <slot />
+  {@render children?.()}
 </div>
diff --git a/web/src/lib/components/shared-components/profile-image-cropper.svelte b/web/src/lib/components/shared-components/profile-image-cropper.svelte
index 3dabd86d4f..b8ac866761 100644
--- a/web/src/lib/components/shared-components/profile-image-cropper.svelte
+++ b/web/src/lib/components/shared-components/profile-image-cropper.svelte
@@ -10,12 +10,20 @@
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let onClose: () => void;
+  interface Props {
+    asset: AssetResponseDto;
+    onClose: () => void;
+  }
 
-  let imgElement: HTMLDivElement;
+  let { asset, onClose }: Props = $props();
+
+  let imgElement: HTMLDivElement | undefined = $state();
 
   onMount(() => {
+    if (!imgElement) {
+      return;
+    }
+
     imgElement.style.width = '100%';
   });
 
@@ -45,6 +53,10 @@
   };
 
   const handleSetProfilePicture = async () => {
+    if (!imgElement) {
+      return;
+    }
+
     try {
       const blob = await domtoimage.toBlob(imgElement);
       if (await hasTransparentPixels(blob)) {
@@ -79,7 +91,8 @@
       <PhotoViewer bind:element={imgElement} {asset} />
     </div>
   </div>
-  <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth on:click={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
-  </svelte:fragment>
+
+  {#snippet stickyBottom()}
+    <Button fullwidth onclick={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte b/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte
index 81de8a24a1..0ccb8f9556 100644
--- a/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte
+++ b/web/src/lib/components/shared-components/progress-bar/progress-bar.svelte
@@ -1,4 +1,4 @@
-<script context="module" lang="ts">
+<script module lang="ts">
   export enum ProgressBarStatus {
     Playing = 'playing',
     Paused = 'paused',
@@ -11,41 +11,49 @@
   import { onMount } from 'svelte';
   import { tweened } from 'svelte/motion';
 
-  /**
-   * Autoplay on mount
-   * @default false
-   */
-  export let autoplay = false;
+  interface Props {
+    /**
+     * Autoplay on mount
+     * @default false
+     */
+    autoplay?: boolean;
+    /**
+     * Progress bar status
+     */
+    status?: ProgressBarStatus;
+    hidden?: boolean;
+    duration?: number;
+    onDone: () => void;
+    onPlaying?: () => void;
+    onPaused?: () => void;
+  }
 
-  /**
-   * Progress bar status
-   */
-  export let status: ProgressBarStatus = ProgressBarStatus.Paused;
+  let {
+    autoplay = false,
+    status = $bindable(),
+    hidden = false,
+    duration = 5,
+    onDone,
+    onPlaying = () => {},
+    onPaused = () => {},
+  }: Props = $props();
 
-  export let hidden = false;
-
-  export let duration = 5;
-
-  export let onDone: () => void;
-  export let onPlaying: () => void = () => {};
-  export let onPaused: () => void = () => {};
-
-  const onChange = async () => {
-    progress = setDuration(duration);
+  const onChange = async (progressDuration: number) => {
+    progress = setDuration(progressDuration);
     await play();
   };
 
   let progress = setDuration(duration);
 
-  // svelte 5, again....
-  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-  $: duration, handlePromiseError(onChange());
+  $effect(() => {
+    handlePromiseError(onChange(duration));
+  });
 
-  $: {
+  $effect(() => {
     if ($progress === 1) {
       onDone();
     }
-  }
+  });
 
   onMount(async () => {
     if (autoplay) {
diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte
index 3bd462f997..00800ab489 100644
--- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte
+++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte
@@ -7,7 +7,11 @@
   import { preferences } from '$lib/stores/user.store';
   import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
 
-  export let onDone: () => void;
+  interface Props {
+    onDone: () => void;
+  }
+
+  let { onDone }: Props = $props();
 </script>
 
 <div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center dark:text-white my-6">
@@ -25,6 +29,6 @@
   </div>
 
   <div class="mt-6 w-full">
-    <Button fullwidth on:click={onDone}>{$t('ok')}</Button>
+    <Button fullwidth onclick={onDone}>{$t('ok')}</Button>
   </div>
 </div>
diff --git a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte
index 0d782f85b3..6a4e7f1a4b 100644
--- a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte
+++ b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte
@@ -8,12 +8,15 @@
   import { purchaseStore } from '$lib/stores/purchase.store';
   import { t } from 'svelte-i18n';
 
-  export let onActivate: () => void;
+  interface Props {
+    onActivate: () => void;
+    showTitle?: boolean;
+    showMessage?: boolean;
+  }
 
-  export let showTitle = true;
-  export let showMessage = true;
-  let productKey = '';
-  let isLoading = false;
+  let { onActivate, showTitle = true, showMessage = true }: Props = $props();
+  let productKey = $state('');
+  let isLoading = $state(false);
 
   const activate = async () => {
     try {
@@ -61,7 +64,7 @@
 
     <div class="mt-6">
       <p class="dark:text-immich-gray">{$t('purchase_input_suggestion')}</p>
-      <form class="mt-2 flex gap-2" on:submit={activate}>
+      <form class="mt-2 flex gap-2" onsubmit={activate}>
         <input
           class="immich-form-input w-full"
           id="purchaseKey"
diff --git a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte b/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte
index 52757bc32a..0334fb9e99 100644
--- a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte
+++ b/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte
@@ -5,9 +5,13 @@
 
   import Portal from '$lib/components/shared-components/portal/portal.svelte';
 
-  export let onClose: () => void;
+  interface Props {
+    onClose: () => void;
+  }
 
-  let showProductActivated = false;
+  let { onClose }: Props = $props();
+
+  let showProductActivated = $state(false);
 </script>
 
 <Portal>
diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
index a55ad1a69c..bdcca509bb 100644
--- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte
+++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
@@ -7,28 +7,45 @@
   import { isTimelineScrolling } from '$lib/stores/timeline.store';
   import { fade, fly } from 'svelte/transition';
 
-  export let timelineTopOffset = 0;
-  export let timelineBottomOffset = 0;
-  export let height = 0;
-  export let assetStore: AssetStore;
-  export let invisible = false;
-  export let scrubOverallPercent: number = 0;
-  export let scrubBucketPercent: number = 0;
-  export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
-  export let leadout: boolean = false;
-  export let onScrub: ScrubberListener | undefined = undefined;
-  export let startScrub: ScrubberListener | undefined = undefined;
-  export let stopScrub: ScrubberListener | undefined = undefined;
+  interface Props {
+    timelineTopOffset?: number;
+    timelineBottomOffset?: number;
+    height?: number;
+    assetStore: AssetStore;
+    invisible?: boolean;
+    scrubOverallPercent?: number;
+    scrubBucketPercent?: number;
+    scrubBucket?: { bucketDate: string | undefined } | undefined;
+    leadout?: boolean;
+    onScrub?: ScrubberListener | undefined;
+    startScrub?: ScrubberListener | undefined;
+    stopScrub?: ScrubberListener | undefined;
+  }
 
-  let isHover = false;
-  let isDragging = false;
-  let hoverLabel: string | undefined;
+  let {
+    timelineTopOffset = 0,
+    timelineBottomOffset = 0,
+    height = 0,
+    assetStore,
+    invisible = false,
+    scrubOverallPercent = 0,
+    scrubBucketPercent = 0,
+    scrubBucket = undefined,
+    leadout = false,
+    onScrub = undefined,
+    startScrub = undefined,
+    stopScrub = undefined,
+  }: Props = $props();
+
+  let isHover = $state(false);
+  let isDragging = $state(false);
+  let hoverLabel: string | undefined = $state();
   let bucketDate: string | undefined;
-  let hoverY = 0;
+  let hoverY = $state(0);
   let clientY = 0;
-  let windowHeight = 0;
-  let scrollBar: HTMLElement | undefined;
-  let segments: Segment[] = [];
+  let windowHeight = $state(0);
+  let scrollBar: HTMLElement | undefined = $state();
+  let segments: Segment[] = $state([]);
 
   const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
   const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
@@ -70,10 +87,14 @@
       return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
     }
   };
-  $: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
-  $: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
-  $: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
-  $: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight);
+  let scrollY = $state(0);
+  $effect(() => {
+    scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
+  });
+
+  let timelineFullHeight = $derived($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
+  let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
+  let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
 
   const listener: BucketListener = (event) => {
     const { type } = event;
@@ -204,12 +225,12 @@
 
 <svelte:window
   bind:innerHeight={windowHeight}
-  on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
-  on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
-  on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
+  onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
+  onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
+  onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
 />
 
-<!-- svelte-ignore a11y-no-static-element-interactions -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
 
 <div
   transition:fly={{ x: 50, duration: 250 }}
@@ -223,8 +244,8 @@
   style:background-color={isDragging ? 'transparent' : 'transparent'}
   draggable="false"
   bind:this={scrollBar}
-  on:mouseenter={() => (isHover = true)}
-  on:mouseleave={() => (isHover = false)}
+  onmouseenter={() => (isHover = true)}
+  onmouseleave={() => (isHover = false)}
 >
   {#if hoverLabel && (isHover || isDragging)}
     <div
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
index 67c3cfe757..d92bd1806c 100644
--- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte
@@ -15,35 +15,38 @@
   import { generateId } from '$lib/utils/generate-id';
   import { tick } from 'svelte';
 
-  export let value = '';
-  export let grayTheme: boolean;
-  export let searchQuery: MetadataSearchDto | SmartSearchDto = {};
+  interface Props {
+    value?: string;
+    grayTheme: boolean;
+    searchQuery?: MetadataSearchDto | SmartSearchDto;
+    onSearch?: () => void;
+  }
 
-  $: showClearIcon = value.length > 0;
+  let { value = $bindable(''), grayTheme, searchQuery = {}, onSearch }: Props = $props();
 
-  let input: HTMLInputElement;
+  let showClearIcon = $derived(value.length > 0);
 
-  let showSuggestions = false;
-  let showFilter = false;
-  let isSearchSuggestions = false;
-  let selectedId: string | undefined;
-  let moveSelection: (direction: 1 | -1) => void;
-  let clearSelection: () => void;
-  let selectActiveOption: () => void;
+  let input = $state<HTMLInputElement>();
+  let searchHistoryBox = $state<ReturnType<typeof SearchHistoryBox>>();
+  let showSuggestions = $state(false);
+  let showFilter = $state(false);
+  let isSearchSuggestions = $state(false);
+  let selectedId: string | undefined = $state();
 
   const listboxId = generateId();
 
-  const onSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
+  const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
     const params = getMetadataSearchQuery(payload);
 
     closeDropdown();
     showFilter = false;
     $isSearchEnabled = false;
     await goto(`${AppRoute.SEARCH}?${params}`);
+    onSearch?.();
   };
 
   const clearSearchTerm = (searchTerm: string) => {
-    input.focus();
+    input?.focus();
     $savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm);
   };
 
@@ -57,7 +60,7 @@
   };
 
   const clearAllSearchTerms = () => {
-    input.focus();
+    input?.focus();
     $savedSearchTerms = [];
   };
 
@@ -82,7 +85,7 @@
   const onHistoryTermClick = async (searchTerm: string) => {
     value = searchTerm;
     const searchPayload = { query: searchTerm };
-    await onSearch(searchPayload);
+    await handleSearch(searchPayload);
   };
 
   const onFilterClick = () => {
@@ -95,13 +98,13 @@
   };
 
   const onSubmit = () => {
-    handlePromiseError(onSearch({ query: value }));
+    handlePromiseError(handleSearch({ query: value }));
     saveSearchTerm(value);
   };
 
   const onClear = () => {
     value = '';
-    input.focus();
+    input?.focus();
   };
 
   const onEscape = () => {
@@ -112,19 +115,19 @@
   const onArrow = async (direction: 1 | -1) => {
     openDropdown();
     await tick();
-    moveSelection(direction);
+    searchHistoryBox?.moveSelection(direction);
   };
 
   const onEnter = (event: KeyboardEvent) => {
     if (selectedId) {
       event.preventDefault();
-      selectActiveOption();
+      searchHistoryBox?.selectActiveOption();
     }
   };
 
   const onInput = () => {
     openDropdown();
-    clearSelection();
+    searchHistoryBox?.clearSelection();
   };
 
   const openDropdown = () => {
@@ -133,14 +136,19 @@
 
   const closeDropdown = () => {
     showSuggestions = false;
-    clearSelection();
+    searchHistoryBox?.clearSelection();
+  };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onSubmit();
   };
 </script>
 
 <svelte:window
   use:shortcuts={[
     { shortcut: { key: 'Escape' }, onShortcut: onEscape },
-    { shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.select() },
+    { shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input?.select() },
     { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick },
   ]}
 />
@@ -151,9 +159,9 @@
     autocomplete="off"
     class="select-text text-sm"
     action={AppRoute.SEARCH}
-    on:reset={() => (value = '')}
-    on:submit|preventDefault={onSubmit}
-    on:focusin={onFocusIn}
+    onreset={() => (value = '')}
+    {onsubmit}
+    onfocusin={onFocusIn}
     role="search"
   >
     <div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1">
@@ -171,8 +179,8 @@
         pattern="^(?!m:$).*$"
         bind:value
         bind:this={input}
-        on:focus={openDropdown}
-        on:input={onInput}
+        onfocus={openDropdown}
+        oninput={onInput}
         disabled={showFilter}
         role="combobox"
         aria-controls={listboxId}
@@ -191,13 +199,11 @@
 
       <!-- SEARCH HISTORY BOX -->
       <SearchHistoryBox
+        bind:this={searchHistoryBox}
+        bind:isSearchSuggestions
         id={listboxId}
         searchQuery={value}
         isOpen={showSuggestions}
-        bind:isSearchSuggestions
-        bind:moveSelection
-        bind:clearSelection
-        bind:selectActiveOption
         onClearAllSearchTerms={clearAllSearchTerms}
         onClearSearchTerm={(searchTerm) => clearSearchTerm(searchTerm)}
         onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))}
@@ -206,19 +212,30 @@
     </div>
 
     <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-2'} flex items-center pl-6 transition-all">
-      <CircleIconButton title={$t('show_search_options')} icon={mdiTune} on:click={onFilterClick} size="20" />
+      <CircleIconButton title={$t('show_search_options')} icon={mdiTune} onclick={onFilterClick} size="20" />
     </div>
     {#if showClearIcon}
       <div class="absolute inset-y-0 right-0 flex items-center pr-2">
-        <CircleIconButton on:click={onClear} icon={mdiClose} title={$t('clear')} size="20" />
+        <CircleIconButton onclick={onClear} icon={mdiClose} title={$t('clear')} size="20" />
       </div>
     {/if}
     <div class="absolute inset-y-0 left-0 flex items-center pl-2">
-      <CircleIconButton type="submit" disabled={showFilter} title={$t('search')} icon={mdiMagnify} size="20" />
+      <CircleIconButton
+        type="submit"
+        disabled={showFilter}
+        title={$t('search')}
+        icon={mdiMagnify}
+        size="20"
+        onclick={() => {}}
+      />
     </div>
   </form>
 
   {#if showFilter}
-    <SearchFilterModal {searchQuery} onSearch={(payload) => onSearch(payload)} onClose={() => (showFilter = false)} />
+    <SearchFilterModal
+      {searchQuery}
+      onSearch={(payload) => handleSearch(payload)}
+      onClose={() => (showFilter = false)}
+    />
   {/if}
 </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
index 3ac8cb8d5a..08ed57d70e 100644
--- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export interface SearchCameraFilter {
     make?: string;
     model?: string;
@@ -6,20 +6,21 @@
 </script>
 
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
   import { handlePromiseError } from '$lib/utils';
   import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let filters: SearchCameraFilter;
+  interface Props {
+    filters: SearchCameraFilter;
+  }
 
-  let makes: string[] = [];
-  let models: string[] = [];
+  let { filters = $bindable() }: Props = $props();
 
-  $: makeFilter = filters.make;
-  $: modelFilter = filters.model;
-  $: handlePromiseError(updateMakes());
-  $: handlePromiseError(updateModels(makeFilter));
+  let makes: string[] = $state([]);
+  let models: string[] = $state([]);
 
   async function updateMakes() {
     const results: Array<string | null> = await getSearchSuggestions({
@@ -47,6 +48,14 @@
       filters.model = undefined;
     }
   }
+  let makeFilter = $derived(filters.make);
+  let modelFilter = $derived(filters.model);
+  run(() => {
+    handlePromiseError(updateMakes());
+  });
+  run(() => {
+    handlePromiseError(updateModels(makeFilter));
+  });
 </script>
 
 <div id="camera-selection">
diff --git a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
index 6b661b6c03..ea27142074 100644
--- a/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-date-section.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export interface SearchDateFilter {
     takenBefore?: string;
     takenAfter?: string;
@@ -9,7 +9,11 @@
   import DateInput from '$lib/components/elements/date-input.svelte';
   import { t } from 'svelte-i18n';
 
-  export let filters: SearchDateFilter;
+  interface Props {
+    filters: SearchDateFilter;
+  }
+
+  let { filters = $bindable() }: Props = $props();
 </script>
 
 <div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
diff --git a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
index 00a5403068..06fa3c5bdf 100644
--- a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export interface SearchDisplayFilters {
     isNotInAlbum?: boolean;
     isArchive?: boolean;
@@ -10,7 +10,11 @@
   import Checkbox from '$lib/components/elements/checkbox.svelte';
   import { t } from 'svelte-i18n';
 
-  export let filters: SearchDisplayFilters;
+  interface Props {
+    filters: SearchDisplayFilters;
+  }
+
+  let { filters = $bindable() }: Props = $props();
 </script>
 
 <div id="display-options-selection">
diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte
index 3ec539ad97..4b53f60b5f 100644
--- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   import type { SearchLocationFilter } from './search-location-section.svelte';
   import type { SearchDisplayFilters } from './search-display-section.svelte';
   import type { SearchDateFilter } from './search-date-section.svelte';
@@ -36,10 +36,15 @@
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import { mdiTune } from '@mdi/js';
   import { generateId } from '$lib/utils/generate-id';
+  import { SvelteSet } from 'svelte/reactivity';
 
-  export let searchQuery: MetadataSearchDto | SmartSearchDto;
-  export let onClose: () => void;
-  export let onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
+  interface Props {
+    searchQuery: MetadataSearchDto | SmartSearchDto;
+    onClose: () => void;
+    onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
+  }
+
+  let { searchQuery, onClose, onSearch }: Props = $props();
 
   const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
   const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
@@ -50,10 +55,10 @@
     return value === null ? undefined : value;
   }
 
-  let filter: SearchFilter = {
+  let filter: SearchFilter = $state({
     query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
     queryType: 'query' in searchQuery ? 'smart' : 'metadata',
-    personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
+    personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
     location: {
       country: withNullAsUndefined(searchQuery.country),
       state: withNullAsUndefined(searchQuery.state),
@@ -78,7 +83,7 @@
         : searchQuery.type === AssetTypeEnum.Video
           ? MediaType.Video
           : MediaType.All,
-  };
+  });
 
   const resetForm = () => {
     filter = {
@@ -122,10 +127,20 @@
 
     onSearch(payload);
   };
+
+  const onreset = (event: Event) => {
+    event.preventDefault();
+    resetForm();
+  };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    search();
+  };
 </script>
 
 <FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
-  <form id={formId} autocomplete="off" on:submit|preventDefault={search} on:reset|preventDefault={resetForm}>
+  <form id={formId} autocomplete="off" {onsubmit} {onreset}>
     <div class="space-y-10 pb-10" tabindex="-1">
       <!-- PEOPLE -->
       <SearchPeopleSection bind:selectedPeople={filter.personIds} />
@@ -152,8 +167,8 @@
     </div>
   </form>
 
-  <svelte:fragment slot="sticky-bottom">
+  {#snippet stickyBottom()}
     <Button type="reset" color="gray" fullwidth form={formId}>{$t('clear_all')}</Button>
     <Button type="submit" fullwidth form={formId}>{$t('search')}</Button>
-  </svelte:fragment>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
index ca25ef5691..92a2f8847e 100644
--- a/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-history-box.svelte
@@ -6,22 +6,41 @@
   import { t } from 'svelte-i18n';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 
-  export let id: string;
-  export let searchQuery: string = '';
-  export let isSearchSuggestions: boolean = false;
-  export let isOpen: boolean = false;
-  export let onSelectSearchTerm: (searchTerm: string) => void;
-  export let onClearSearchTerm: (searchTerm: string) => void;
-  export let onClearAllSearchTerms: () => void;
-  export let onActiveSelectionChange: (selectedId: string | undefined) => void;
+  interface Props {
+    id: string;
+    searchQuery?: string;
+    isSearchSuggestions?: boolean;
+    isOpen?: boolean;
+    onSelectSearchTerm: (searchTerm: string) => void;
+    onClearSearchTerm: (searchTerm: string) => void;
+    onClearAllSearchTerms: () => void;
+    onActiveSelectionChange: (selectedId: string | undefined) => void;
+  }
 
-  $: filteredSearchTerms = $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase()));
-  $: isSearchSuggestions = filteredSearchTerms.length > 0;
-  $: showClearAll = searchQuery === '';
-  $: suggestionCount = showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length;
+  let {
+    id,
+    searchQuery = '',
+    isSearchSuggestions = $bindable(false),
+    isOpen = false,
+    onSelectSearchTerm,
+    onClearSearchTerm,
+    onClearAllSearchTerms,
+    onActiveSelectionChange,
+  }: Props = $props();
 
-  let selectedIndex: number | undefined = undefined;
-  let element: HTMLDivElement;
+  let filteredSearchTerms = $derived(
+    $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
+  );
+
+  $effect(() => {
+    isSearchSuggestions = filteredSearchTerms.length > 0;
+  });
+
+  let showClearAll = $derived(searchQuery === '');
+  let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
+
+  let selectedIndex: number | undefined = $state(undefined);
+  let element = $state<HTMLDivElement>();
 
   export function moveSelection(increment: 1 | -1) {
     if (!isSearchSuggestions) {
@@ -45,7 +64,7 @@
     if (selectedIndex === undefined) {
       return;
     }
-    const selectedElement = element.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
+    const selectedElement = element?.querySelector(`#${getId(selectedIndex)}`) as HTMLElement;
     selectedElement?.click();
   }
 
@@ -86,7 +105,7 @@
             type="button"
             class="rounded-lg p-2 font-semibold text-immich-primary aria-selected:bg-immich-primary/25 hover:bg-immich-primary/25 dark:text-immich-dark-primary"
             role="option"
-            on:click={() => handleClearAll()}
+            onclick={() => handleClearAll()}
             tabindex="-1"
             aria-selected={selectedIndex === 0}
             aria-label={$t('clear_all_recent_searches')}
@@ -100,11 +119,11 @@
         {@const index = showClearAll ? i + 1 : i}
         <div class="flex w-full items-center justify-between text-sm text-black dark:text-gray-300">
           <div class="relative w-full items-center">
-            <!-- svelte-ignore a11y-click-events-have-key-events -->
+            <!-- svelte-ignore a11y_click_events_have_key_events -->
             <div
               id={getId(index)}
               class="relative flex w-full cursor-pointer gap-3 py-3 pl-5 hover:bg-gray-100 aria-selected:bg-gray-100 dark:aria-selected:bg-gray-500/30 dark:hover:bg-gray-500/30"
-              on:click={() => handleSelect(savedSearchTerm)}
+              onclick={() => handleSelect(savedSearchTerm)}
               role="option"
               tabindex="-1"
               aria-selected={selectedIndex === index}
@@ -120,7 +139,7 @@
                 size="18"
                 padding="1"
                 tabindex={-1}
-                on:click={() => handleClearSingle(savedSearchTerm)}
+                onclick={() => handleClearSingle(savedSearchTerm)}
               />
             </div>
           </div>
diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
index 71912264ed..d68578276c 100644
--- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export interface SearchLocationFilter {
     country?: string;
     state?: string;
@@ -7,22 +7,22 @@
 </script>
 
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
   import { handlePromiseError } from '$lib/utils';
   import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
-  export let filters: SearchLocationFilter;
+  interface Props {
+    filters: SearchLocationFilter;
+  }
 
-  let countries: string[] = [];
-  let states: string[] = [];
-  let cities: string[] = [];
+  let { filters = $bindable() }: Props = $props();
 
-  $: countryFilter = filters.country;
-  $: stateFilter = filters.state;
-  $: handlePromiseError(updateCountries());
-  $: handlePromiseError(updateStates(countryFilter));
-  $: handlePromiseError(updateCities(countryFilter, stateFilter));
+  let countries: string[] = $state([]);
+  let states: string[] = $state([]);
+  let cities: string[] = $state([]);
 
   async function updateCountries() {
     const results: Array<string | null> = await getSearchSuggestions({
@@ -64,6 +64,17 @@
       filters.city = undefined;
     }
   }
+  let countryFilter = $derived(filters.country);
+  let stateFilter = $derived(filters.state);
+  run(() => {
+    handlePromiseError(updateCountries());
+  });
+  run(() => {
+    handlePromiseError(updateStates(countryFilter));
+  });
+  run(() => {
+    handlePromiseError(updateCities(countryFilter, stateFilter));
+  });
 </script>
 
 <div id="location-selection">
diff --git a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
index b78868d614..37fa4292ae 100644
--- a/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-media-section.svelte
@@ -3,7 +3,11 @@
   import { MediaType } from './search-filter-modal.svelte';
   import { t } from 'svelte-i18n';
 
-  export let filteredMedia: MediaType;
+  interface Props {
+    filteredMedia: MediaType;
+  }
+
+  let { filteredMedia = $bindable() }: Props = $props();
 </script>
 
 <div id="media-type-selection">
diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
index 0c8d32a1ae..8e5059cbbf 100644
--- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte
@@ -10,12 +10,16 @@
   import { t } from 'svelte-i18n';
   import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
 
-  export let selectedPeople: Set<string>;
+  interface Props {
+    selectedPeople: Set<string>;
+  }
+
+  let { selectedPeople = $bindable() }: Props = $props();
 
   let peoplePromise = getPeople();
-  let showAllPeople = false;
-  let name = '';
-  let numberOfPeople = 1;
+  let showAllPeople = $state(false);
+  let name = $state('');
+  let numberOfPeople = $state(1);
 
   function orderBySelectedPeopleFirst(people: PersonResponseDto[]) {
     return [
@@ -72,7 +76,7 @@
             )
               ? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
               : 'border-transparent'}"
-            on:click={() => togglePersonSelection(person.id)}
+            onclick={() => togglePersonSelection(person.id)}
           >
             <ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
             <p class="mt-2 line-clamp-2 text-sm font-medium dark:text-white">{person.name}</p>
@@ -86,7 +90,7 @@
             shadow={false}
             color="text-primary"
             class="flex gap-2 place-items-center"
-            on:click={() => (showAllPeople = !showAllPeople)}
+            onclick={() => (showAllPeople = !showAllPeople)}
           >
             {#if showAllPeople}
               <span><Icon path={mdiClose} ariaHidden /></span>
diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
index c3145b2f0c..2f118e6567 100644
--- a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
+++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte
@@ -2,8 +2,12 @@
   import RadioButton from '$lib/components/elements/radio-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let query: string | undefined;
-  export let queryType: 'smart' | 'metadata' = 'smart';
+  interface Props {
+    query: string | undefined;
+    queryType?: 'smart' | 'metadata';
+  }
+
+  let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
 </script>
 
 <fieldset>
diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/components/shared-components/server-about-modal.svelte
index 1373a98d3f..cf935cd314 100644
--- a/web/src/lib/components/shared-components/server-about-modal.svelte
+++ b/web/src/lib/components/shared-components/server-about-modal.svelte
@@ -7,10 +7,13 @@
   import { mdiAlert } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
 
-  export let onClose: () => void;
+  interface Props {
+    onClose: () => void;
+    info: ServerAboutResponseDto;
+    versions: ServerVersionHistoryResponseDto[];
+  }
 
-  export let info: ServerAboutResponseDto;
-  export let versions: ServerVersionHistoryResponseDto[];
+  let { onClose, info, versions }: Props = $props();
 </script>
 
 <Portal>
diff --git a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte
index a6257fce29..7fbab302d2 100644
--- a/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-accordion-state.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type AccordionState = Set<string>;
 
   const { get: getAccordionState, set: setAccordionState } = createContext<Writable<AccordionState>>();
@@ -11,25 +11,33 @@
   import { page } from '$app/stores';
   import { handlePromiseError } from '$lib/utils';
   import { goto } from '$app/navigation';
+  import type { Snippet } from 'svelte';
 
   const getParamValues = (param: string) => {
     return new Set(($page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== ''));
   };
 
-  export let queryParam: string;
-  export let state: Writable<AccordionState> = writable(getParamValues(queryParam));
+  interface Props {
+    queryParam: string;
+    state?: Writable<AccordionState>;
+    children?: Snippet;
+  }
+
+  let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props();
   setAccordionState(state);
 
-  $: if (queryParam && $state) {
-    const searchParams = new URLSearchParams($page.url.searchParams);
-    if ($state.size > 0) {
-      searchParams.set(queryParam, [...$state].join(' '));
-    } else {
-      searchParams.delete(queryParam);
-    }
+  $effect(() => {
+    if (queryParam && $state) {
+      const searchParams = new URLSearchParams($page.url.searchParams);
+      if ($state.size > 0) {
+        searchParams.set(queryParam, [...$state].join(' '));
+      } else {
+        searchParams.delete(queryParam);
+      }
 
-    handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
-  }
+      handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
+    }
+  });
 </script>
 
-<slot />
+{@render children?.()}
diff --git a/web/src/lib/components/shared-components/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte
index d8b50b2132..0fe1c9dc14 100755
--- a/web/src/lib/components/shared-components/settings/setting-accordion.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-accordion.svelte
@@ -1,21 +1,34 @@
 <script lang="ts">
   import { slide } from 'svelte/transition';
   import { getAccordionState } from './setting-accordion-state.svelte';
-  import { onDestroy } from 'svelte';
+  import { onDestroy, onMount, type Snippet } from 'svelte';
   import Icon from '$lib/components/elements/icon.svelte';
 
   const accordionState = getAccordionState();
 
-  export let title: string;
-  export let subtitle = '';
-  export let key: string;
-  export let isOpen = $accordionState.has(key);
-  export let autoScrollTo = false;
-  export let icon = '';
+  interface Props {
+    title: string;
+    subtitle?: string;
+    key: string;
+    isOpen?: boolean;
+    autoScrollTo?: boolean;
+    icon?: string;
+    subtitleSnippet?: Snippet;
+    children?: Snippet;
+  }
 
-  let accordionElement: HTMLDivElement;
+  let {
+    title,
+    subtitle = '',
+    key,
+    isOpen = $bindable($accordionState.has(key)),
+    autoScrollTo = false,
+    icon = '',
+    subtitleSnippet,
+    children,
+  }: Props = $props();
 
-  $: setIsOpen(isOpen);
+  let accordionElement: HTMLDivElement | undefined = $state();
 
   const setIsOpen = (isOpen: boolean) => {
     if (isOpen) {
@@ -23,7 +36,7 @@
 
       if (autoScrollTo) {
         setTimeout(() => {
-          accordionElement.scrollIntoView({
+          accordionElement?.scrollIntoView({
             behavior: 'smooth',
             block: 'start',
           });
@@ -38,6 +51,15 @@
   onDestroy(() => {
     setIsOpen(false);
   });
+
+  const onclick = () => {
+    isOpen = !isOpen;
+    setIsOpen(isOpen);
+  };
+
+  onMount(() => {
+    setIsOpen(isOpen);
+  });
 </script>
 
 <div
@@ -49,7 +71,7 @@
   <button
     type="button"
     aria-expanded={isOpen}
-    on:click={() => (isOpen = !isOpen)}
+    {onclick}
     class="flex w-full place-items-center justify-between text-left"
   >
     <div>
@@ -62,9 +84,9 @@
         </h2>
       </div>
 
-      <slot name="subtitle">
+      {#if subtitleSnippet}{@render subtitleSnippet()}{:else}
         <p class="text-sm dark:text-immich-dark-fg mt-1">{subtitle}</p>
-      </slot>
+      {/if}
     </div>
 
     <div
@@ -88,7 +110,7 @@
 
   {#if isOpen}
     <ul transition:slide={{ duration: 150 }} class="mb-2 ml-4">
-      <slot />
+      {@render children?.()}
     </ul>
   {/if}
 </div>
diff --git a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
index 97bcb1d499..95edac6dfb 100644
--- a/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte
@@ -3,10 +3,14 @@
   import type { ResetOptions } from '$lib/utils/dipatch';
   import { t } from 'svelte-i18n';
 
-  export let showResetToDefault = true;
-  export let disabled = false;
-  export let onReset: (options: ResetOptions) => void;
-  export let onSave: () => void;
+  interface Props {
+    showResetToDefault?: boolean;
+    disabled?: boolean;
+    onReset: (options: ResetOptions) => void;
+    onSave: () => void;
+  }
+
+  let { showResetToDefault = true, disabled = false, onReset, onSave }: Props = $props();
 </script>
 
 <div class="mt-8 flex justify-between gap-2">
@@ -14,7 +18,7 @@
     {#if showResetToDefault}
       <button
         type="button"
-        on:click={() => onReset({ default: true })}
+        onclick={() => onReset({ default: true })}
         class="bg-none text-sm font-medium text-immich-primary hover:text-immich-primary/75 dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75"
       >
         {$t('reset_to_default')}
@@ -23,7 +27,7 @@
   </div>
 
   <div class="right">
-    <Button {disabled} size="sm" color="gray" on:click={() => onReset({ default: false })}>{$t('reset')}</Button>
-    <Button type="submit" {disabled} size="sm" on:click={() => onSave()}>{$t('save')}</Button>
+    <Button {disabled} size="sm" color="gray" onclick={() => onReset({ default: false })}>{$t('reset')}</Button>
+    <Button type="submit" {disabled} size="sm" onclick={() => onSave()}>{$t('save')}</Button>
   </div>
 </div>
diff --git a/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte b/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte
index 3def0ce08d..09f0ea438b 100644
--- a/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte
@@ -4,13 +4,25 @@
   import { fly } from 'svelte/transition';
   import { t } from 'svelte-i18n';
 
-  export let value: string[];
-  export let options: { value: string; text: string }[];
-  export let label = '';
-  export let desc = '';
-  export let name = '';
-  export let isEdited = false;
-  export let disabled = false;
+  interface Props {
+    value: string[];
+    options: { value: string; text: string }[];
+    label?: string;
+    desc?: string;
+    name?: string;
+    isEdited?: boolean;
+    disabled?: boolean;
+  }
+
+  let {
+    value = $bindable(),
+    options,
+    label = '',
+    desc = '',
+    name = '',
+    isEdited = false,
+    disabled = false,
+  }: Props = $props();
 
   function handleCheckboxChange(option: string) {
     value = value.includes(option) ? value.filter((item) => item !== option) : [...value, option];
@@ -46,7 +58,7 @@
         checked={value.includes(option.value)}
         {disabled}
         labelClass="text-gray-500 dark:text-gray-300"
-        on:change={() => handleCheckboxChange(option.value)}
+        onchange={() => handleCheckboxChange(option.value)}
       />
     {/each}
   </div>
diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte
index 722af048a5..5314ad7193 100644
--- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte
@@ -3,14 +3,29 @@
   import { fly } from 'svelte/transition';
   import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let title: string;
-  export let comboboxPlaceholder: string;
-  export let subtitle = '';
-  export let isEdited = false;
-  export let options: ComboBoxOption[];
-  export let selectedOption: ComboBoxOption;
-  export let onSelect: (combobox: ComboBoxOption | undefined) => void;
+  interface Props {
+    title: string;
+    comboboxPlaceholder: string;
+    subtitle?: string;
+    isEdited?: boolean;
+    options: ComboBoxOption[];
+    selectedOption: ComboBoxOption;
+    onSelect: (combobox: ComboBoxOption | undefined) => void;
+    children?: Snippet;
+  }
+
+  let {
+    title,
+    comboboxPlaceholder,
+    subtitle = '',
+    isEdited = false,
+    options,
+    selectedOption,
+    onSelect,
+    children,
+  }: Props = $props();
 </script>
 
 <div class="grid grid-cols-2">
@@ -33,6 +48,6 @@
   </div>
   <div class="flex items-center">
     <Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
-    <slot />
+    {@render children?.()}
   </div>
 </div>
diff --git a/web/src/lib/components/shared-components/settings/setting-dropdown.svelte b/web/src/lib/components/shared-components/settings/setting-dropdown.svelte
index 20324fe4f8..57e78e6c6f 100644
--- a/web/src/lib/components/shared-components/settings/setting-dropdown.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-dropdown.svelte
@@ -3,14 +3,27 @@
   import { fly } from 'svelte/transition';
   import Dropdown, { type RenderedOption } from '$lib/components/elements/dropdown.svelte';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let title: string;
-  export let subtitle = '';
-  export let options: RenderedOption[];
-  export let selectedOption: RenderedOption;
-  export let isEdited = false;
+  interface Props {
+    title: string;
+    subtitle?: string;
+    options: RenderedOption[];
+    selectedOption: RenderedOption;
+    isEdited?: boolean;
+    onToggle: (option: RenderedOption) => void;
+    children?: Snippet;
+  }
 
-  export let onToggle: (option: RenderedOption) => void;
+  let {
+    title,
+    subtitle = '',
+    options,
+    selectedOption = $bindable(),
+    isEdited = false,
+    onToggle,
+    children,
+  }: Props = $props();
 </script>
 
 <div class="flex place-items-center justify-between">
@@ -30,7 +43,7 @@
     </div>
 
     <p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
-    <slot />
+    {@render children?.()}
   </div>
   <div class="w-fit">
     <Dropdown
diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts
index 642492dda5..80cb920074 100644
--- a/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts
+++ b/web/src/lib/components/shared-components/settings/setting-input-field.spec.ts
@@ -1,7 +1,7 @@
+import { SettingInputFieldType } from '$lib/constants';
 import { render } from '@testing-library/svelte';
 import userEvent from '@testing-library/user-event';
-// @ts-expect-error the import works but tsc check errors
-import SettingInputField, { SettingInputFieldType } from './setting-input-field.svelte';
+import SettingInputField from './setting-input-field.svelte';
 
 describe('SettingInputField component', () => {
   it('validates number input on blur', async () => {
diff --git a/web/src/lib/components/shared-components/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte
index 410adc6458..1463cc4840 100644
--- a/web/src/lib/components/shared-components/settings/setting-input-field.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-input-field.svelte
@@ -1,36 +1,47 @@
-<script lang="ts" context="module">
-  export enum SettingInputFieldType {
-    EMAIL = 'email',
-    TEXT = 'text',
-    NUMBER = 'number',
-    PASSWORD = 'password',
-    COLOR = 'color',
-  }
-</script>
-
 <script lang="ts">
   import { quintOut } from 'svelte/easing';
   import type { FormEventHandler } from 'svelte/elements';
   import { fly } from 'svelte/transition';
   import PasswordField from '../password-field.svelte';
   import { t } from 'svelte-i18n';
-  import { onMount, tick } from 'svelte';
+  import { onMount, tick, type Snippet } from 'svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  export let inputType: SettingInputFieldType;
-  export let value: string | number;
-  export let min = Number.MIN_SAFE_INTEGER;
-  export let max = Number.MAX_SAFE_INTEGER;
-  export let step = '1';
-  export let label = '';
-  export let desc = '';
-  export let title = '';
-  export let required = false;
-  export let disabled = false;
-  export let isEdited = false;
-  export let autofocus = false;
-  export let passwordAutocomplete: AutoFill = 'current-password';
+  interface Props {
+    inputType: SettingInputFieldType;
+    value: string | number;
+    min?: number;
+    max?: number;
+    step?: string;
+    label?: string;
+    description?: string;
+    title?: string;
+    required?: boolean;
+    disabled?: boolean;
+    isEdited?: boolean;
+    autofocus?: boolean;
+    passwordAutocomplete?: AutoFill;
+    descriptionSnippet?: Snippet;
+  }
 
-  let input: HTMLInputElement;
+  let {
+    inputType,
+    value = $bindable(),
+    min = Number.MIN_SAFE_INTEGER,
+    max = Number.MAX_SAFE_INTEGER,
+    step = '1',
+    label = '',
+    description = '',
+    title = '',
+    required = false,
+    disabled = false,
+    isEdited = false,
+    autofocus = false,
+    passwordAutocomplete = 'current-password',
+    descriptionSnippet,
+  }: Props = $props();
+
+  let input: HTMLInputElement | undefined = $state();
 
   const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
     value = e.currentTarget.value;
@@ -73,12 +84,12 @@
     {/if}
   </div>
 
-  {#if desc}
+  {#if description}
     <p class="immich-form-label pb-2 text-sm" id="{label}-desc">
-      {desc}
+      {description}
     </p>
   {:else}
-    <slot name="desc" />
+    {@render descriptionSnippet?.()}
   {/if}
 
   {#if inputType !== SettingInputFieldType.PASSWORD}
@@ -87,7 +98,7 @@
         <input
           bind:this={input}
           class="immich-form-input w-full pb-2 rounded-none mr-1"
-          aria-describedby={desc ? `${label}-desc` : undefined}
+          aria-describedby={description ? `${label}-desc` : undefined}
           aria-labelledby="{label}-label"
           id={label}
           name={label}
@@ -97,7 +108,7 @@
           {step}
           {required}
           {value}
-          on:change={handleChange}
+          onchange={handleChange}
           {disabled}
           {title}
         />
@@ -107,7 +118,7 @@
         bind:this={input}
         class="immich-form-input w-full pb-2"
         class:color-picker={inputType === SettingInputFieldType.COLOR}
-        aria-describedby={desc ? `${label}-desc` : undefined}
+        aria-describedby={description ? `${label}-desc` : undefined}
         aria-labelledby="{label}-label"
         id={label}
         name={label}
@@ -117,14 +128,14 @@
         {step}
         {required}
         {value}
-        on:change={handleChange}
+        onchange={handleChange}
         {disabled}
         {title}
       />
     </div>
   {:else}
     <PasswordField
-      aria-describedby={desc ? `${label}-desc` : undefined}
+      aria-describedby={description ? `${label}-desc` : undefined}
       aria-labelledby="{label}-label"
       id={label}
       name={label}
diff --git a/web/src/lib/components/shared-components/settings/setting-select.svelte b/web/src/lib/components/shared-components/settings/setting-select.svelte
index 92cabbff25..44f03075da 100644
--- a/web/src/lib/components/shared-components/settings/setting-select.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-select.svelte
@@ -5,15 +5,29 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { mdiChevronDown } from '@mdi/js';
 
-  export let value: string | number;
-  export let options: { value: string | number; text: string }[];
-  export let label = '';
-  export let desc = '';
-  export let name = '';
-  export let isEdited = false;
-  export let number = false;
-  export let disabled = false;
-  export let onSelect: (setting: string | number) => void = () => {};
+  interface Props {
+    value: string | number;
+    options: { value: string | number; text: string }[];
+    label?: string;
+    desc?: string;
+    name?: string;
+    isEdited?: boolean;
+    number?: boolean;
+    disabled?: boolean;
+    onSelect?: (setting: string | number) => void;
+  }
+
+  let {
+    value = $bindable(),
+    options,
+    label = '',
+    desc = '',
+    name = '',
+    isEdited = false,
+    number = false,
+    disabled = false,
+    onSelect = () => {},
+  }: Props = $props();
 
   const handleChange = (e: Event) => {
     value = (e.target as HTMLInputElement).value;
@@ -62,7 +76,7 @@
       {name}
       id="{name}-select"
       bind:value
-      on:change={handleChange}
+      onchange={handleChange}
     >
       {#each options as option}
         <option value={option.value}>{option.text}</option>
diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte
index 11716526f8..29c1f213d3 100644
--- a/web/src/lib/components/shared-components/settings/setting-switch.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte
@@ -4,18 +4,32 @@
   import Slider from '$lib/components/elements/slider.svelte';
   import { generateId } from '$lib/utils/generate-id';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let title: string;
-  export let subtitle = '';
-  export let checked = false;
-  export let disabled = false;
-  export let isEdited = false;
-  export let onToggle: (isChecked: boolean) => void = () => {};
+  interface Props {
+    title: string;
+    subtitle?: string;
+    checked?: boolean;
+    disabled?: boolean;
+    isEdited?: boolean;
+    onToggle?: (isChecked: boolean) => void;
+    children?: Snippet;
+  }
+
+  let {
+    title,
+    subtitle = '',
+    checked = $bindable(false),
+    disabled = false,
+    isEdited = false,
+    onToggle = () => {},
+    children,
+  }: Props = $props();
 
   let id: string = generateId();
 
-  $: sliderId = `${id}-slider`;
-  $: subtitleId = subtitle ? `${id}-subtitle` : undefined;
+  let sliderId = $derived(`${id}-slider`);
+  let subtitleId = $derived(subtitle ? `${id}-subtitle` : undefined);
 </script>
 
 <div class="flex place-items-center justify-between">
@@ -37,7 +51,7 @@
     {#if subtitle}
       <p id={subtitleId} class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
     {/if}
-    <slot />
+    {@render children?.()}
   </div>
 
   <Slider id={sliderId} bind:checked {disabled} {onToggle} ariaDescribedBy={subtitleId} />
diff --git a/web/src/lib/components/shared-components/settings/setting-textarea.svelte b/web/src/lib/components/shared-components/settings/setting-textarea.svelte
index 5c7b138388..9f9f885263 100644
--- a/web/src/lib/components/shared-components/settings/setting-textarea.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-textarea.svelte
@@ -2,13 +2,27 @@
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import { t } from 'svelte-i18n';
+  import type { Snippet } from 'svelte';
 
-  export let value: string;
-  export let label = '';
-  export let desc = '';
-  export let required = false;
-  export let disabled = false;
-  export let isEdited = false;
+  interface Props {
+    value: string;
+    label?: string;
+    description?: string;
+    required?: boolean;
+    disabled?: boolean;
+    isEdited?: boolean;
+    descriptionSnippet?: Snippet;
+  }
+
+  let {
+    value = $bindable(),
+    label = '',
+    description = '',
+    required = false,
+    disabled = false,
+    isEdited = false,
+    descriptionSnippet,
+  }: Props = $props();
 
   const handleInput = (e: Event) => {
     value = (e.target as HTMLInputElement).value;
@@ -32,23 +46,23 @@
     {/if}
   </div>
 
-  {#if desc}
+  {#if description}
     <p class="immich-form-label pb-2 text-sm" id="{label}-desc">
-      {desc}
+      {description}
     </p>
   {:else}
-    <slot name="desc" />
+    {@render descriptionSnippet?.()}
   {/if}
 
   <textarea
     class="immich-form-input w-full pb-2"
-    aria-describedby={desc ? `${label}-desc` : undefined}
+    aria-describedby={description ? `${label}-desc` : undefined}
     aria-labelledby="{label}-label"
     id={label}
     name={label}
     {required}
     {value}
-    on:input={handleInput}
+    oninput={handleInput}
     {disabled}
   ></textarea>
 </div>
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte
index 2bd1b8976b..a3cfd83ad5 100644
--- a/web/src/lib/components/shared-components/show-shortcuts.svelte
+++ b/web/src/lib/components/shared-components/show-shortcuts.svelte
@@ -15,25 +15,31 @@
     info?: string;
   }
 
-  export let onClose: () => void;
+  interface Props {
+    onClose: () => void;
+    shortcuts?: Shortcuts;
+  }
 
-  export let shortcuts: Shortcuts = {
-    general: [
-      { key: ['←', '→'], action: $t('previous_or_next_photo') },
-      { key: ['Esc'], action: $t('back_close_deselect') },
-      { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
-      { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
-    ],
-    actions: [
-      { key: ['f'], action: $t('favorite_or_unfavorite_photo') },
-      { key: ['i'], action: $t('show_or_hide_info') },
-      { key: ['s'], action: $t('stack_selected_photos') },
-      { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
-      { key: ['⇧', 'd'], action: $t('download') },
-      { key: ['Space'], action: $t('play_or_pause_video') },
-      { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
-    ],
-  };
+  let {
+    onClose,
+    shortcuts = {
+      general: [
+        { key: ['←', '→'], action: $t('previous_or_next_photo') },
+        { key: ['Esc'], action: $t('back_close_deselect') },
+        { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
+        { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
+      ],
+      actions: [
+        { key: ['f'], action: $t('favorite_or_unfavorite_photo') },
+        { key: ['i'], action: $t('show_or_hide_info') },
+        { key: ['s'], action: $t('stack_selected_photos') },
+        { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
+        { key: ['⇧', 'd'], action: $t('download') },
+        { key: ['Space'], action: $t('play_or_pause_video') },
+        { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
+      ],
+    },
+  }: Props = $props();
 </script>
 
 <FullScreenModal title={$t('keyboard_shortcuts')} width="auto" {onClose}>
diff --git a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte
index 68c58ab155..58ce0c8574 100644
--- a/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte
+++ b/web/src/lib/components/shared-components/side-bar/more-information-albums.svelte
@@ -3,7 +3,11 @@
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { t } from 'svelte-i18n';
 
-  export let albumType: keyof AlbumStatisticsResponseDto;
+  interface Props {
+    albumType: keyof AlbumStatisticsResponseDto;
+  }
+
+  let { albumType }: Props = $props();
 
   const handleAlbumCount = async () => {
     try {
diff --git a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte
index 1da245390b..5e4589be18 100644
--- a/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte
+++ b/web/src/lib/components/shared-components/side-bar/more-information-assets.svelte
@@ -3,7 +3,11 @@
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { t } from 'svelte-i18n';
 
-  export let assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
+  interface Props {
+    assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
+  }
+
+  let { assetStats }: Props = $props();
 </script>
 
 {#await getAssetStatistics(assetStats)}
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
index a284c7efc1..2c4ab8818c 100644
--- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
+++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
@@ -18,12 +18,12 @@
   import { getButtonVisibility } from '$lib/utils/purchase-utils';
   import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
 
-  let showMessage = false;
-  let isOpen = false;
-  let hoverMessage = false;
-  let hoverButton = false;
+  let showMessage = $state(false);
+  let isOpen = $state(false);
+  let hoverMessage = $state(false);
+  let hoverButton = $state(false);
 
-  let showBuyButton = getButtonVisibility();
+  let showBuyButton = $state(getButtonVisibility());
 
   const { isPurchased } = purchaseStore;
 
@@ -63,13 +63,15 @@
     }
   };
 
-  $: if (showMessage && !hoverMessage && !hoverButton) {
-    setTimeout(() => {
-      if (!hoverMessage && !hoverButton) {
-        showMessage = false;
-      }
-    }, 300);
-  }
+  $effect(() => {
+    if (showMessage && !hoverMessage && !hoverButton) {
+      setTimeout(() => {
+        if (!hoverMessage && !hoverButton) {
+          showMessage = false;
+        }
+      }, 300);
+    }
+  });
 </script>
 
 {#if isOpen}
@@ -79,7 +81,7 @@
 <div class="hidden md:block license-status pl-4 text-sm">
   {#if $isPurchased && $preferences.purchase.showSupportBadge}
     <button
-      on:click={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
+      onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
       class="w-full"
       type="button"
     >
@@ -88,11 +90,11 @@
   {:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
     <button
       type="button"
-      on:click={openPurchaseModal}
-      on:mouseover={onButtonHover}
-      on:mouseleave={() => (hoverButton = false)}
-      on:focus={onButtonHover}
-      on:blur={() => (hoverButton = false)}
+      onclick={openPurchaseModal}
+      onmouseover={onButtonHover}
+      onmouseleave={() => (hoverButton = false)}
+      onfocus={onButtonHover}
+      onblur={() => (hoverButton = false)}
       class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 w-full"
     >
       <div class="flex justify-between w-full place-items-center place-content-center">
@@ -122,10 +124,10 @@
     <div
       class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
       transition:fade={{ duration: 150 }}
-      on:mouseover={() => (hoverMessage = true)}
-      on:mouseleave={() => (hoverMessage = false)}
-      on:focus={() => (hoverMessage = true)}
-      on:blur={() => (hoverMessage = false)}
+      onmouseover={() => (hoverMessage = true)}
+      onmouseleave={() => (hoverMessage = false)}
+      onfocus={() => (hoverMessage = true)}
+      onblur={() => (hoverMessage = false)}
       role="dialog"
     >
       <div class="flex justify-between place-items-center">
@@ -134,7 +136,7 @@
         </div>
         <CircleIconButton
           icon={mdiClose}
-          on:click={() => {
+          onclick={() => {
             showMessage = false;
           }}
           title={$t('close')}
@@ -157,12 +159,12 @@
         </p>
       </div>
 
-      <Button class="mt-2" fullwidth on:click={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button>
+      <Button class="mt-2" fullwidth onclick={openPurchaseModal}>{$t('purchase_button_buy_immich')}</Button>
       <div class="mt-3 flex gap-4">
-        <Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(true)}>
+        <Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(true)}>
           {$t('purchase_button_never_show_again')}
         </Button>
-        <Button size="sm" fullwidth shadow={false} color="transparent-gray" on:click={() => hideButton(false)}>
+        <Button size="sm" fullwidth shadow={false} color="transparent-gray" onclick={() => hideButton(false)}>
           {$t('purchase_button_reminder')}
         </Button>
       </div>
diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte
index 9774c07c63..2a0e6a0821 100644
--- a/web/src/lib/components/shared-components/side-bar/server-status.svelte
+++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte
@@ -15,21 +15,22 @@
 
   const { serverVersion, connected } = websocketStore;
 
-  let isOpen = false;
+  let isOpen = $state(false);
 
-  $: isMain = info?.sourceRef === 'main' && info.repository === 'immich-app/immich';
-  $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
-
-  let info: ServerAboutResponseDto;
-  let versions: ServerVersionHistoryResponseDto[] = [];
+  let info: ServerAboutResponseDto | undefined = $state();
+  let versions: ServerVersionHistoryResponseDto[] = $state([]);
 
   onMount(async () => {
     await requestServerInfo();
     [info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
   });
+  let isMain = $derived(info?.sourceRef === 'main' && info.repository === 'immich-app/immich');
+  let version = $derived(
+    $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
+  );
 </script>
 
-{#if isOpen}
+{#if isOpen && info}
   <ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} />
 {/if}
 
@@ -50,9 +51,9 @@
 
   <div class="flex justify-between justify-items-center">
     {#if $connected && version}
-      <button type="button" on:click={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1">
+      <button type="button" onclick={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1">
         {#if isMain}
-          <Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info.sourceRef}
+          <Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info?.sourceRef}
         {:else}
           {version}
         {/if}
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte
index 4590b12255..d3fd94ae08 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte
@@ -4,17 +4,34 @@
   import { mdiInformationOutline } from '@mdi/js';
   import { resolveRoute } from '$app/paths';
   import { page } from '$app/stores';
+  import type { Snippet } from 'svelte';
 
-  export let title: string;
-  export let routeId: string;
-  export let icon: string;
-  export let flippedLogo = false;
-  export let isSelected = false;
-  export let preloadData = true;
+  interface Props {
+    title: string;
+    routeId: string;
+    icon: string;
+    flippedLogo?: boolean;
+    isSelected?: boolean;
+    preloadData?: boolean;
+    moreInformation?: Snippet;
+  }
 
-  let showMoreInformation = false;
-  $: routePath = resolveRoute(routeId, {});
-  $: isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
+  let {
+    title,
+    routeId,
+    icon,
+    flippedLogo = false,
+    isSelected = $bindable(false),
+    preloadData = true,
+    moreInformation,
+  }: Props = $props();
+
+  let showMoreInformation = $state(false);
+  let routePath = $derived(resolveRoute(routeId, {}));
+
+  $effect(() => {
+    isSelected = ($page.route.id?.match(/^\/(admin|\(user\))\/[^/]*/) || [])[0] === routeId;
+  });
 </script>
 
 <a
@@ -37,12 +54,12 @@
   <div
     class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
   >
-    {#if $$slots.moreInformation}
-      <!-- svelte-ignore a11y-no-static-element-interactions -->
+    {#if moreInformation}
+      <!-- svelte-ignore a11y_no_static_element_interactions -->
       <div
         class="relative flex cursor-default select-none justify-center"
-        on:mouseenter={() => (showMoreInformation = true)}
-        on:mouseleave={() => (showMoreInformation = false)}
+        onmouseenter={() => (showMoreInformation = true)}
+        onmouseleave={() => (showMoreInformation = false)}
       >
         <div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
           <Icon path={mdiInformationOutline} />
@@ -55,7 +72,7 @@
               class:hidden={!showMoreInformation}
               transition:fade={{ duration: 200 }}
             >
-              <slot name="moreInformation" />
+              {@render moreInformation?.()}
             </div>
           </div>
         {/if}
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte
index 233010153f..37867da7af 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte
@@ -1,4 +1,11 @@
 <script lang="ts">
+  import type { Snippet } from 'svelte';
+
+  interface Props {
+    children?: Snippet;
+  }
+
+  let { children }: Props = $props();
 </script>
 
 <section
@@ -6,5 +13,5 @@
   tabindex="-1"
   class="immich-scrollbar group relative z-10 flex w-18 flex-col gap-1 overflow-y-auto bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg hover:sm:w-64 hover:sm:border-r hover:sm:pr-6 hover:sm:shadow-2xl hover:sm:dark:border-r-immich-dark-gray md:w-64 md:pr-6 hover:md:border-none hover:md:shadow-none"
 >
-  <slot />
+  {@render children?.()}
 </section>
diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
index fab7c6ed6d..54607e1779 100644
--- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte
+++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte
@@ -30,14 +30,14 @@
   import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
   import { preferences } from '$lib/stores/user.store';
 
-  let isArchiveSelected: boolean;
-  let isFavoritesSelected: boolean;
-  let isMapSelected: boolean;
-  let isPeopleSelected: boolean;
-  let isPhotosSelected: boolean;
-  let isSharingSelected: boolean;
-  let isTrashSelected: boolean;
-  let isUtilitiesSelected: boolean;
+  let isArchiveSelected: boolean = $state(false);
+  let isFavoritesSelected: boolean = $state(false);
+  let isMapSelected: boolean = $state(false);
+  let isPeopleSelected: boolean = $state(false);
+  let isPhotosSelected: boolean = $state(false);
+  let isSharingSelected: boolean = $state(false);
+  let isTrashSelected: boolean = $state(false);
+  let isUtilitiesSelected: boolean = $state(false);
 </script>
 
 <SideBarSection>
@@ -48,9 +48,9 @@
       bind:isSelected={isPhotosSelected}
       icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
     >
-      <svelte:fragment slot="moreInformation">
+      {#snippet moreInformation()}
         <MoreInformationAssets assetStats={{ isArchived: false }} />
-      </svelte:fragment>
+      {/snippet}
     </SideBarLink>
 
     {#if $featureFlags.search}
@@ -81,9 +81,9 @@
       icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
       bind:isSelected={isSharingSelected}
     >
-      <svelte:fragment slot="moreInformation">
+      {#snippet moreInformation()}
         <MoreInformationAlbums albumType="shared" />
-      </svelte:fragment>
+      {/snippet}
     </SideBarLink>
 
     <div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
@@ -97,15 +97,15 @@
       icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
       bind:isSelected={isFavoritesSelected}
     >
-      <svelte:fragment slot="moreInformation">
+      {#snippet moreInformation()}
         <MoreInformationAssets assetStats={{ isFavorite: true }} />
-      </svelte:fragment>
+      {/snippet}
     </SideBarLink>
 
     <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
-      <svelte:fragment slot="moreInformation">
+      {#snippet moreInformation()}
         <MoreInformationAlbums albumType="owned" />
-      </svelte:fragment>
+      {/snippet}
     </SideBarLink>
 
     {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
@@ -129,9 +129,9 @@
       bind:isSelected={isArchiveSelected}
       icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
     >
-      <svelte:fragment slot="moreInformation">
+      {#snippet moreInformation()}
         <MoreInformationAssets assetStats={{ isArchived: true }} />
-      </svelte:fragment>
+      {/snippet}
     </SideBarLink>
 
     {#if $featureFlags.trash}
@@ -141,9 +141,9 @@
         bind:isSelected={isTrashSelected}
         icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
       >
-        <svelte:fragment slot="moreInformation">
+        {#snippet moreInformation()}
           <MoreInformationAssets assetStats={{ isTrashed: true }} />
-        </svelte:fragment>
+        {/snippet}
       </SideBarLink>
     {/if}
   </nav>
diff --git a/web/src/lib/components/shared-components/side-bar/storage-space.svelte b/web/src/lib/components/shared-components/side-bar/storage-space.svelte
index c62b73e1b2..c0de9378ac 100644
--- a/web/src/lib/components/shared-components/side-bar/storage-space.svelte
+++ b/web/src/lib/components/shared-components/side-bar/storage-space.svelte
@@ -8,12 +8,12 @@
   import { getByteUnitString } from '../../../utils/byte-units';
   import LoadingSpinner from '../loading-spinner.svelte';
 
-  let usageClasses = '';
+  let usageClasses = $state('');
 
-  $: hasQuota = $user?.quotaSizeInBytes !== null;
-  $: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
-  $: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
-  $: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100);
+  let hasQuota = $derived($user?.quotaSizeInBytes !== null);
+  let availableBytes = $derived((hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0);
+  let usedBytes = $derived((hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0);
+  let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
 
   const onUpdate = () => {
     usageClasses = getUsageClass();
@@ -31,9 +31,11 @@
     return 'bg-immich-primary dark:bg-immich-dark-primary';
   };
 
-  $: if ($user) {
-    onUpdate();
-  }
+  $effect(() => {
+    if ($user) {
+      onUpdate();
+    }
+  });
 
   onMount(async () => {
     await requestServerInfo();
diff --git a/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte b/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte
index f2cb326c39..3d5e815996 100644
--- a/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte
+++ b/web/src/lib/components/shared-components/side-bar/supporter-badge.svelte
@@ -2,8 +2,12 @@
   import { t } from 'svelte-i18n';
   import ImmichLogo from '../immich-logo.svelte';
 
-  export let centered = false;
-  export let logoSize: 'sm' | 'lg' = 'sm';
+  interface Props {
+    centered?: boolean;
+    logoSize?: 'sm' | 'lg';
+  }
+
+  let { centered = false, logoSize = 'sm' }: Props = $props();
 </script>
 
 <div
diff --git a/web/src/lib/components/shared-components/single-grid-row.svelte b/web/src/lib/components/shared-components/single-grid-row.svelte
index 90020f2922..7764b9eb17 100644
--- a/web/src/lib/components/shared-components/single-grid-row.svelte
+++ b/web/src/lib/components/shared-components/single-grid-row.svelte
@@ -1,10 +1,14 @@
 <script lang="ts">
-  let className = '';
-  export { className as class };
-  export let itemCount = 1;
+  interface Props {
+    class?: string;
+    itemCount?: number;
+    children?: import('svelte').Snippet<[{ itemCount: number }]>;
+  }
 
-  let container: HTMLElement | undefined;
-  let contentRect: DOMRectReadOnly | undefined;
+  let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
+
+  let container: HTMLElement | undefined = $state();
+  let contentRect: DOMRectReadOnly | undefined = $state();
 
   const getGridGap = (element: Element) => {
     const style = getComputedStyle(element);
@@ -28,11 +32,13 @@
     return Math.floor((containerWidth + columnGap) / (childWidth + columnGap)) || 1;
   };
 
-  $: if (container && contentRect) {
-    itemCount = getItemCount(container, contentRect.width);
-  }
+  $effect(() => {
+    if (container && contentRect) {
+      itemCount = getItemCount(container, contentRect.width);
+    }
+  });
 </script>
 
 <div class={className} bind:this={container} bind:contentRect>
-  <slot {itemCount} />
+  {@render children?.({ itemCount })}
 </div>
diff --git a/web/src/lib/components/shared-components/star-rating.svelte b/web/src/lib/components/shared-components/star-rating.svelte
index ee1b2b7433..333248c227 100644
--- a/web/src/lib/components/shared-components/star-rating.svelte
+++ b/web/src/lib/components/shared-components/star-rating.svelte
@@ -5,17 +5,23 @@
   import { generateId } from '$lib/utils/generate-id';
   import { t } from 'svelte-i18n';
 
-  export let count = 5;
-  export let rating: number;
-  export let readOnly = false;
-  export let onRating: (rating: number) => void | undefined;
+  interface Props {
+    count?: number;
+    rating: number;
+    readOnly?: boolean;
+    onRating: (rating: number) => void | undefined;
+  }
 
-  let ratingSelection = 0;
-  let hoverRating = 0;
-  let focusRating = 0;
+  let { count = 5, rating, readOnly = false, onRating }: Props = $props();
+
+  let ratingSelection = $state(rating);
+  let hoverRating = $state(0);
+  let focusRating = $state(0);
   let timeoutId: ReturnType<typeof setTimeout> | undefined;
 
-  $: ratingSelection = rating;
+  $effect(() => {
+    ratingSelection = rating;
+  });
 
   const starIcon =
     'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
@@ -53,10 +59,10 @@
   };
 </script>
 
-<!-- svelte-ignore a11y-mouse-events-have-key-events -->
+<!-- svelte-ignore a11y_mouse_events_have_key_events -->
 <fieldset
   class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
-  on:mouseleave={() => setHoverRating(0)}
+  onmouseleave={() => setHoverRating(0)}
   use:focusOutside={{ onFocusOut: reset }}
   use:shortcuts={[
     { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
@@ -69,13 +75,13 @@
       {@const value = index + 1}
       {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
       {@const starId = `${id}-${value}`}
-      <!-- svelte-ignore a11y-mouse-events-have-key-events -->
-      <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
+      <!-- svelte-ignore a11y_mouse_events_have_key_events -->
+      <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
       <label
         for={starId}
         class:cursor-pointer={!readOnly}
         class:ring-2={focusRating === value}
-        on:mouseover={() => setHoverRating(value)}
+        onmouseover={() => setHoverRating(value)}
         tabindex={-1}
         data-testid="star"
       >
@@ -96,10 +102,10 @@
         id={starId}
         bind:group={ratingSelection}
         disabled={readOnly}
-        on:focus={() => {
+        onfocus={() => {
           focusRating = value;
         }}
-        on:change={() => handleSelectDebounced(value)}
+        onchange={() => handleSelectDebounced(value)}
         class="sr-only"
       />
     {/each}
@@ -108,7 +114,7 @@
 {#if ratingSelection > 0 && !readOnly}
   <button
     type="button"
-    on:click={() => {
+    onclick={() => {
       ratingSelection = 0;
       handleSelect(ratingSelection);
     }}
diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte
index f5ba87799b..446668256f 100644
--- a/web/src/lib/components/shared-components/theme-button.svelte
+++ b/web/src/lib/components/shared-components/theme-button.svelte
@@ -5,14 +5,15 @@
   import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
   import { t } from 'svelte-i18n';
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: icon = $colorTheme.value === Theme.LIGHT ? moonPath : sunPath;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: viewBox = $colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: isDark = $colorTheme.value === Theme.DARK;
+  let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath);
+  let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox);
+  let isDark = $derived($colorTheme.value === Theme.DARK);
 
-  export let padding: Padding = '3';
+  interface Props {
+    padding?: Padding;
+  }
+
+  let { padding = '3' }: Props = $props();
 </script>
 
 {#if !$colorTheme.system}
@@ -22,7 +23,7 @@
     {viewBox}
     role="switch"
     aria-checked={isDark ? 'true' : 'false'}
-    on:click={handleToggleTheme}
+    onclick={handleToggleTheme}
     {padding}
   />
 {/if}
diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
index a3c49a1430..1d841339bc 100644
--- a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
+++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
@@ -4,12 +4,16 @@
   import { mdiArrowUpLeft, mdiChevronRight } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let pathSegments: string[] = [];
-  export let getLink: (path: string) => string;
-  export let title: string;
-  export let icon: string;
+  interface Props {
+    pathSegments?: string[];
+    getLink: (path: string) => string;
+    title: string;
+    icon: string;
+  }
 
-  $: isRoot = pathSegments.length === 0;
+  let { pathSegments = [], getLink, title, icon }: Props = $props();
+
+  let isRoot = $derived(pathSegments.length === 0);
 </script>
 
 <nav class="flex items-center py-2">
@@ -21,6 +25,7 @@
         href={getLink(pathSegments.slice(0, -1).join('/'))}
         class="mr-2"
         padding="2"
+        onclick={() => {}}
       />
     </div>
   {/if}
@@ -37,6 +42,7 @@
           size="1.25em"
           padding="2"
           aria-current={isRoot ? 'page' : undefined}
+          onclick={() => {}}
         />
       </li>
       {#each pathSegments as segment, index}
diff --git a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte
index 759a3e5e65..1b4d30d050 100644
--- a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte
+++ b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte
@@ -1,9 +1,13 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
 
-  export let items: string[] = [];
-  export let icon: string;
-  export let onClick: (path: string) => void;
+  interface Props {
+    items?: string[];
+    icon: string;
+    onClick: (path: string) => void;
+  }
+
+  let { items = [], icon, onClick }: Props = $props();
 </script>
 
 {#if items.length > 0}
@@ -13,7 +17,7 @@
     {#each items as item}
       <button
         class="flex flex-col place-items-center gap-2 py-2 px-4 hover:bg-immich-primary/10 dark:hover:bg-immich-primary/40 rounded-xl"
-        on:click={() => onClick(item)}
+        onclick={() => onClick(item)}
         title={item}
         type="button"
       >
diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte
index 4bdc95db9f..c6db9fec8d 100644
--- a/web/src/lib/components/shared-components/tree/tree-items.svelte
+++ b/web/src/lib/components/shared-components/tree/tree-items.svelte
@@ -2,12 +2,16 @@
   import Tree from '$lib/components/shared-components/tree/tree.svelte';
   import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
 
-  export let items: RecursiveObject;
-  export let parent = '';
-  export let active = '';
-  export let icons: { default: string; active: string };
-  export let getLink: (path: string) => string;
-  export let getColor: (path: string) => string | undefined = () => undefined;
+  interface Props {
+    items: RecursiveObject;
+    parent?: string;
+    active?: string;
+    icons: { default: string; active: string };
+    getLink: (path: string) => string;
+    getColor?: (path: string) => string | undefined;
+  }
+
+  let { items, parent = '', active = '', icons, getLink, getColor = () => undefined }: Props = $props();
 </script>
 
 <ul class="list-none ml-2">
diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte
index 5c4b367a54..c6a13ec197 100644
--- a/web/src/lib/components/shared-components/tree/tree.svelte
+++ b/web/src/lib/components/shared-components/tree/tree.svelte
@@ -4,19 +4,31 @@
   import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
   import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
 
-  export let tree: RecursiveObject;
-  export let parent: string;
-  export let value: string;
-  export let active = '';
-  export let icons: { default: string; active: string };
-  export let getLink: (path: string) => string;
-  export let getColor: (path: string) => string | undefined;
+  interface Props {
+    tree: RecursiveObject;
+    parent: string;
+    value: string;
+    active?: string;
+    icons: { default: string; active: string };
+    getLink: (path: string) => string;
+    getColor: (path: string) => string | undefined;
+  }
 
-  $: path = normalizeTreePath(`${parent}/${value}`);
-  $: isActive = active === path || active.startsWith(`${path}/`);
-  $: isOpen = isActive;
-  $: isTarget = active === path;
-  $: color = getColor(path);
+  let { tree, parent, value, active = '', icons, getLink, getColor }: Props = $props();
+
+  let path = $derived(normalizeTreePath(`${parent}/${value}`));
+  let isActive = $derived(active === path || active.startsWith(`${path}/`));
+  let isOpen = $state(false);
+  $effect(() => {
+    isOpen = isActive;
+  });
+  let isTarget = $derived(active === path);
+  let color = $derived(getColor(path));
+
+  const onclick = (event: MouseEvent) => {
+    event.preventDefault();
+    isOpen = !isOpen;
+  };
 </script>
 
 <a
@@ -24,11 +36,7 @@
   title={value}
   class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
 >
-  <button
-    type="button"
-    on:click|preventDefault={() => (isOpen = !isOpen)}
-    class={Object.values(tree).length === 0 ? 'invisible' : ''}
-  >
+  <button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}>
     <Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
   </button>
   <div>
diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte
index 7765e2ce5c..bd3b7856d1 100644
--- a/web/src/lib/components/shared-components/upload-asset-preview.svelte
+++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte
@@ -20,7 +20,11 @@
   import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
 
-  export let uploadAsset: UploadAsset;
+  interface Props {
+    uploadAsset: UploadAsset;
+  }
+
+  let { uploadAsset }: Props = $props();
 
   const handleDismiss = (uploadAsset: UploadAsset) => {
     uploadAssetsStore.removeItem(uploadAsset.id);
@@ -74,16 +78,16 @@
         >
           <Icon path={mdiOpenInNew} size="20" />
         </a>
-        <button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
+        <button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
           <Icon path={mdiClose} size="20" />
         </button>
       </div>
     {:else if uploadAsset.state === UploadState.ERROR}
       <div class="flex items-center justify-between gap-1">
-        <button type="button" on:click={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
+        <button type="button" onclick={() => handleRetry(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
           <Icon path={mdiRestart} size="20" />
         </button>
-        <button type="button" on:click={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
+        <button type="button" onclick={() => handleDismiss(uploadAsset)} class="" aria-hidden="true" tabindex={-1}>
           <Icon path={mdiClose} size="20" />
         </button>
       </div>
diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte
index d536053286..7dd6d25596 100644
--- a/web/src/lib/components/shared-components/upload-panel.svelte
+++ b/web/src/lib/components/shared-components/upload-panel.svelte
@@ -11,9 +11,9 @@
   import { t } from 'svelte-i18n';
   import { locale } from '$lib/stores/preferences.store';
 
-  let showDetail = false;
-  let showOptions = false;
-  let concurrency = uploadExecutionQueue.concurrency;
+  let showDetail = $state(false);
+  let showOptions = $state(false);
+  let concurrency = $state(uploadExecutionQueue.concurrency);
 
   let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
 
@@ -27,16 +27,18 @@
     }
   };
 
-  $: if ($isUploading) {
-    autoHide();
-  }
+  $effect(() => {
+    if ($isUploading) {
+      autoHide();
+    }
+  });
 </script>
 
 {#if $isUploading}
   <div
     in:fade={{ duration: 250 }}
     out:fade={{ duration: 250 }}
-    on:outroend={() => {
+    onoutroend={() => {
       if ($stats.errors > 0) {
         notificationController.show({
           message: $t('upload_errors', { values: { count: $stats.errors } }),
@@ -92,14 +94,14 @@
                 icon={mdiCog}
                 size="14"
                 padding="1"
-                on:click={() => (showOptions = !showOptions)}
+                onclick={() => (showOptions = !showOptions)}
               />
               <CircleIconButton
                 title={$t('minimize')}
                 icon={mdiWindowMinimize}
                 size="14"
                 padding="1"
-                on:click={() => (showDetail = false)}
+                onclick={() => (showDetail = false)}
               />
             </div>
             {#if $isDismissible}
@@ -108,7 +110,7 @@
                 icon={mdiCancel}
                 size="14"
                 padding="1"
-                on:click={() => uploadAssetsStore.dismissErrors()}
+                onclick={() => uploadAssetsStore.dismissErrors()}
               />
             {/if}
           </div>
@@ -128,7 +130,7 @@
               max="50"
               step="1"
               bind:value={concurrency}
-              on:change={() => (uploadExecutionQueue.concurrency = concurrency)}
+              onchange={() => (uploadExecutionQueue.concurrency = concurrency)}
             />
           </div>
         {/if}
@@ -143,7 +145,7 @@
         <button
           type="button"
           in:scale={{ duration: 250, easing: quartInOut }}
-          on:click={() => (showDetail = true)}
+          onclick={() => (showDetail = true)}
           class="absolute -left-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-primary p-5 text-xs text-gray-200"
         >
           {$remainingUploads.toLocaleString($locale)}
@@ -152,7 +154,7 @@
           <button
             type="button"
             in:scale={{ duration: 250, easing: quartInOut }}
-            on:click={() => (showDetail = true)}
+            onclick={() => (showDetail = true)}
             class="absolute -right-4 -top-4 flex h-10 w-10 place-content-center place-items-center rounded-full bg-immich-error p-5 text-xs text-gray-200"
           >
             {$stats.errors.toLocaleString($locale)}
@@ -161,7 +163,7 @@
         <button
           type="button"
           in:scale={{ duration: 250, easing: quartInOut }}
-          on:click={() => (showDetail = true)}
+          onclick={() => (showDetail = true)}
           class="flex h-16 w-16 place-content-center place-items-center rounded-full bg-gray-200 p-5 text-sm text-immich-primary shadow-lg dark:bg-gray-600 dark:text-immich-gray"
         >
           <div class="animate-pulse">
diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte
index 74750953b5..938f569508 100644
--- a/web/src/lib/components/shared-components/user-avatar.svelte
+++ b/web/src/lib/components/shared-components/user-avatar.svelte
@@ -1,4 +1,4 @@
-<script lang="ts" context="module">
+<script lang="ts" module>
   export type Size = 'full' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl';
 </script>
 
@@ -16,25 +16,34 @@
     profileChangedAt: string;
   }
 
-  export let user: User;
-  export let color: UserAvatarColor | undefined = undefined;
-  export let size: Size = 'full';
-  export let rounded = true;
-  export let interactive = false;
-  export let showTitle = true;
-  export let showProfileImage = true;
-  export let label: string | undefined = undefined;
+  interface Props {
+    user: User;
+    color?: UserAvatarColor | undefined;
+    size?: Size;
+    rounded?: boolean;
+    interactive?: boolean;
+    showTitle?: boolean;
+    showProfileImage?: boolean;
+    label?: string | undefined;
+  }
 
-  let img: HTMLImageElement;
-  let showFallback = true;
+  let {
+    user,
+    color = undefined,
+    size = 'full',
+    rounded = true,
+    interactive = false,
+    showTitle = true,
+    showProfileImage = true,
+    label = undefined,
+  }: Props = $props();
 
-  // sveeeeeeelteeeeee fiveeeeee
-  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-  $: img, user, void tryLoadImage();
+  let img: HTMLImageElement | undefined = $state();
+  let showFallback = $state(true);
 
   const tryLoadImage = async () => {
     try {
-      await img.decode();
+      await img?.decode();
       showFallback = false;
     } catch {
       showFallback = true;
@@ -64,12 +73,20 @@
     xxxl: 'w-28 h-28',
   };
 
-  $: colorClass = colorClasses[color || user.avatarColor];
-  $: sizeClass = sizeClasses[size];
-  $: title = label ?? `${user.name} (${user.email})`;
-  $: interactiveClass = interactive
-    ? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
-    : '';
+  $effect(() => {
+    if (img && user) {
+      tryLoadImage().catch(console.error);
+    }
+  });
+
+  let colorClass = $derived(colorClasses[color || user.avatarColor]);
+  let sizeClass = $derived(sizeClasses[size]);
+  let title = $derived(label ?? `${user.name} (${user.email})`);
+  let interactiveClass = $derived(
+    interactive
+      ? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors'
+      : '',
+  );
 </script>
 
 <figure
diff --git a/web/src/lib/components/shared-components/version-announcement-box.svelte b/web/src/lib/components/shared-components/version-announcement-box.svelte
index fb5466e7ae..62e9baf779 100644
--- a/web/src/lib/components/shared-components/version-announcement-box.svelte
+++ b/web/src/lib/components/shared-components/version-announcement-box.svelte
@@ -6,18 +6,12 @@
   import { t } from 'svelte-i18n';
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
 
-  let showModal = false;
+  let showModal = $state(false);
 
   const { release } = websocketStore;
 
   const semverToName = ({ major, minor, patch }: ServerVersionResponseDto) => `v${major}.${minor}.${patch}`;
 
-  $: releaseVersion = $release && semverToName($release.releaseVersion);
-  $: serverVersion = $release && semverToName($release.serverVersion);
-  $: if ($release?.isAvailable) {
-    handleRelease();
-  }
-
   const onAcknowledge = () => {
     localStorage.setItem('appVersion', releaseVersion);
     showModal = false;
@@ -34,21 +28,30 @@
       console.error('Error [VersionAnnouncementBox]:', error);
     }
   };
+  let releaseVersion = $derived($release && semverToName($release.releaseVersion));
+  let serverVersion = $derived($release && semverToName($release.serverVersion));
+  $effect(() => {
+    if ($release?.isAvailable) {
+      handleRelease();
+    }
+  });
 </script>
 
 {#if showModal}
   <FullScreenModal title="🎉 {$t('new_version_available')}" onClose={() => (showModal = false)}>
     <div>
-      <FormatMessage key="version_announcement_message" let:tag let:message>
-        {#if tag === 'link'}
-          <span class="font-medium underline">
-            <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
-              {message}
-            </a>
-          </span>
-        {:else if tag === 'code'}
-          <code>{message}</code>
-        {/if}
+      <FormatMessage key="version_announcement_message">
+        {#snippet children({ tag, message })}
+          {#if tag === 'link'}
+            <span class="font-medium underline">
+              <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
+                {message}
+              </a>
+            </span>
+          {:else if tag === 'code'}
+            <code>{message}</code>
+          {/if}
+        {/snippet}
       </FormatMessage>
     </div>
 
@@ -60,8 +63,8 @@
       <code>{$t('latest_version')}: {releaseVersion}</code>
     </div>
 
-    <svelte:fragment slot="sticky-bottom">
-      <Button fullwidth on:click={onAcknowledge}>{$t('acknowledge')}</Button>
-    </svelte:fragment>
+    {#snippet stickyBottom()}
+      <Button fullwidth onclick={onAcknowledge}>{$t('acknowledge')}</Button>
+    {/snippet}
   </FullScreenModal>
 {/if}
diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte
index f955d8479a..9ec9fc76ce 100644
--- a/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte
+++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-copy.svelte
@@ -7,8 +7,12 @@
   import { mdiContentCopy } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let link: SharedLinkResponseDto;
-  export let menuItem = false;
+  interface Props {
+    link: SharedLinkResponseDto;
+    menuItem?: boolean;
+  }
+
+  let { link, menuItem = false }: Props = $props();
 
   const handleCopy = async () => {
     await copyToClipboard(makeSharedLinkUrl($serverConfig.externalDomain, link.key));
@@ -18,5 +22,5 @@
 {#if menuItem}
   <MenuOption text={$t('copy_link')} icon={mdiContentCopy} onClick={handleCopy} />
 {:else}
-  <CircleIconButton title={$t('copy_link')} icon={mdiContentCopy} on:click={handleCopy} />
+  <CircleIconButton title={$t('copy_link')} icon={mdiContentCopy} onclick={handleCopy} />
 {/if}
diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte
index d458d5d77a..8c81e736bb 100644
--- a/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte
+++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-delete.svelte
@@ -4,12 +4,16 @@
   import { mdiDelete } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let menuItem = false;
-  export let onDelete: () => void;
+  interface Props {
+    menuItem?: boolean;
+    onDelete: () => void;
+  }
+
+  let { menuItem = false, onDelete }: Props = $props();
 </script>
 
 {#if menuItem}
   <MenuOption text={$t('delete_link')} icon={mdiDelete} onClick={onDelete} />
 {:else}
-  <CircleIconButton title={$t('delete_link')} icon={mdiDelete} on:click={onDelete} />
+  <CircleIconButton title={$t('delete_link')} icon={mdiDelete} onclick={onDelete} />
 {/if}
diff --git a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte
index 49c6105632..0a0c5a4736 100644
--- a/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte
+++ b/web/src/lib/components/sharedlinks-page/actions/shared-link-edit.svelte
@@ -4,12 +4,16 @@
   import { mdiCircleEditOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let menuItem = false;
-  export let onEdit: () => void;
+  interface Props {
+    menuItem?: boolean;
+    onEdit: () => void;
+  }
+
+  let { menuItem = false, onEdit }: Props = $props();
 </script>
 
 {#if menuItem}
   <MenuOption text={$t('edit_link')} icon={mdiCircleEditOutline} onClick={onEdit} />
 {:else}
-  <CircleIconButton title={$t('edit_link')} icon={mdiCircleEditOutline} on:click={onEdit} />
+  <CircleIconButton title={$t('edit_link')} icon={mdiCircleEditOutline} onclick={onEdit} />
 {/if}
diff --git a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte
index bf5031e39d..76822cc786 100644
--- a/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte
+++ b/web/src/lib/components/sharedlinks-page/covers/asset-cover.svelte
@@ -1,13 +1,16 @@
 <script lang="ts">
   import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
 
-  export let alt;
-  export let preload = false;
-  export let src: string;
-  let className = '';
-  export { className as class };
+  interface Props {
+    alt?: string;
+    preload?: boolean;
+    src: string;
+    class?: string;
+  }
 
-  let isBroken = false;
+  let { alt, preload = false, src, class: className = '' }: Props = $props();
+
+  let isBroken = $state(false);
 </script>
 
 {#if isBroken}
@@ -15,7 +18,7 @@
 {:else}
   <img
     {alt}
-    on:error={() => (isBroken = true)}
+    onerror={() => (isBroken = true)}
     class="size-full rounded-xl object-cover aspect-square {className}"
     data-testid="album-image"
     draggable="false"
diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte
index 087204d6a5..1e09c6bcfa 100644
--- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte
+++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte
@@ -1,8 +1,11 @@
 <script lang="ts">
-  export let alt = '';
-  export let preload = false;
-  let className = '';
-  export { className as class };
+  interface Props {
+    alt?: string;
+    preload?: boolean;
+    class?: string;
+  }
+
+  let { alt = '', preload = false, class: className = '' }: Props = $props();
 </script>
 
 <enhanced:img
diff --git a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte
index 09f32d7dac..6f15cca45f 100644
--- a/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte
+++ b/web/src/lib/components/sharedlinks-page/covers/share-cover.svelte
@@ -6,10 +6,13 @@
   import { getAssetThumbnailUrl } from '$lib/utils';
   import { t } from 'svelte-i18n';
 
-  export let link: SharedLinkResponseDto;
-  export let preload = false;
-  let className = '';
-  export { className as class };
+  interface Props {
+    link: SharedLinkResponseDto;
+    preload?: boolean;
+    class?: string;
+  }
+
+  let { link, preload = false, class: className = '' }: Props = $props();
 </script>
 
 <div class="relative shrink-0 size-24">
diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
index 13beec0ec0..70f6247533 100644
--- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
+++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte
@@ -12,13 +12,17 @@
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
   import { mdiDotsVertical } from '@mdi/js';
 
-  export let link: SharedLinkResponseDto;
-  export let onDelete: () => void;
-  export let onEdit: () => void;
+  interface Props {
+    link: SharedLinkResponseDto;
+    onDelete: () => void;
+    onEdit: () => void;
+  }
+
+  let { link, onDelete, onEdit }: Props = $props();
 
   let now = DateTime.now();
-  $: expiresAt = link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined;
-  $: isExpired = expiresAt ? now > expiresAt : false;
+  let expiresAt = $derived(link.expiresAt ? DateTime.fromISO(link.expiresAt) : undefined);
+  let isExpired = $derived(expiresAt ? now > expiresAt : false);
 
   const getCountDownExpirationDate = (expiresAtDate: DateTime, now: DateTime) => {
     const relativeUnits: ToRelativeUnit[] = ['days', 'hours', 'minutes', 'seconds'];
diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte
index 6f0397be98..723960d914 100644
--- a/web/src/lib/components/slideshow-settings.svelte
+++ b/web/src/lib/components/slideshow-settings.svelte
@@ -1,8 +1,6 @@
 <script lang="ts">
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import {
     mdiArrowDownThin,
@@ -17,10 +15,15 @@
   import type { RenderedOption } from './elements/dropdown.svelte';
   import SettingDropdown from './shared-components/settings/setting-dropdown.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
   const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore;
 
-  export let onClose = () => {};
+  interface Props {
+    onClose?: () => void;
+  }
+
+  let { onClose = () => {} }: Props = $props();
 
   const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
     [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@@ -46,7 +49,7 @@
   };
 </script>
 
-<FullScreenModal title={$t('slideshow_settings')} {onClose}>
+<FullScreenModal title={$t('slideshow_settings')} onClose={() => onClose()}>
   <div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
     <SettingDropdown
       title={$t('direction')}
@@ -69,12 +72,13 @@
     <SettingInputField
       inputType={SettingInputFieldType.NUMBER}
       label={$t('duration')}
-      desc={$t('admin.slideshow_duration_description')}
+      description={$t('admin.slideshow_duration_description')}
       min={1}
       bind:value={$slideshowDelay}
     />
   </div>
-  <svelte:fragment slot="sticky-bottom">
-    <Button fullwidth color="primary" on:click={onClose}>{$t('done')}</Button>
-  </svelte:fragment>
+
+  {#snippet stickyBottom()}
+    <Button fullwidth color="primary" onclick={(_) => onClose()}>{$t('done')}</Button>
+  {/snippet}
 </FullScreenModal>
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte
index 4bfd9a4e0e..63209ca289 100644
--- a/web/src/lib/components/user-settings-page/app-settings.svelte
+++ b/web/src/lib/components/user-settings-page/app-settings.svelte
@@ -19,26 +19,7 @@
   import { fade } from 'svelte/transition';
   import { invalidateAll } from '$app/navigation';
 
-  let time = new Date();
-
-  $: formattedDate = time.toLocaleString(editedLocale, {
-    year: 'numeric',
-    month: '2-digit',
-    day: '2-digit',
-  });
-  $: timePortion = time.toLocaleString(editedLocale, {
-    hour: '2-digit',
-    minute: '2-digit',
-    second: '2-digit',
-  });
-  $: selectedDate = `${formattedDate} ${timePortion}`;
-  $: editedLocale = findLocale($locale).code;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: selectedOption = {
-    value: findLocale(editedLocale).code || fallbackLocale.code,
-    label: findLocale(editedLocale).name || fallbackLocale.name,
-  };
-  $: closestLanguage = getClosestAvailableLocale([$lang], langCodes);
+  let time = $state(new Date());
 
   onMount(() => {
     const interval = setInterval(() => {
@@ -90,6 +71,27 @@
       $locale = newLocale;
     }
   };
+  let editedLocale = $derived(findLocale($locale).code);
+  let formattedDate = $derived(
+    time.toLocaleString(editedLocale, {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    }),
+  );
+  let timePortion = $derived(
+    time.toLocaleString(editedLocale, {
+      hour: '2-digit',
+      minute: '2-digit',
+      second: '2-digit',
+    }),
+  );
+  let selectedDate = $derived(`${formattedDate} ${timePortion}`);
+  let selectedOption = $derived({
+    value: findLocale(editedLocale).code || fallbackLocale.code,
+    label: findLocale(editedLocale).name || fallbackLocale.name,
+  });
+  let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
 </script>
 
 <section class="my-4">
diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte
index 39fd78e037..b31cbac34f 100644
--- a/web/src/lib/components/user-settings-page/change-password-settings.svelte
+++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte
@@ -8,14 +8,13 @@
 
   import Button from '$lib/components/elements/buttons/button.svelte';
   import type { HttpError } from '@sveltejs/kit';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  let password = '';
-  let newPassword = '';
-  let confirmPassword = '';
+  let password = $state('');
+  let newPassword = $state('');
+  let confirmPassword = $state('');
 
   const handleChangePassword = async () => {
     try {
@@ -37,11 +36,15 @@
       });
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <section class="my-4">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.PASSWORD}
@@ -72,7 +75,7 @@
             type="submit"
             size="sm"
             disabled={!(password && newPassword && newPassword === confirmPassword)}
-            on:click={() => handleChangePassword()}>{$t('save')}</Button
+            onclick={() => handleChangePassword()}>{$t('save')}</Button
           >
         </div>
       </div>
diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte
index d43977ea08..5248a6d119 100644
--- a/web/src/lib/components/user-settings-page/device-card.svelte
+++ b/web/src/lib/components/user-settings-page/device-card.svelte
@@ -17,8 +17,12 @@
   import { DateTime, type ToRelativeCalendarOptions } from 'luxon';
   import { t } from 'svelte-i18n';
 
-  export let device: SessionResponseDto;
-  export let onDelete: (() => void) | undefined = undefined;
+  interface Props {
+    device: SessionResponseDto;
+    onDelete?: (() => void) | undefined;
+  }
+
+  let { device, onDelete = undefined }: Props = $props();
 
   const options: ToRelativeCalendarOptions = {
     unit: 'days',
@@ -71,7 +75,7 @@
           icon={mdiTrashCanOutline}
           title={$t('log_out')}
           size="16"
-          on:click={onDelete}
+          onclick={onDelete}
         />
       </div>
     {/if}
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte
index 26e03c35d8..bb202b3606 100644
--- a/web/src/lib/components/user-settings-page/device-list.svelte
+++ b/web/src/lib/components/user-settings-page/device-list.svelte
@@ -7,12 +7,16 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let devices: SessionResponseDto[];
+  interface Props {
+    devices: SessionResponseDto[];
+  }
+
+  let { devices = $bindable() }: Props = $props();
 
   const refresh = () => getSessions().then((_devices) => (devices = _devices));
 
-  $: currentDevice = devices.find((device) => device.current);
-  $: otherDevices = devices.filter((device) => !device.current);
+  let currentDevice = $derived(devices.find((device) => device.current));
+  let otherDevices = $derived(devices.filter((device) => !device.current));
 
   const handleDelete = async (device: SessionResponseDto) => {
     const isConfirmed = await dialogController.show({
@@ -78,7 +82,7 @@
       {$t('log_out_all_devices').toUpperCase()}
     </h3>
     <div class="flex justify-end">
-      <Button color="red" size="sm" on:click={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
+      <Button color="red" size="sm" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
     </div>
   {/if}
 </section>
diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte
index f5b94ebee8..97da347fb7 100644
--- a/web/src/lib/components/user-settings-page/download-settings.svelte
+++ b/web/src/lib/components/user-settings-page/download-settings.svelte
@@ -10,14 +10,13 @@
   import { preferences } from '$lib/stores/user.store';
   import Button from '../elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  let archiveSize = convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB);
-  let includeEmbeddedVideos = $preferences?.download?.includeEmbeddedVideos || false;
+  let archiveSize = $state(convertFromBytes($preferences?.download?.archiveSize || 4, ByteUnit.GiB));
+  let includeEmbeddedVideos = $state($preferences?.download?.includeEmbeddedVideos || false);
 
   const handleSave = async () => {
     try {
@@ -36,16 +35,20 @@
       handleError(error, $t('errors.unable_to_update_settings'));
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <section class="my-4">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.NUMBER}
           label={$t('archive_size')}
-          desc={$t('archive_size_description')}
+          description={$t('archive_size_description')}
           bind:value={archiveSize}
         />
         <SettingSwitch
@@ -54,7 +57,7 @@
           bind:checked={includeEmbeddedVideos}
         ></SettingSwitch>
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
+          <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte
index d04bbc3e7d..9a60f83647 100644
--- a/web/src/lib/components/user-settings-page/feature-settings.svelte
+++ b/web/src/lib/components/user-settings-page/feature-settings.svelte
@@ -14,22 +14,22 @@
   import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
 
   // Folders
-  let foldersEnabled = $preferences?.folders?.enabled ?? false;
-  let foldersSidebar = $preferences?.folders?.sidebarWeb ?? false;
+  let foldersEnabled = $state($preferences?.folders?.enabled ?? false);
+  let foldersSidebar = $state($preferences?.folders?.sidebarWeb ?? false);
 
   // Memories
-  let memoriesEnabled = $preferences?.memories?.enabled ?? true;
+  let memoriesEnabled = $state($preferences?.memories?.enabled ?? true);
 
   // People
-  let peopleEnabled = $preferences?.people?.enabled ?? false;
-  let peopleSidebar = $preferences?.people?.sidebarWeb ?? false;
+  let peopleEnabled = $state($preferences?.people?.enabled ?? false);
+  let peopleSidebar = $state($preferences?.people?.sidebarWeb ?? false);
 
   // Ratings
-  let ratingsEnabled = $preferences?.ratings?.enabled ?? false;
+  let ratingsEnabled = $state($preferences?.ratings?.enabled ?? false);
 
   // Tags
-  let tagsEnabled = $preferences?.tags?.enabled ?? false;
-  let tagsSidebar = $preferences?.tags?.sidebarWeb ?? false;
+  let tagsEnabled = $state($preferences?.tags?.enabled ?? false);
+  let tagsSidebar = $state($preferences?.tags?.sidebarWeb ?? false);
 
   const handleSave = async () => {
     try {
@@ -50,11 +50,15 @@
       handleError(error, $t('errors.unable_to_update_settings'));
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <section class="my-4">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col">
         <SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
           <div class="ml-4 mt-6">
@@ -116,7 +120,7 @@
         </SettingAccordion>
 
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
+          <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/notifications-settings.svelte b/web/src/lib/components/user-settings-page/notifications-settings.svelte
index 275f628f0a..bec0633964 100644
--- a/web/src/lib/components/user-settings-page/notifications-settings.svelte
+++ b/web/src/lib/components/user-settings-page/notifications-settings.svelte
@@ -12,9 +12,9 @@
   import Button from '../elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
 
-  let emailNotificationsEnabled = $preferences?.emailNotifications?.enabled ?? true;
-  let albumInviteNotificationEnabled = $preferences?.emailNotifications?.albumInvite ?? true;
-  let albumUpdateNotificationEnabled = $preferences?.emailNotifications?.albumUpdate ?? true;
+  let emailNotificationsEnabled = $state($preferences?.emailNotifications?.enabled ?? true);
+  let albumInviteNotificationEnabled = $state($preferences?.emailNotifications?.albumInvite ?? true);
+  let albumUpdateNotificationEnabled = $state($preferences?.emailNotifications?.albumUpdate ?? true);
 
   const handleSave = async () => {
     try {
@@ -37,11 +37,15 @@
       handleError(error, $t('errors.unable_to_update_settings'));
     }
   };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+  };
 </script>
 
 <section class="my-4">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" {onsubmit}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <div class="ml-4">
           <SettingSwitch
@@ -67,7 +71,7 @@
         </div>
 
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
+          <Button type="submit" size="sm" onclick={() => handleSave()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/oauth-settings.svelte b/web/src/lib/components/user-settings-page/oauth-settings.svelte
index 10e71e64eb..8dbe3539b8 100644
--- a/web/src/lib/components/user-settings-page/oauth-settings.svelte
+++ b/web/src/lib/components/user-settings-page/oauth-settings.svelte
@@ -11,9 +11,13 @@
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
   import { t } from 'svelte-i18n';
 
-  export let user: UserAdminResponseDto;
+  interface Props {
+    user: UserAdminResponseDto;
+  }
 
-  let loading = true;
+  let { user = $bindable() }: Props = $props();
+
+  let loading = $state(true);
 
   onMount(async () => {
     if (oauth.isCallback(window.location)) {
@@ -58,9 +62,9 @@
         </div>
       {:else if $featureFlags.oauth}
         {#if user.oauthId}
-          <Button size="sm" on:click={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
+          <Button size="sm" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
         {:else}
-          <Button size="sm" on:click={() => oauth.authorize(window.location)}>{$t('link_to_oauth')}</Button>
+          <Button size="sm" onclick={() => oauth.authorize(window.location)}>{$t('link_to_oauth')}</Button>
         {/if}
       {/if}
     </div>
diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
index 8ab747aa27..070246b612 100644
--- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
+++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
@@ -6,12 +6,16 @@
   import Button from '../elements/buttons/button.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
 
-  export let user: UserResponseDto;
-  export let onClose: () => void;
-  export let onAddUsers: (users: UserResponseDto[]) => void;
+  interface Props {
+    user: UserResponseDto;
+    onClose: () => void;
+    onAddUsers: (users: UserResponseDto[]) => void;
+  }
 
-  let availableUsers: UserResponseDto[] = [];
-  let selectedUsers: UserResponseDto[] = [];
+  let { user, onClose, onAddUsers }: Props = $props();
+
+  let availableUsers: UserResponseDto[] = $state([]);
+  let selectedUsers: UserResponseDto[] = $state([]);
 
   onMount(async () => {
     let users = await searchUsers();
@@ -38,7 +42,7 @@
       {#each availableUsers as user}
         <button
           type="button"
-          on:click={() => selectUser(user)}
+          onclick={() => selectUser(user)}
           class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
         >
           {#if selectedUsers.includes(user)}
@@ -68,7 +72,7 @@
 
     {#if selectedUsers.length > 0}
       <div class="pt-5">
-        <Button size="sm" fullwidth on:click={() => onAddUsers(selectedUsers)}>{$t('add')}</Button>
+        <Button size="sm" fullwidth onclick={() => onAddUsers(selectedUsers)}>{$t('add')}</Button>
       </div>
     {/if}
   </div>
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte
index 050e2c42f3..7d57510547 100644
--- a/web/src/lib/components/user-settings-page/partner-settings.svelte
+++ b/web/src/lib/components/user-settings-page/partner-settings.svelte
@@ -27,11 +27,15 @@
     inTimeline: boolean;
   }
 
-  export let user: UserResponseDto;
+  interface Props {
+    user: UserResponseDto;
+  }
 
-  let createPartnerFlag = false;
+  let { user }: Props = $props();
+
+  let createPartnerFlag = $state(false);
   // let removePartnerDto: PartnerResponseDto | null = null;
-  let partners: Array<PartnerSharing> = [];
+  let partners: Array<PartnerSharing> = $state([]);
 
   onMount(async () => {
     await refreshPartners();
@@ -139,7 +143,7 @@
 
           {#if partner.sharedByMe}
             <CircleIconButton
-              on:click={() => handleRemovePartner(partner.user)}
+              onclick={() => handleRemovePartner(partner.user)}
               icon={mdiClose}
               size={'16'}
               title={$t('stop_sharing_photos_with_user')}
@@ -186,7 +190,7 @@
   {/if}
 
   <div class="flex justify-end mt-5">
-    <Button size="sm" on:click={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button>
+    <Button size="sm" onclick={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button>
   </div>
 </section>
 
diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte
index a63bdb3ca9..c5c6bae9e5 100644
--- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte
+++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte
@@ -19,11 +19,15 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  export let keys: ApiKeyResponseDto[];
+  interface Props {
+    keys: ApiKeyResponseDto[];
+  }
 
-  let newKey: { name: string } | null = null;
-  let editKey: ApiKeyResponseDto | null = null;
-  let secret = '';
+  let { keys = $bindable() }: Props = $props();
+
+  let newKey: { name: string } | null = $state(null);
+  let editKey: ApiKeyResponseDto | null = $state(null);
+  let secret = $state('');
 
   const format: Intl.DateTimeFormatOptions = {
     month: 'short',
@@ -118,7 +122,7 @@
 <section class="my-4">
   <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
     <div class="mb-2 flex justify-end">
-      <Button size="sm" on:click={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button>
+      <Button size="sm" onclick={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button>
     </div>
 
     {#if keys.length > 0}
@@ -152,14 +156,14 @@
                     icon={mdiPencilOutline}
                     title={$t('edit_key')}
                     size="16"
-                    on:click={() => (editKey = key)}
+                    onclick={() => (editKey = key)}
                   />
                   <CircleIconButton
                     color="primary"
                     icon={mdiTrashCanOutline}
                     title={$t('delete_key')}
                     size="16"
-                    on:click={() => handleDelete(key)}
+                    onclick={() => handleDelete(key)}
                   />
                 </td>
               </tr>
diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
index 1f3b59bfdd..a49eabcf13 100644
--- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte
+++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
@@ -1,11 +1,12 @@
 <script lang="ts">
+  import { createBubbler, preventDefault } from 'svelte/legacy';
+
+  const bubble = createBubbler();
   import {
     notificationController,
     NotificationType,
   } from '$lib/components/shared-components/notification/notification';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import { user } from '$lib/stores/user.store';
   import { updateMyUser } from '@immich/sdk';
   import { cloneDeep } from 'lodash-es';
@@ -13,8 +14,9 @@
   import { handleError } from '../../utils/handle-error';
   import Button from '../elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
+  import { SettingInputFieldType } from '$lib/constants';
 
-  let editedUser = cloneDeep($user);
+  let editedUser = $state(cloneDeep($user));
 
   const handleSaveProfile = async () => {
     try {
@@ -40,7 +42,7 @@
 
 <section class="my-4">
   <div in:fade={{ duration: 500 }}>
-    <form autocomplete="off" on:submit|preventDefault>
+    <form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
       <div class="ml-4 mt-4 flex flex-col gap-4">
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
@@ -67,7 +69,7 @@
         />
 
         <div class="flex justify-end">
-          <Button type="submit" size="sm" on:click={() => handleSaveProfile()}>{$t('save')}</Button>
+          <Button type="submit" size="sm" onclick={() => handleSaveProfile()}>{$t('save')}</Button>
         </div>
       </div>
     </form>
diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte
index 71f76d07c0..2408651234 100644
--- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte
+++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte
@@ -24,8 +24,8 @@
   import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
   const { isPurchased } = purchaseStore;
 
-  let isServerProduct = false;
-  let serverPurchaseInfo: LicenseResponseDto | null = null;
+  let isServerProduct = $state(false);
+  let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
 
   const checkPurchaseInfo = async () => {
     const serverInfo = await getAboutInfo();
@@ -145,7 +145,7 @@
 
         {#if $user.isAdmin}
           <div class="text-right mt-4">
-            <Button size="sm" color="red" on:click={removeServerProductKey}>{$t('purchase_button_remove_key')}</Button>
+            <Button size="sm" color="red" onclick={removeServerProductKey}>{$t('purchase_button_remove_key')}</Button>
           </div>
         {/if}
       {:else}
@@ -169,8 +169,7 @@
         </div>
 
         <div class="text-right mt-4">
-          <Button size="sm" color="red" on:click={removeIndividualProductKey}>{$t('purchase_button_remove_key')}</Button
-          >
+          <Button size="sm" color="red" onclick={removeIndividualProductKey}>{$t('purchase_button_remove_key')}</Button>
         </div>
       {/if}
     {:else}
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte
index f355c3105c..6f8a0ce4dc 100644
--- a/web/src/lib/components/user-settings-page/user-settings-list.svelte
+++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte
@@ -33,8 +33,12 @@
     mdiTwoFactorAuthentication,
   } from '@mdi/js';
 
-  export let keys: ApiKeyResponseDto[] = [];
-  export let sessions: SessionResponseDto[] = [];
+  interface Props {
+    keys?: ApiKeyResponseDto[];
+    sessions?: SessionResponseDto[];
+  }
+
+  let { keys = $bindable([]), sessions = $bindable([]) }: Props = $props();
 
   let oauthOpen =
     oauth.isCallback(window.location) ||
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
index 2103250b54..19190745d1 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
@@ -7,13 +7,17 @@
   import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let asset: AssetResponseDto;
-  export let isSelected: boolean;
-  export let onSelectAsset: (asset: AssetResponseDto) => void;
-  export let onViewAsset: (asset: AssetResponseDto) => void;
+  interface Props {
+    asset: AssetResponseDto;
+    isSelected: boolean;
+    onSelectAsset: (asset: AssetResponseDto) => void;
+    onViewAsset: (asset: AssetResponseDto) => void;
+  }
 
-  $: isFromExternalLibrary = !!asset.libraryId;
-  $: assetData = JSON.stringify(asset, null, 2);
+  let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
+
+  let isFromExternalLibrary = $derived(!!asset.libraryId);
+  let assetData = $derived(JSON.stringify(asset, null, 2));
 </script>
 
 <div
@@ -24,7 +28,7 @@
   <div class="relative w-full">
     <button
       type="button"
-      on:click={() => onSelectAsset(asset)}
+      onclick={() => onSelectAsset(asset)}
       class="block relative w-full"
       aria-pressed={isSelected}
       aria-label={$t('keep')}
@@ -74,7 +78,7 @@
 
     <button
       type="button"
-      on:click={() => onViewAsset(asset)}
+      onclick={() => onViewAsset(asset)}
       class="absolute rounded-full top-1 left-1 text-gray-200 p-2 hover:text-white bg-black/35 hover:bg-black/50"
       title={$t('view')}
     >
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
index fd5b68d8c3..6b9bc93c1e 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
@@ -11,21 +11,26 @@
   import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
   import { onDestroy, onMount } from 'svelte';
   import { t } from 'svelte-i18n';
+  import { SvelteSet } from 'svelte/reactivity';
 
-  export let assets: AssetResponseDto[];
-  export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
-  export let onStack: (assets: AssetResponseDto[]) => void;
+  interface Props {
+    assets: AssetResponseDto[];
+    onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
+    onStack: (assets: AssetResponseDto[]) => void;
+  }
+
+  let { assets, onResolve, onStack }: Props = $props();
   const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
   const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
 
-  let selectedAssetIds = new Set<string>();
-  $: trashCount = assets.length - selectedAssetIds.size;
+  let selectedAssetIds = $state(new SvelteSet<string>());
+  let trashCount = $derived(assets.length - selectedAssetIds.size);
 
   onMount(() => {
     const suggestedAsset = suggestDuplicateByFileSize(assets);
 
     if (!suggestedAsset) {
-      selectedAssetIds = new Set(assets[0].id);
+      selectedAssetIds = new SvelteSet(assets[0].id);
       return;
     }
 
@@ -53,7 +58,7 @@
   };
 
   const onSelectAll = () => {
-    selectedAssetIds = new Set(assets.map((asset) => asset.id));
+    selectedAssetIds = new SvelteSet(assets.map((asset) => asset.id));
   };
 
   const handleResolve = () => {
@@ -100,12 +105,12 @@
       <button
         type="button"
         class="px-4 py-3 flex place-items-center gap-2 rounded-tl-full rounded-bl-full dark:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/90 bg-immich-primary/25 hover:bg-immich-primary/50"
-        on:click={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button
+        onclick={onSelectAll}><Icon path={mdiCheck} size="20" />{$t('select_keep_all')}</button
       >
       <button
         type="button"
         class="px-4 py-3 flex place-items-center gap-2 rounded-tr-full rounded-br-full dark:bg-immich-dark-primary/50 hover:dark:bg-immich-dark-primary/70 bg-immich-primary hover:bg-immich-primary/80 text-white"
-        on:click={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button
+        onclick={onSelectNone}><Icon path={mdiTrashCanOutline} size="20" />{$t('select_trash_all')}</button
       >
     </div>
 
@@ -116,7 +121,7 @@
           size="sm"
           color="primary"
           class="flex place-items-center rounded-tl-full rounded-bl-full gap-2"
-          on:click={handleResolve}
+          onclick={handleResolve}
         >
           <Icon path={mdiCheck} size="20" />{$t('keep_all')}
         </Button>
@@ -125,7 +130,7 @@
           size="sm"
           color="red"
           class="flex place-items-center rounded-tl-full rounded-bl-full gap-2 py-3"
-          on:click={handleResolve}
+          onclick={handleResolve}
         >
           <Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
             ? $t('trash_all')
@@ -136,7 +141,7 @@
         size="sm"
         color="primary"
         class="flex place-items-center rounded-tr-full rounded-br-full  gap-2"
-        on:click={handleStack}
+        onclick={handleStack}
         disabled={selectedAssetIds.size !== 1}
       >
         <Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index aa1d976b6f..c0031ac436 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -270,6 +270,7 @@ export const langs = [
   { name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
   { name: 'Persian', code: 'fa', loader: () => import('$i18n/fa.json') },
   { name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
+  { name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
   { name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
   { name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json') },
   { name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
@@ -291,6 +292,7 @@ export const langs = [
   { name: 'Malay', code: 'ms', loader: () => import('$i18n/ms.json') },
   { name: 'Norwegian Bokmål', code: 'nb-NO', weblateCode: 'nb_NO', loader: () => import('$i18n/nb_NO.json') },
   { name: 'Dutch', code: 'nl', loader: () => import('$i18n/nl.json') },
+  { name: 'Norwegian Nynorsk', code: 'nn', loader: () => import('$i18n/nn.json') },
   { name: 'Polish', code: 'pl', loader: () => import('$i18n/pl.json') },
   { name: 'Portuguese', code: 'pt', loader: () => import('$i18n/pt.json') },
   { name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
@@ -331,3 +333,30 @@ export enum ImmichProduct {
   Client = 'immich-client',
   Server = 'immich-server',
 }
+
+export enum SettingInputFieldType {
+  EMAIL = 'email',
+  TEXT = 'text',
+  NUMBER = 'number',
+  PASSWORD = 'password',
+  COLOR = 'color',
+}
+
+export enum AlbumPageViewMode {
+  LINK_SHARING = 'link-sharing',
+  SELECT_USERS = 'select-users',
+  SELECT_THUMBNAIL = 'select-thumbnail',
+  SELECT_ASSETS = 'select-assets',
+  VIEW_USERS = 'view-users',
+  VIEW = 'view',
+  OPTIONS = 'options',
+}
+
+export enum PersonPageViewMode {
+  VIEW_ASSETS = 'view-assets',
+  SELECT_PERSON = 'select-person',
+  MERGE_PEOPLE = 'merge-people',
+  SUGGEST_MERGE = 'suggest-merge',
+  BIRTH_DATE = 'birth-date',
+  UNASSIGN_ASSETS = 'unassign-faces',
+}
diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte
index bf24d0e7e4..feda36fa01 100644
--- a/web/src/routes/(user)/+layout.svelte
+++ b/web/src/routes/(user)/+layout.svelte
@@ -1,13 +1,21 @@
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
   import { page } from '$app/stores';
 
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import type { Snippet } from 'svelte';
+  interface Props {
+    children?: Snippet;
+  }
+
+  let { children }: Props = $props();
   let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
 
   // $page.data.asset is loaded by route specific +page.ts loaders if that
   // route contains the assetId path.
-  $: {
+  run(() => {
     if ($page.data.asset) {
       setAsset($page.data.asset);
     } else {
@@ -15,11 +23,11 @@
     }
     const asset = $page.url.searchParams.get('at');
     $gridScrollTarget = { at: asset };
-  }
+  });
 </script>
 
 <div class:display-none={$showAssetViewer}>
-  <slot />
+  {@render children?.()}
 </div>
 <UploadCover />
 
diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte
index 35402ce331..29079a48b8 100644
--- a/web/src/routes/(user)/albums/+page.svelte
+++ b/web/src/routes/(user)/albums/+page.svelte
@@ -10,16 +10,22 @@
   import SearchBar from '$lib/components/elements/search-bar.svelte';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let searchQuery = '';
-  let albumGroups: string[] = [];
+  let { data }: Props = $props();
+
+  let searchQuery = $state('');
+  let albumGroups: string[] = $state([]);
 </script>
 
 <UserPageLayout title={data.meta.title}>
-  <div class="flex place-items-center gap-2" slot="buttons">
-    <AlbumsControls {albumGroups} bind:searchQuery />
-  </div>
+  {#snippet buttons()}
+    <div class="flex place-items-center gap-2">
+      <AlbumsControls {albumGroups} bind:searchQuery />
+    </div>
+  {/snippet}
 
   <div class="xl:hidden">
     <div class="w-fit h-14 dark:text-immich-dark-fg py-2">
@@ -43,6 +49,8 @@
     {searchQuery}
     bind:albumGroupIds={albumGroups}
   >
-    <EmptyPlaceholder slot="empty" text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
+    {#snippet empty()}
+      <EmptyPlaceholder text={$t('no_albums_message')} onClick={() => createAlbumAndRedirect()} />
+    {/snippet}
   </Albums>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 9ccb2b7182..7ee9c60a54 100644
--- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -32,7 +32,7 @@
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
-  import { AppRoute } from '$lib/constants';
+  import { AppRoute, AlbumPageViewMode } from '$lib/constants';
   import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -87,69 +87,31 @@
   import { confirmAlbumDelete } from '$lib/utils/album-utils';
   import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data = $bindable() }: Props = $props();
 
   let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
   let { slideshowState, slideshowNavigation } = slideshowStore;
 
-  let oldAt: AssetGridRouteSearchParams | null | undefined;
+  let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
 
-  $: album = data.album;
-  $: albumId = album.id;
-  $: albumKey = `${albumId}_${albumOrder}`;
+  let backUrl: string = $state(AppRoute.ALBUMS);
+  let viewMode = $state(AlbumPageViewMode.VIEW);
+  let isCreatingSharedAlbum = $state(false);
+  let isShowActivity = $state(false);
+  let isLiked: ActivityResponseDto | null = $state(null);
+  let reactions: ActivityResponseDto[] = $state([]);
+  let albumOrder: AssetOrder | undefined = $state(data.album.order);
 
-  $: {
-    if (!album.isActivityEnabled && $numberOfComments === 0) {
-      isShowActivity = false;
-    }
-  }
-
-  enum ViewMode {
-    LINK_SHARING = 'link-sharing',
-    SELECT_USERS = 'select-users',
-    SELECT_THUMBNAIL = 'select-thumbnail',
-    SELECT_ASSETS = 'select-assets',
-    VIEW_USERS = 'view-users',
-    VIEW = 'view',
-    OPTIONS = 'options',
-  }
-
-  let backUrl: string = AppRoute.ALBUMS;
-  let viewMode = ViewMode.VIEW;
-  let isCreatingSharedAlbum = false;
-  let isShowActivity = false;
-  let isLiked: ActivityResponseDto | null = null;
-  let reactions: ActivityResponseDto[] = [];
-  let globalWidth: number;
-  let assetGridWidth: number;
-  let albumOrder: AssetOrder | undefined = data.album.order;
-
-  $: assetStore = new AssetStore({ albumId, order: albumOrder });
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
-  $: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId);
   const timelineInteractionStore = createAssetInteractionStore();
   const { selectedAssets: timelineSelected } = timelineInteractionStore;
 
-  $: isOwned = $user.id == album.ownerId;
-  $: isAllUserOwned = [...$selectedAssets].every((asset) => asset.ownerId === $user.id);
-  $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
-  $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
-  $: {
-    assetGridWidth = isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth;
-  }
-  $: showActivityStatus =
-    album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: isEditor =
-    album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
-    album.ownerId === $user.id;
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: albumHasViewers = album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer);
-
   afterNavigate(({ from }) => {
     let url: string | undefined = from?.url?.pathname;
 
@@ -166,17 +128,22 @@
 
     if (backUrl === AppRoute.SHARING && album.albumUsers.length === 0 && !album.hasSharedLink) {
       isCreatingSharedAlbum = true;
+    } else if (backUrl === AppRoute.SHARED_LINKS) {
+      backUrl = history.state?.backUrl || AppRoute.ALBUMS;
     }
   });
 
   const handleToggleEnableActivity = async () => {
     try {
-      await updateAlbumInfo({
+      const updateAlbum = await updateAlbumInfo({
         id: album.id,
         updateAlbumDto: {
           isActivityEnabled: !album.isActivityEnabled,
         },
       });
+
+      album = { ...album, isActivityEnabled: updateAlbum.isActivityEnabled };
+
       await refreshAlbum();
       notificationController.show({
         type: NotificationType.Info,
@@ -236,11 +203,6 @@
     isShowActivity = !isShowActivity;
   };
 
-  $: if (album.albumUsers.length > 0) {
-    handlePromiseError(getFavorite());
-    handlePromiseError(getNumberOfComments());
-  }
-
   const handleStartSlideshow = async () => {
     const asset =
       $slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
@@ -251,21 +213,21 @@
   };
 
   const handleEscape = async () => {
-    if (viewMode === ViewMode.SELECT_USERS) {
-      viewMode = ViewMode.VIEW;
+    if (viewMode === AlbumPageViewMode.SELECT_USERS) {
+      viewMode = AlbumPageViewMode.VIEW;
       return;
     }
 
-    if (viewMode === ViewMode.SELECT_ASSETS) {
+    if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
       await handleCloseSelectAssets();
       return;
     }
-    if (viewMode === ViewMode.LINK_SHARING) {
-      viewMode = ViewMode.VIEW;
+    if (viewMode === AlbumPageViewMode.LINK_SHARING) {
+      viewMode = AlbumPageViewMode.VIEW;
       return;
     }
-    if (viewMode === ViewMode.OPTIONS) {
-      viewMode = ViewMode.VIEW;
+    if (viewMode === AlbumPageViewMode.OPTIONS) {
+      viewMode = AlbumPageViewMode.VIEW;
       return;
     }
     if ($showAssetViewer) {
@@ -280,7 +242,7 @@
   };
 
   const refreshAlbum = async () => {
-    data.album = await getAlbumInfo({ id: album.id, withoutAssets: true });
+    album = await getAlbumInfo({ id: album.id, withoutAssets: true });
   };
 
   const handleAddAssets = async () => {
@@ -308,7 +270,7 @@
   };
 
   const setModeToView = async () => {
-    viewMode = ViewMode.VIEW;
+    viewMode = AlbumPageViewMode.VIEW;
     assetStore.destroy();
     assetStore = new AssetStore({ albumId, order: albumOrder });
     timelineStore.destroy();
@@ -341,13 +303,13 @@
       });
       await refreshAlbum();
 
-      viewMode = ViewMode.VIEW;
+      viewMode = AlbumPageViewMode.VIEW;
     } catch (error) {
       handleError(error, $t('errors.error_adding_users_to_album'));
     }
   };
 
-  const handleRemoveUser = async (userId: string, nextViewMode: ViewMode) => {
+  const handleRemoveUser = async (userId: string, nextViewMode: AlbumPageViewMode) => {
     if (userId == 'me' || userId === $user.id) {
       await goto(backUrl);
       return;
@@ -357,7 +319,7 @@
       await refreshAlbum();
 
       // Dynamically set the view mode based on the passed argument
-      viewMode = album.albumUsers.length > 0 ? nextViewMode : ViewMode.VIEW;
+      viewMode = album.albumUsers.length > 0 ? nextViewMode : AlbumPageViewMode.VIEW;
     } catch (error) {
       handleError(error, $t('errors.error_deleting_shared_user'));
     }
@@ -371,7 +333,7 @@
     const isConfirmed = await confirmAlbumDelete(album);
 
     if (!isConfirmed) {
-      viewMode = ViewMode.VIEW;
+      viewMode = AlbumPageViewMode.VIEW;
       return;
     }
 
@@ -381,7 +343,7 @@
     } catch (error) {
       handleError(error, $t('errors.unable_to_delete_album'));
     } finally {
-      viewMode = ViewMode.VIEW;
+      viewMode = AlbumPageViewMode.VIEW;
     }
   };
 
@@ -391,11 +353,11 @@
   };
 
   const handleUpdateThumbnail = async (assetId: string) => {
-    if (viewMode !== ViewMode.SELECT_THUMBNAIL) {
+    if (viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL) {
       return;
     }
 
-    viewMode = ViewMode.VIEW;
+    viewMode = AlbumPageViewMode.VIEW;
     assetInteractionStore.clearMultiselect();
 
     await updateThumbnail(assetId);
@@ -432,9 +394,43 @@
     assetStore.destroy();
     timelineStore.destroy();
   });
+
+  let album = $state(data.album);
+  let albumId = $derived(album.id);
+  let albumKey = $derived(`${albumId}_${albumOrder}`);
+
+  $effect(() => {
+    if (!album.isActivityEnabled && $numberOfComments === 0) {
+      isShowActivity = false;
+    }
+  });
+
+  let assetStore = $derived(new AssetStore({ albumId, order: albumOrder }));
+  let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId));
+
+  let isOwned = $derived($user.id == album.ownerId);
+  let isAllUserOwned = $derived([...$selectedAssets].every((asset) => asset.ownerId === $user.id));
+  let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
+  let isAllArchived = $derived([...$selectedAssets].every((asset) => asset.isArchived));
+
+  let showActivityStatus = $derived(
+    album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0),
+  );
+  let isEditor = $derived(
+    album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
+      album.ownerId === $user.id,
+  );
+
+  let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
+  $effect(() => {
+    if (album.albumUsers.length > 0) {
+      handlePromiseError(getFavorite());
+      handlePromiseError(getNumberOfComments());
+    }
+  });
 </script>
 
-<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
+<div class="flex overflow-hidden">
   <div class="relative w-full shrink">
     {#if $isMultiSelectState}
       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
@@ -475,14 +471,14 @@
         </ButtonContextMenu>
       </AssetSelectControlBar>
     {:else}
-      {#if viewMode === ViewMode.VIEW}
+      {#if viewMode === AlbumPageViewMode.VIEW}
         <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
-          <svelte:fragment slot="trailing">
+          {#snippet trailing()}
             {#if isEditor}
               <CircleIconButton
                 title={$t('add_photos')}
-                on:click={async () => {
-                  viewMode = ViewMode.SELECT_ASSETS;
+                onclick={async () => {
+                  viewMode = AlbumPageViewMode.SELECT_ASSETS;
                   oldAt = { at: $gridScrollTarget?.at };
                   await navigate(
                     { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
@@ -496,23 +492,27 @@
             {#if isOwned}
               <CircleIconButton
                 title={$t('share')}
-                on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+                onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
                 icon={mdiShareVariantOutline}
               />
             {/if}
 
             {#if album.assetCount > 0}
-              <CircleIconButton title={$t('slideshow')} on:click={handleStartSlideshow} icon={mdiPresentationPlay} />
-              <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
+              <CircleIconButton title={$t('slideshow')} onclick={handleStartSlideshow} icon={mdiPresentationPlay} />
+              <CircleIconButton title={$t('download')} onclick={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 
               {#if isOwned}
                 <ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
                   <MenuOption
                     icon={mdiImageOutline}
                     text={$t('select_album_cover')}
-                    onClick={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
+                    onClick={() => (viewMode = AlbumPageViewMode.SELECT_THUMBNAIL)}
+                  />
+                  <MenuOption
+                    icon={mdiCogOutline}
+                    text={$t('options')}
+                    onClick={() => (viewMode = AlbumPageViewMode.OPTIONS)}
                   />
-                  <MenuOption icon={mdiCogOutline} text={$t('options')} onClick={() => (viewMode = ViewMode.OPTIONS)} />
                   <MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} onClick={() => handleRemoveAlbum()} />
                 </ButtonContextMenu>
               {/if}
@@ -523,18 +523,18 @@
                 size="sm"
                 rounded="lg"
                 disabled={album.assetCount === 0}
-                on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+                onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
               >
                 {$t('share')}
               </Button>
             {/if}
-          </svelte:fragment>
+          {/snippet}
         </ControlAppBar>
       {/if}
 
-      {#if viewMode === ViewMode.SELECT_ASSETS}
+      {#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
         <ControlAppBar onClose={handleCloseSelectAssets}>
-          <svelte:fragment slot="leading">
+          {#snippet leading()}
             <p class="text-lg dark:text-immich-dark-fg">
               {#if $timelineSelected.size === 0}
                 {$t('add_to_album')}
@@ -542,37 +542,36 @@
                 {$t('selected_count', { values: { count: $timelineSelected.size } })}
               {/if}
             </p>
-          </svelte:fragment>
+          {/snippet}
 
-          <svelte:fragment slot="trailing">
+          {#snippet trailing()}
             <button
               type="button"
-              on:click={handleSelectFromComputer}
+              onclick={handleSelectFromComputer}
               class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
             >
               {$t('select_from_computer')}
             </button>
-            <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}
+            <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} onclick={handleAddAssets}
               >{$t('done')}</Button
             >
-          </svelte:fragment>
+          {/snippet}
         </ControlAppBar>
       {/if}
 
-      {#if viewMode === ViewMode.SELECT_THUMBNAIL}
-        <ControlAppBar onClose={() => (viewMode = ViewMode.VIEW)}>
-          <svelte:fragment slot="leading">{$t('select_album_cover')}</svelte:fragment>
+      {#if viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
+        <ControlAppBar onClose={() => (viewMode = AlbumPageViewMode.VIEW)}>
+          {#snippet leading()}
+            {$t('select_album_cover')}
+          {/snippet}
         </ControlAppBar>
       {/if}
     {/if}
 
-    <main
-      class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
-      style={`width:${assetGridWidth}px`}
-    >
+    <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
       <!-- Use key because AssetGrid can't deal with changing stores -->
       {#key albumKey}
-        {#if viewMode === ViewMode.SELECT_ASSETS}
+        {#if viewMode === AlbumPageViewMode.SELECT_ASSETS}
           <AssetGrid
             enableRouting={false}
             assetStore={timelineStore}
@@ -586,13 +585,13 @@
             {assetStore}
             {assetInteractionStore}
             isShared={album.albumUsers.length > 0}
-            isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
-            singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
+            isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
+            singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL}
             showArchiveIcon
             onSelect={({ id }) => handleUpdateThumbnail(id)}
             onEscape={handleEscape}
           >
-            {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
+            {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
               <!-- ALBUM TITLE -->
               <section class="pt-8 md:pt-24">
                 <AlbumTitle
@@ -616,18 +615,18 @@
                         color="gray"
                         size="20"
                         icon={mdiLink}
-                        on:click={() => (viewMode = ViewMode.LINK_SHARING)}
+                        onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
                       />
                     {/if}
 
                     <!-- owner -->
-                    <button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
+                    <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
                       <UserAvatar user={album.owner} size="md" />
                     </button>
 
                     <!-- users with write access (collaborators) -->
                     {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
-                      <button type="button" on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
+                      <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}>
                         <UserAvatar {user} size="md" />
                       </button>
                     {/each}
@@ -639,7 +638,7 @@
                         color="gray"
                         size="20"
                         icon={mdiDotsVertical}
-                        on:click={() => (viewMode = ViewMode.VIEW_USERS)}
+                        onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}
                       />
                     {/if}
 
@@ -648,7 +647,7 @@
                         color="gray"
                         size="20"
                         icon={mdiPlus}
-                        on:click={() => (viewMode = ViewMode.SELECT_USERS)}
+                        onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
                         title={$t('add_more_users')}
                       />
                     {/if}
@@ -665,7 +664,7 @@
                   <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p>
                   <button
                     type="button"
-                    on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
+                    onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)}
                     class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
                   >
                     <span class="text-text-immich-primary dark:text-immich-dark-primary"
@@ -717,29 +716,29 @@
     </div>
   {/if}
 </div>
-{#if viewMode === ViewMode.SELECT_USERS}
+{#if viewMode === AlbumPageViewMode.SELECT_USERS}
   <UserSelectionModal
     {album}
     onSelect={handleAddUsers}
-    onShare={() => (viewMode = ViewMode.LINK_SHARING)}
-    onClose={() => (viewMode = ViewMode.VIEW)}
+    onShare={() => (viewMode = AlbumPageViewMode.LINK_SHARING)}
+    onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
   />
 {/if}
 
-{#if viewMode === ViewMode.LINK_SHARING}
-  <CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = ViewMode.VIEW)} />
+{#if viewMode === AlbumPageViewMode.LINK_SHARING}
+  <CreateSharedLinkModal albumId={album.id} onClose={() => (viewMode = AlbumPageViewMode.VIEW)} />
 {/if}
 
-{#if viewMode === ViewMode.VIEW_USERS}
+{#if viewMode === AlbumPageViewMode.VIEW_USERS}
   <ShareInfoModal
-    onClose={() => (viewMode = ViewMode.VIEW)}
+    onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
     {album}
-    onRemove={(userId) => handleRemoveUser(userId, ViewMode.VIEW_USERS)}
+    onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.VIEW_USERS)}
     onRefreshAlbum={refreshAlbum}
   />
 {/if}
 
-{#if viewMode === ViewMode.OPTIONS && $user}
+{#if viewMode === AlbumPageViewMode.OPTIONS && $user}
   <AlbumOptions
     {album}
     order={albumOrder}
@@ -748,11 +747,11 @@
       albumOrder = order;
       await setModeToView();
     }}
-    onRemove={(userId) => handleRemoveUser(userId, ViewMode.OPTIONS)}
+    onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.OPTIONS)}
     onRefreshAlbum={refreshAlbum}
-    onClose={() => (viewMode = ViewMode.VIEW)}
+    onClose={() => (viewMode = AlbumPageViewMode.VIEW)}
     onToggleEnabledActivity={handleToggleEnableActivity}
-    onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
+    onShowSelectSharedUser={() => (viewMode = AlbumPageViewMode.SELECT_USERS)}
   />
 {/if}
 
diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 2ce1309351..3402dff960 100644
--- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -19,13 +19,17 @@
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   const assetStore = new AssetStore({ isArchived: true });
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
-  $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
+  let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
 
   onDestroy(() => {
     assetStore.destroy();
@@ -51,6 +55,8 @@
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
-    <EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
+    {#snippet empty()}
+      <EmptyPlaceholder text={$t('no_archived_assets_message')} />
+    {/snippet}
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte
index 1f71269c11..eb0194c447 100644
--- a/web/src/routes/(user)/buy/+page.svelte
+++ b/web/src/routes/(user)/buy/+page.svelte
@@ -11,8 +11,12 @@
   import { purchaseStore } from '$lib/stores/purchase.store';
   import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
 
-  export let data: PageData;
-  let showLicenseActivated = false;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
+  let showLicenseActivated = $state(false);
   const { isPurchased } = purchaseStore;
 </script>
 
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte
index ebd2e96b5a..c31c0538e4 100644
--- a/web/src/routes/(user)/explore/+page.svelte
+++ b/web/src/routes/(user)/explore/+page.svelte
@@ -12,7 +12,11 @@
   import { websocketEvents } from '$lib/stores/websocket';
   import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   enum Field {
     CITY = 'exifInfo.city',
@@ -23,9 +27,10 @@
     return targetField?.items || [];
   };
 
-  $: places = getFieldItems(data.items, Field.CITY);
-  $: people = data.response.people;
-  $: hasPeople = data.response.total > 0;
+  let places = $derived(getFieldItems(data.items, Field.CITY));
+  let people = $state(data.response.people);
+
+  let hasPeople = $derived(data.response.total > 0);
 
   onMount(() => {
     return websocketEvents.on('on_person_thumbnail', (personId: string) => {
@@ -51,13 +56,21 @@
           draggable="false">{$t('view_all')}</a
         >
       </div>
-      <SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4" let:itemCount>
-        {#each people.slice(0, itemCount) as person (person.id)}
-          <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
-            <ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />
-            <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
-          </a>
-        {/each}
+      <SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
+        {#snippet children({ itemCount })}
+          {#each people.slice(0, itemCount) as person (person.id)}
+            <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center">
+              <ImageThumbnail
+                circle
+                shadow
+                url={getPeopleThumbnailUrl(person)}
+                altText={person.name}
+                widthStyle="100%"
+              />
+              <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
+            </a>
+          {/each}
+        {/snippet}
       </SingleGridRow>
     </div>
   {/if}
@@ -72,23 +85,29 @@
           draggable="false">{$t('view_all')}</a
         >
       </div>
-      <SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4" let:itemCount>
-        {#each places.slice(0, itemCount) as item (item.data.id)}
-          <a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}" draggable="false">
-            <div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
-              <img
-                src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
-                alt={item.value}
-                class="object-cover aspect-square w-full"
-              />
-            </div>
-            <span
-              class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
+      <SingleGridRow class="grid md:grid-auto-fill-36 grid-auto-fill-28 gap-x-4">
+        {#snippet children({ itemCount })}
+          {#each places.slice(0, itemCount) as item (item.data.id)}
+            <a
+              class="relative"
+              href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}"
+              draggable="false"
             >
-              {item.value}
-            </span>
-          </a>
-        {/each}
+              <div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
+                <img
+                  src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
+                  alt={item.value}
+                  class="object-cover aspect-square w-full"
+                />
+              </div>
+              <span
+                class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
+              >
+                {item.value}
+              </span>
+            </a>
+          {/each}
+        {/snippet}
       </SingleGridRow>
     </div>
   {/if}
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 13e70c9161..8699582f9a 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -21,13 +21,17 @@
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   const assetStore = new AssetStore({ isFavorite: true });
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
-  $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
+  let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
 
   onDestroy(() => {
     assetStore.destroy();
@@ -56,6 +60,8 @@
 
 <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
-    <EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
+    {#snippet empty()}
+      <EmptyPlaceholder text={$t('no_favorites_message')} />
+    {/snippet}
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 2cd3d8c9f3..255a4373ca 100644
--- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -18,15 +18,19 @@
   import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let selectedAssets: Set<AssetResponseDto> = new Set();
-  const viewport: Viewport = { width: 0, height: 0 };
+  let { data }: Props = $props();
 
-  $: pathSegments = data.path ? data.path.split('/') : [];
-  $: tree = buildTree($foldersStore?.uniquePaths || []);
-  $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
-  $: currentTreeItems = currentPath ? data.currentFolders : Object.keys(tree);
+  let selectedAssets: Set<AssetResponseDto> = $state(new Set());
+  const viewport: Viewport = $state({ width: 0, height: 0 });
+
+  let pathSegments = $derived(data.path ? data.path.split('/') : []);
+  let tree = $derived(buildTree($foldersStore?.uniquePaths || []));
+  let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
+  let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
 
   onMount(async () => {
     await foldersStore.fetchUniquePaths();
@@ -48,20 +52,22 @@
 </script>
 
 <UserPageLayout title={data.meta.title}>
-  <SideBarSection slot="sidebar">
-    <SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
-    <section>
-      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
-      <div class="h-full">
-        <TreeItems
-          icons={{ default: mdiFolderOutline, active: mdiFolder }}
-          items={tree}
-          active={currentPath}
-          {getLink}
-        />
-      </div>
-    </section>
-  </SideBarSection>
+  {#snippet sidebar()}
+    <SideBarSection>
+      <SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
+      <section>
+        <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
+        <div class="h-full">
+          <TreeItems
+            icons={{ default: mdiFolderOutline, active: mdiFolder }}
+            items={tree}
+            active={currentPath}
+            {getLink}
+          />
+        </div>
+      </section>
+    </SideBarSection>
+  {/snippet}
 
   <Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} {getLink} />
 
diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
index adbc3cfe69..613ae4d66b 100644
--- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -1,4 +1,6 @@
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import { goto } from '$app/navigation';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
@@ -17,15 +19,19 @@
   import { handlePromiseError } from '$lib/utils';
   import { navigate } from '$lib/utils/navigation';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
 
   let abortController: AbortController;
-  let mapMarkers: MapMarkerResponseDto[] = [];
-  let viewingAssets: string[] = [];
+  let mapMarkers: MapMarkerResponseDto[] = $state([]);
+  let viewingAssets: string[] = $state([]);
   let viewingAssetCursor = 0;
-  let showSettingsModal = false;
+  let showSettingsModal = $state(false);
 
   onMount(async () => {
     mapMarkers = await loadMapMarkers();
@@ -36,9 +42,11 @@
     assetViewingStore.showAssetViewer(false);
   });
 
-  $: if (!$featureFlags.map) {
-    handlePromiseError(goto(AppRoute.PHOTOS));
-  }
+  run(() => {
+    if (!$featureFlags.map) {
+      handlePromiseError(goto(AppRoute.PHOTOS));
+    }
+  });
   const omit = (obj: MapSettings, key: string) => {
     return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key));
   };
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 2caab9de82..4332e5339e 100644
--- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -15,7 +15,11 @@
   import { mdiPlus, mdiArrowLeft } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true });
   const assetInteractionStore = createAssetInteractionStore();
@@ -39,11 +43,11 @@
     </AssetSelectControlBar>
   {:else}
     <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}>
-      <svelte:fragment slot="leading">
+      {#snippet leading()}
         <p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
           {data.partner.name}'s photos
         </p>
-      </svelte:fragment>
+      {/snippet}
     </ControlAppBar>
   {/if}
   <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte
index b6d25c48bf..5b3fbeea03 100644
--- a/web/src/routes/(user)/people/+page.svelte
+++ b/web/src/routes/(user)/people/+page.svelte
@@ -38,34 +38,35 @@
   import { fly } from 'svelte/transition';
   import type { PageData } from './$types';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  $: people = data.people.people;
-  $: visiblePeople = people.filter((people) => !people.isHidden);
-  $: countVisiblePeople = searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden;
-  $: showPeople = searchName ? searchedPeopleLocal : visiblePeople;
-
-  let selectHidden = false;
-  let searchName = '';
-  let showChangeNameModal = false;
-  let showSetBirthDateModal = false;
-  let showMergeModal = false;
-  let personName = '';
-  let nextPage = data.people.hasNextPage ? 2 : null;
-  let personMerge1: PersonResponseDto;
-  let personMerge2: PersonResponseDto;
-  let potentialMergePeople: PersonResponseDto[] = [];
-  let edittingPerson: PersonResponseDto | null = null;
-  let searchedPeopleLocal: PersonResponseDto[] = [];
-  let handleSearchPeople: (force?: boolean, name?: string) => Promise<void>;
-  let changeNameInputEl: HTMLInputElement | null;
-  let innerHeight: number;
+  let { data }: Props = $props();
 
+  let selectHidden = $state(false);
+  let searchName = $state('');
+  let showChangeNameModal = $state(false);
+  let showSetBirthDateModal = $state(false);
+  let showMergeModal = $state(false);
+  let personName = $state('');
+  let nextPage = $state(data.people.hasNextPage ? 2 : null);
+  let personMerge1 = $state<PersonResponseDto>();
+  let personMerge2 = $state<PersonResponseDto>();
+  let potentialMergePeople: PersonResponseDto[] = $state([]);
+  let edittingPerson: PersonResponseDto | null = $state(null);
+  let searchedPeopleLocal: PersonResponseDto[] = $state([]);
+  // let handleSearchPeople: (force?: boolean, name?: string) => Promise<void> = $state();
+  let changeNameInputEl = $state<HTMLInputElement>();
+  let innerHeight = $state(0);
+  let searchPeopleElement = $state<ReturnType<typeof SearchPeople>>();
   onMount(() => {
     const getSearchedPeople = $page.url.searchParams.get(QueryParameter.SEARCHED_PEOPLE);
     if (getSearchedPeople) {
       searchName = getSearchedPeople;
-      handlePromiseError(handleSearchPeople(true, searchName));
+      if (searchPeopleElement) {
+        handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
+      }
     }
     return websocketEvents.on('on_person_thumbnail', (personId: string) => {
       for (const person of people) {
@@ -198,7 +199,9 @@
     );
   };
 
-  const submitNameChange = async () => {
+  const submitNameChange = async (event: Event) => {
+    event.preventDefault();
+
     potentialMergePeople = [];
     showChangeNameModal = false;
     if (!edittingPerson || personName === edittingPerson.name) {
@@ -225,9 +228,9 @@
       potentialMergePeople = people
         .filter(
           (person: PersonResponseDto) =>
-            personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
+            personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
             person.id !== personMerge2.id &&
-            person.id !== personMerge1.id &&
+            person.id !== personMerge1?.id &&
             !person.isHidden,
         )
         .slice(0, 3);
@@ -293,11 +296,26 @@
   const onResetSearchBar = async () => {
     await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url);
   };
+
+  let people = $state(data.people.people);
+  $effect(() => {
+    people = data.people.people;
+  });
+  let visiblePeople = $derived(people.filter((people) => !people.isHidden));
+  let countVisiblePeople = $derived(searchName ? searchedPeopleLocal.length : data.people.total - data.people.hidden);
+  let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople);
+
+  // const submitNameChange = (event: Event) => {
+  //   event.preventDefault();
+  //   if (searchPeopleElement) {
+  //     handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
+  //   }
+  // };
 </script>
 
 <svelte:window bind:innerHeight />
 
-{#if showMergeModal}
+{#if showMergeModal && personMerge1 && personMerge2}
   <MergeSuggestionModal
     {personMerge1}
     {personMerge2}
@@ -312,23 +330,23 @@
   title={$t('people')}
   description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
 >
-  <svelte:fragment slot="buttons">
+  {#snippet buttons()}
     {#if people.length > 0}
       <div class="flex gap-2 items-center justify-center">
         <div class="hidden sm:block">
           <div class="w-40 lg:w-80 h-10">
             <SearchPeople
+              bind:this={searchPeopleElement}
               type="searchBar"
               placeholder={$t('search_people')}
               onReset={onResetSearchBar}
               onSearch={handleSearch}
               bind:searchName
               bind:searchedPeopleLocal
-              bind:handleSearch={handleSearchPeople}
             />
           </div>
         </div>
-        <LinkButton on:click={() => (selectHidden = !selectHidden)}>
+        <LinkButton onclick={() => (selectHidden = !selectHidden)}>
           <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
             <Icon path={mdiEyeOutline} size="18" />
             <p class="ml-2">{$t('show_and_hide_people')}</p>
@@ -336,24 +354,20 @@
         </LinkButton>
       </div>
     {/if}
-  </svelte:fragment>
+  {/snippet}
 
   {#if countVisiblePeople > 0 && (!searchName || searchedPeopleLocal.length > 0)}
-    <PeopleInfiniteScroll
-      people={showPeople}
-      hasNextPage={!!nextPage && !searchName}
-      {loadNextPage}
-      let:person
-      let:index
-    >
-      <PeopleCard
-        {person}
-        preload={index < 20}
-        onChangeName={() => handleChangeName(person)}
-        onSetBirthDate={() => handleSetBirthDate(person)}
-        onMergePeople={() => handleMergePeople(person)}
-        onHidePerson={() => handleHidePerson(person)}
-      />
+    <PeopleInfiniteScroll people={showPeople} hasNextPage={!!nextPage && !searchName} {loadNextPage}>
+      {#snippet children({ person, index })}
+        <PeopleCard
+          {person}
+          preload={index < 20}
+          onChangeName={() => handleChangeName(person)}
+          onSetBirthDate={() => handleSetBirthDate(person)}
+          onMergePeople={() => handleMergePeople(person)}
+          onHidePerson={() => handleHidePerson(person)}
+        />
+      {/snippet}
     </PeopleInfiniteScroll>
   {:else}
     <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
@@ -368,7 +382,7 @@
 
   {#if showChangeNameModal}
     <FullScreenModal title={$t('change_name')} onClose={() => (showChangeNameModal = false)}>
-      <form on:submit|preventDefault={submitNameChange} autocomplete="off" id="change-name-form">
+      <form onsubmit={submitNameChange} autocomplete="off" id="change-name-form">
         <div class="flex flex-col gap-2">
           <label class="immich-form-label" for="name">{$t('name')}</label>
           <input
@@ -381,16 +395,17 @@
           />
         </div>
       </form>
-      <svelte:fragment slot="sticky-bottom">
+
+      {#snippet stickyBottom()}
         <Button
           color="gray"
           fullwidth
-          on:click={() => {
+          onclick={() => {
             showChangeNameModal = false;
           }}>{$t('cancel')}</Button
         >
         <Button type="submit" fullwidth form="change-name-form">{$t('ok')}</Button>
-      </svelte:fragment>
+      {/snippet}
     </FullScreenModal>
   {/if}
 
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index d68367d106..d9b7c6a08f 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -25,7 +25,7 @@
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
-  import { AppRoute, QueryParameter } from '$lib/constants';
+  import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { AssetStore } from '$lib/stores/assets.store';
@@ -58,47 +58,33 @@
   import { t } from 'svelte-i18n';
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 
-  export let data: PageData;
-
-  let numberOfAssets = data.statistics.assets;
-  let { isViewing: showAssetViewer } = assetViewingStore;
-
-  enum ViewMode {
-    VIEW_ASSETS = 'view-assets',
-    SELECT_PERSON = 'select-person',
-    MERGE_PEOPLE = 'merge-people',
-    SUGGEST_MERGE = 'suggest-merge',
-    BIRTH_DATE = 'birth-date',
-    UNASSIGN_ASSETS = 'unassign-faces',
+  interface Props {
+    data: PageData;
   }
 
+  let { data = $bindable() }: Props = $props();
+
+  let numberOfAssets = $state(data.statistics.assets);
+  let { isViewing: showAssetViewer } = assetViewingStore;
+
   let assetStore = new AssetStore({
     isArchived: false,
     personId: data.person.id,
   });
 
-  $: person = data.person;
-  $: thumbnailData = getPeopleThumbnailUrl(person);
-  $: if (person) {
-    handlePromiseError(updateAssetCount());
-    handlePromiseError(assetStore.updateOptions({ personId: person.id }));
-  }
-
   const assetInteractionStore = createAssetInteractionStore();
   const { selectedAssets, isMultiSelectState } = assetInteractionStore;
 
-  let viewMode: ViewMode = ViewMode.VIEW_ASSETS;
-  let isEditingName = false;
-  let previousRoute: string = AppRoute.EXPLORE;
+  let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
+  let isEditingName = $state(false);
+  let previousRoute: string = $state(AppRoute.EXPLORE);
   let people: PersonResponseDto[] = [];
-  let personMerge1: PersonResponseDto;
-  let personMerge2: PersonResponseDto;
-  let potentialMergePeople: PersonResponseDto[] = [];
-
-  let refreshAssetGrid = false;
+  let personMerge1: PersonResponseDto | undefined = $state();
+  let personMerge2: PersonResponseDto | undefined = $state();
+  let potentialMergePeople: PersonResponseDto[] = $state([]);
 
   let personName = '';
-  let suggestedPeople: PersonResponseDto[] = [];
+  let suggestedPeople: PersonResponseDto[] = $state([]);
 
   /**
    * Save the word used to search people name: for example,
@@ -107,11 +93,8 @@
    * However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server).
    * or if the new search word starts with another word / letter
    **/
-  let isSearchingPeople = false;
-  let suggestionContainer: HTMLDivElement;
-
-  $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
-  $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
+  let isSearchingPeople = $state(false);
+  let suggestionContainer: HTMLElement | undefined = $state();
 
   onMount(() => {
     const action = $page.url.searchParams.get(QueryParameter.ACTION);
@@ -120,7 +103,7 @@
       previousRoute = getPreviousRoute;
     }
     if (action == 'merge') {
-      viewMode = ViewMode.MERGE_PEOPLE;
+      viewMode = PersonPageViewMode.MERGE_PEOPLE;
     }
 
     return websocketEvents.on('on_person_thumbnail', (personId: string) => {
@@ -131,7 +114,7 @@
   });
 
   const handleEscape = async () => {
-    if ($showAssetViewer || viewMode === ViewMode.SUGGEST_MERGE) {
+    if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) {
       return;
     }
     if ($isMultiSelectState) {
@@ -162,11 +145,11 @@
   const handleUnmerge = () => {
     $assetStore.removeAssets([...$selectedAssets].map((a) => a.id));
     assetInteractionStore.clearMultiselect();
-    viewMode = ViewMode.VIEW_ASSETS;
+    viewMode = PersonPageViewMode.VIEW_ASSETS;
   };
 
   const handleReassignAssets = () => {
-    viewMode = ViewMode.UNASSIGN_ASSETS;
+    viewMode = PersonPageViewMode.UNASSIGN_ASSETS;
   };
 
   const toggleHidePerson = async () => {
@@ -191,13 +174,11 @@
     await updateAssetCount();
     await handleGoBack();
 
-    data.person = person;
-
-    refreshAssetGrid = !refreshAssetGrid;
+    data = { ...data, person };
   };
 
   const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => {
-    if (viewMode !== ViewMode.SELECT_PERSON) {
+    if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
       return;
     }
     try {
@@ -209,12 +190,12 @@
 
     assetInteractionStore.clearMultiselect();
 
-    viewMode = ViewMode.VIEW_ASSETS;
+    viewMode = PersonPageViewMode.VIEW_ASSETS;
   };
 
   const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => {
     const [personToMerge, personToBeMergedIn] = response;
-    viewMode = ViewMode.VIEW_ASSETS;
+    viewMode = PersonPageViewMode.VIEW_ASSETS;
     isEditingName = false;
     try {
       await mergePerson({
@@ -228,7 +209,6 @@
       people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
       if (personToBeMergedIn.name != personName && person.id === personToBeMergedIn.id) {
         await updateAssetCount();
-        refreshAssetGrid = !refreshAssetGrid;
         return;
       }
       await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
@@ -243,11 +223,11 @@
     personName = person.name;
     personMerge1 = person;
     personMerge2 = person2;
-    viewMode = ViewMode.SUGGEST_MERGE;
+    viewMode = PersonPageViewMode.SUGGEST_MERGE;
   };
 
   const changeName = async () => {
-    viewMode = ViewMode.VIEW_ASSETS;
+    viewMode = PersonPageViewMode.VIEW_ASSETS;
     person.name = personName;
     try {
       isEditingName = false;
@@ -264,7 +244,7 @@
   };
 
   const handleCancelEditName = () => {
-    if (viewMode === ViewMode.SUGGEST_MERGE) {
+    if (viewMode === PersonPageViewMode.SUGGEST_MERGE) {
       return;
     }
     isSearchingPeople = false;
@@ -295,13 +275,13 @@
       potentialMergePeople = result
         .filter(
           (person: PersonResponseDto) =>
-            personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
+            personMerge2?.name.toLowerCase() === person.name.toLowerCase() &&
             person.id !== personMerge2.id &&
-            person.id !== personMerge1.id &&
+            person.id !== personMerge1?.id &&
             !person.isHidden,
         )
         .slice(0, 3);
-      viewMode = ViewMode.SUGGEST_MERGE;
+      viewMode = PersonPageViewMode.SUGGEST_MERGE;
       return;
     }
     await changeName();
@@ -309,7 +289,7 @@
 
   const handleSetBirthDate = async (birthDate: string) => {
     try {
-      viewMode = ViewMode.VIEW_ASSETS;
+      viewMode = PersonPageViewMode.VIEW_ASSETS;
       person.birthDate = birthDate;
 
       const updatedPerson = await updatePerson({
@@ -331,7 +311,7 @@
   };
 
   const handleGoBack = async () => {
-    viewMode = ViewMode.VIEW_ASSETS;
+    viewMode = PersonPageViewMode.VIEW_ASSETS;
     if ($page.url.searchParams.has(QueryParameter.ACTION)) {
       $page.url.searchParams.delete(QueryParameter.ACTION);
       await goto($page.url);
@@ -341,37 +321,50 @@
   onDestroy(() => {
     assetStore.destroy();
   });
+  let person = $derived(data.person);
+
+  let thumbnailData = $derived(getPeopleThumbnailUrl(person));
+
+  $effect(() => {
+    if (person) {
+      handlePromiseError(updateAssetCount());
+      handlePromiseError(assetStore.updateOptions({ personId: person.id }));
+    }
+  });
+
+  let isAllArchive = $derived([...$selectedAssets].every((asset) => asset.isArchived));
+  let isAllFavorite = $derived([...$selectedAssets].every((asset) => asset.isFavorite));
 </script>
 
-{#if viewMode === ViewMode.UNASSIGN_ASSETS}
+{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS}
   <UnMergeFaceSelector
     assetIds={[...$selectedAssets].map((a) => a.id)}
     personAssets={person}
-    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
     onConfirm={handleUnmerge}
   />
 {/if}
 
-{#if viewMode === ViewMode.SUGGEST_MERGE}
+{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2}
   <MergeSuggestionModal
     {personMerge1}
     {personMerge2}
     {potentialMergePeople}
-    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
     onReject={changeName}
     onConfirm={handleMergeSamePerson}
   />
 {/if}
 
-{#if viewMode === ViewMode.BIRTH_DATE}
+{#if viewMode === PersonPageViewMode.BIRTH_DATE}
   <SetBirthDateModal
     birthDate={person.birthDate ?? ''}
-    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}
     onUpdate={handleSetBirthDate}
   />
 {/if}
 
-{#if viewMode === ViewMode.MERGE_PEOPLE}
+{#if viewMode === PersonPageViewMode.MERGE_PEOPLE}
   <MergeFaceSelector {person} onBack={handleGoBack} onMerge={handleMerge} />
 {/if}
 
@@ -399,14 +392,14 @@
       </ButtonContextMenu>
     </AssetSelectControlBar>
   {:else}
-    {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
+    {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
       <ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
-        <svelte:fragment slot="trailing">
+        {#snippet trailing()}
           <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
             <MenuOption
               text={$t('select_featured_photo')}
               icon={mdiAccountBoxOutline}
-              onClick={() => (viewMode = ViewMode.SELECT_PERSON)}
+              onClick={() => (viewMode = PersonPageViewMode.SELECT_PERSON)}
             />
             <MenuOption
               text={person.isHidden ? $t('unhide_person') : $t('hide_person')}
@@ -416,21 +409,23 @@
             <MenuOption
               text={$t('set_date_of_birth')}
               icon={mdiCalendarEditOutline}
-              onClick={() => (viewMode = ViewMode.BIRTH_DATE)}
+              onClick={() => (viewMode = PersonPageViewMode.BIRTH_DATE)}
             />
             <MenuOption
               text={$t('merge_people')}
               icon={mdiAccountMultipleCheckOutline}
-              onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)}
+              onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)}
             />
           </ButtonContextMenu>
-        </svelte:fragment>
+        {/snippet}
       </ControlAppBar>
     {/if}
 
-    {#if viewMode === ViewMode.SELECT_PERSON}
-      <ControlAppBar onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}>
-        <svelte:fragment slot="leading">{$t('select_featured_photo')}</svelte:fragment>
+    {#if viewMode === PersonPageViewMode.SELECT_PERSON}
+      <ControlAppBar onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)}>
+        {#snippet leading()}
+          {$t('select_featured_photo')}
+        {/snippet}
       </ControlAppBar>
     {/if}
   {/if}
@@ -442,12 +437,12 @@
       enableRouting={true}
       {assetStore}
       {assetInteractionStore}
-      isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
-      singleSelect={viewMode === ViewMode.SELECT_PERSON}
+      isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
+      singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
       onSelect={handleSelectFeaturePhoto}
       onEscape={handleEscape}
     >
-      {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
+      {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE}
         <!-- Person information block -->
         <div
           class="relative w-fit p-4 sm:px-6"
@@ -473,7 +468,7 @@
                   type="button"
                   class="flex items-center justify-center"
                   title={$t('edit_name')}
-                  on:click={() => (isEditingName = true)}
+                  onclick={() => (isEditingName = true)}
                 >
                   <ImageThumbnail
                     circle
@@ -510,11 +505,11 @@
                   {#each suggestedPeople as person, index (person.id)}
                     <button
                       type="button"
-                      class="flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
+                      class="flex w-full border border-gray-200 dark:border-immich-dark-gray h-14 place-items-center bg-gray-100 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
                       suggestedPeople.length - 1
                         ? 'rounded-b-lg border-b'
                         : ''}"
-                      on:click={() => handleSuggestPeople(person)}
+                      onclick={() => handleSuggestPeople(person)}
                     >
                       <ImageThumbnail
                         circle
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index b44c58bc76..7e233fcd17 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -35,13 +35,12 @@
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
-  let isAllFavorite: boolean;
-  let isAllOwned: boolean;
-  let isAssetStackSelected: boolean;
-  let isLinkActionAvailable: boolean;
+  let isAllFavorite = $state(false);
+  let isAllOwned = $state(false);
+  let isAssetStackSelected = $state(false);
+  let isLinkActionAvailable = $state(false);
 
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: {
+  $effect(() => {
     const selection = [...$selectedAssets];
     isAllOwned = selection.every((asset) => asset.ownerId === $user.id);
     isAllFavorite = selection.every((asset) => asset.isFavorite);
@@ -52,7 +51,7 @@
       selection.some((asset) => asset.type === AssetTypeEnum.Image) &&
       selection.some((asset) => asset.type === AssetTypeEnum.Image);
     isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate);
-  }
+  });
 
   const handleEscape = () => {
     if ($showAssetViewer) {
@@ -134,6 +133,8 @@
     {#if $preferences.memories.enabled}
       <MemoryLane />
     {/if}
-    <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} slot="empty" />
+    {#snippet empty()}
+      <EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} />
+    {/snippet}
   </AssetGrid>
 </UserPageLayout>
diff --git a/web/src/routes/(user)/places/+page.svelte b/web/src/routes/(user)/places/+page.svelte
index 28c8e95cb1..1808755482 100644
--- a/web/src/routes/(user)/places/+page.svelte
+++ b/web/src/routes/(user)/places/+page.svelte
@@ -9,7 +9,11 @@
   import { t } from 'svelte-i18n';
   import { getAssetThumbnailUrl } from '$lib/utils';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   type AssetWithCity = AssetResponseDto & {
     exifInfo: {
@@ -17,10 +21,10 @@
     };
   };
 
-  $: places = data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city);
-  $: hasPlaces = places.length > 0;
+  let places = $derived(data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city));
+  let hasPlaces = $derived(places.length > 0);
 
-  let innerHeight: number;
+  let innerHeight: number = $state(0);
 </script>
 
 <svelte:window bind:innerHeight />
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 4605a2207e..0b6fba1613 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -40,24 +40,40 @@
   import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
   import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
   import { t } from 'svelte-i18n';
-  import { afterUpdate, tick } from 'svelte';
+  import { onMount, tick } from 'svelte';
   import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
 
   const MAX_ASSET_COUNT = 5000;
   let { isViewing: showAssetViewer } = assetViewingStore;
-  const viewport: Viewport = { width: 0, height: 0 };
+  const viewport: Viewport = $state({ width: 0, height: 0 });
 
   // The GalleryViewer pushes it's own history state, which causes weird
   // behavior for history.back(). To prevent that we store the previous page
   // manually and navigate back to that.
-  let previousRoute = AppRoute.EXPLORE as string;
+  let previousRoute = $state(AppRoute.EXPLORE as string);
 
   let nextPage: number | null = 1;
-  let searchResultAlbums: AlbumResponseDto[] = [];
-  let searchResultAssets: AssetResponseDto[] = [];
-  let isLoading = true;
-  let scrollY = 0;
+  let searchResultAlbums: AlbumResponseDto[] = $state([]);
+  let searchResultAssets: AssetResponseDto[] = $state([]);
+  let isLoading = $state(true);
+  let scrollY = $state(0);
   let scrollYHistory = 0;
+  let selectedAssets: Set<AssetResponseDto> = $state(new Set());
+
+  type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
+
+  let isMultiSelectionMode = $derived(selectedAssets.size > 0);
+  let isAllArchived = $derived([...selectedAssets].every((asset) => asset.isArchived));
+  let isAllFavorite = $derived([...selectedAssets].every((asset) => asset.isFavorite));
+  let searchQuery = $derived($page.url.searchParams.get(QueryParameter.QUERY));
+
+  onMount(() => {
+    if (terms && $featureFlags.loaded) {
+      handlePromiseError(onSearchQueryUpdate());
+    }
+  });
+
+  let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
 
   const onEscape = () => {
     if ($showAssetViewer) {
@@ -74,8 +90,7 @@
     $preventRaceConditionSearchBar = false;
   };
 
-  // save and restore scroll position
-  afterUpdate(() => {
+  $effect(() => {
     if (scrollY) {
       scrollYHistory = scrollY;
     }
@@ -105,11 +120,6 @@
       });
   });
 
-  let selectedAssets: Set<AssetResponseDto> = new Set();
-  $: isMultiSelectionMode = selectedAssets.size > 0;
-  $: isAllArchived = [...selectedAssets].every((asset) => asset.isArchived);
-  $: isAllFavorite = [...selectedAssets].every((asset) => asset.isFavorite);
-
   const onAssetDelete = (assetIds: string[]) => {
     const assetIdSet = new Set(assetIds);
     searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id));
@@ -118,16 +128,6 @@
     selectedAssets = new Set(searchResultAssets);
   };
 
-  type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
-
-  $: searchQuery = $page.url.searchParams.get(QueryParameter.QUERY);
-  let terms: SearchTerms;
-  $: terms = searchQuery ? JSON.parse(searchQuery) : {};
-
-  $: if (terms && $featureFlags.loaded) {
-    handlePromiseError(onSearchQueryUpdate());
-  }
-
   async function onSearchQueryUpdate() {
     nextPage = 1;
     searchResultAssets = [];
@@ -234,7 +234,7 @@
     <div class="fixed z-[100] top-0 left-0 w-full">
       <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
         <CreateSharedLink />
-        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
+        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
         <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
           <AddToAlbum {onAddToAlbum} />
           <AddToAlbum shared {onAddToAlbum} />
@@ -256,45 +256,52 @@
     <div class="fixed z-[100] top-0 left-0 w-full">
       <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
         <div class="w-full flex-1 pl-4">
-          <SearchBar grayTheme={false} value={terms.query ?? ''} searchQuery={terms} />
+          <SearchBar
+            grayTheme={false}
+            value={terms?.query ?? ''}
+            searchQuery={terms}
+            onSearch={() => handlePromiseError(onSearchQueryUpdate())}
+          />
         </div>
       </ControlAppBar>
     </div>
   {/if}
 </section>
 
-<section
-  id="search-chips"
-  class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
->
-  {#each getObjectKeys(terms) as key (key)}
-    {@const value = terms[key]}
-    <div class="flex place-content-center place-items-center text-xs">
-      <div
-        class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
+{#if terms}
+  <section
+    id="search-chips"
+    class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
+  >
+    {#each getObjectKeys(terms) as key (key)}
+      {@const value = terms[key]}
+      <div class="flex place-content-center place-items-center text-xs">
+        <div
+          class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
           {value === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
-      >
-        {getHumanReadableSearchKey(key)}
-      </div>
-
-      {#if value !== true}
-        <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
-          {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
-            {getHumanReadableDate(value)}
-          {:else if key === 'personIds' && Array.isArray(value)}
-            {#await getPersonName(value) then personName}
-              {personName}
-            {/await}
-          {:else if value === null || value === ''}
-            {$t('unknown')}
-          {:else}
-            {value}
-          {/if}
+        >
+          {getHumanReadableSearchKey(key as keyof SearchTerms)}
         </div>
-      {/if}
-    </div>
-  {/each}
-</section>
+
+        {#if value !== true}
+          <div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
+            {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
+              {getHumanReadableDate(value)}
+            {:else if key === 'personIds' && Array.isArray(value)}
+              {#await getPersonName(value) then personName}
+                {personName}
+              {/await}
+            {:else if value === null || value === ''}
+              {$t('unknown')}
+            {:else}
+              {value}
+            {/if}
+          </div>
+        {/if}
+      </div>
+    {/each}
+  </section>
+{/if}
 
 <section
   class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index f4fac282ba..dfe465f94d 100644
--- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -15,21 +15,24 @@
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { tick } from 'svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   let { gridScrollTarget } = assetViewingStore;
-  let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data;
-  let { title, description } = meta;
-  let isOwned = $user ? $user.id === sharedLink?.userId : false;
-  let password = '';
-  let innerWidth: number;
+  let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data);
+  let { title, description } = $state(meta);
+  let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
+  let password = $state('');
+  let innerWidth: number = $state(0);
 
   const handlePasswordSubmit = async () => {
     try {
       sharedLink = await getMySharedLink({ password, key });
       setSharedLink(sharedLink);
       passwordRequired = false;
-      isOwned = $user ? $user.id === sharedLink.userId : false;
       title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
       description =
         sharedLink.description ||
@@ -43,6 +46,11 @@
       handleError(error, $t('errors.unable_to_get_shared_link'));
     }
   };
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handlePasswordSubmit();
+  };
 </script>
 
 <svelte:window bind:innerWidth />
@@ -54,13 +62,13 @@
 {#if passwordRequired}
   <header>
     <ControlAppBar showBackButton={false}>
-      <svelte:fragment slot="leading">
+      {#snippet leading()}
         <ImmichLogoSmallLink width={innerWidth} />
-      </svelte:fragment>
+      {/snippet}
 
-      <svelte:fragment slot="trailing">
+      {#snippet trailing()}
         <ThemeButton />
-      </svelte:fragment>
+      {/snippet}
     </ControlAppBar>
   </header>
   <main
@@ -72,7 +80,7 @@
         {$t('sharing_enter_password')}
       </div>
       <div class="mt-4">
-        <form novalidate autocomplete="off" on:submit|preventDefault={handlePasswordSubmit}>
+        <form novalidate autocomplete="off" {onsubmit}>
           <input type="password" class="immich-form-input mr-2" placeholder={$t('password')} bind:value={password} />
           <Button type="submit">{$t('submit')}</Button>
         </form>
diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte
index 35279a02db..1e59a2720d 100644
--- a/web/src/routes/(user)/sharing/+page.svelte
+++ b/web/src/routes/(user)/sharing/+page.svelte
@@ -20,7 +20,11 @@
   import Albums from '$lib/components/album-page/albums-list.svelte';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   const settings: AlbumViewSettings = {
     view: AlbumViewMode.Cover,
@@ -34,21 +38,23 @@
 </script>
 
 <UserPageLayout title={data.meta.title}>
-  <div class="flex" slot="buttons">
-    <LinkButton on:click={() => createAlbumAndRedirect()}>
-      <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
-        <Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" />
-        <span class="leading-none max-sm:text-xs">{$t('create_album')}</span>
-      </div>
-    </LinkButton>
+  {#snippet buttons()}
+    <div class="flex">
+      <LinkButton onclick={() => createAlbumAndRedirect()}>
+        <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
+          <Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" />
+          <span class="leading-none max-sm:text-xs">{$t('create_album')}</span>
+        </div>
+      </LinkButton>
 
-    <LinkButton href={AppRoute.SHARED_LINKS}>
-      <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
-        <Icon path={mdiLink} size="18" class="shrink-0" />
-        <span class="leading-none max-sm:text-xs">{$t('shared_links')}</span>
-      </div>
-    </LinkButton>
-  </div>
+      <LinkButton href={AppRoute.SHARED_LINKS}>
+        <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
+          <Icon path={mdiLink} size="18" class="shrink-0" />
+          <span class="leading-none max-sm:text-xs">{$t('shared_links')}</span>
+        </div>
+      </LinkButton>
+    </div>
+  {/snippet}
 
   <div class="flex flex-col">
     {#if data.partners.length > 0}
@@ -89,7 +95,9 @@
         <!-- Shared Album List -->
         <Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
           <!-- Empty List -->
-          <EmptyPlaceholder slot="empty" text={$t('no_shared_albums_message')} src={empty2Url} />
+          {#snippet empty()}
+            <EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} />
+          {/snippet}
         </Albums>
       </div>
     </div>
diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
index 67e80f4703..b7d4da2941 100644
--- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
+++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { goto } from '$app/navigation';
+  import { goto, afterNavigate } from '$app/navigation';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
   import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
   import {
@@ -15,8 +15,8 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
 
-  let sharedLinks: SharedLinkResponseDto[] = [];
-  let editSharedLink: SharedLinkResponseDto | null = null;
+  let sharedLinks: SharedLinkResponseDto[] = $state([]);
+  let editSharedLink: SharedLinkResponseDto | null = $state(null);
 
   const refresh = async () => {
     sharedLinks = await getAllSharedLinks();
@@ -50,10 +50,19 @@
     await refresh();
     editSharedLink = null;
   };
+
+  let backUrl: string = AppRoute.SHARING;
+
+  afterNavigate(({ from }) => {
+    let url: string | undefined = from?.url?.pathname;
+    backUrl = url || AppRoute.SHARING;
+  });
 </script>
 
-<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}>
-  <svelte:fragment slot="leading">{$t('shared_links')}</svelte:fragment>
+<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
+  {#snippet leading()}
+    {$t('shared_links')}
+  {/snippet}
 </ControlAppBar>
 
 <section class="mt-[120px] flex flex-col pb-[120px] container max-w-screen-lg mx-auto px-3">
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
index ce91abb451..c52f0acb9e 100644
--- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -11,13 +11,11 @@
     notificationController,
     NotificationType,
   } from '$lib/components/shared-components/notification/notification';
-  import SettingInputField, {
-    SettingInputFieldType,
-  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
+  import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
   import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
   import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
   import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
-  import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
+  import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
   import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
   import { AssetStore } from '$lib/stores/assets.store';
   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
@@ -29,10 +27,14 @@
   import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  $: pathSegments = data.path ? data.path.split('/') : [];
-  $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
+  let { data }: Props = $props();
+
+  let pathSegments = $derived(data.path ? data.path.split('/') : []);
+  let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
 
   const assetInteractionStore = createAssetInteractionStore();
 
@@ -42,14 +44,19 @@
 
   const assetStore = new AssetStore({});
 
-  $: tags = data.tags;
-  $: tagsMap = buildMap(tags);
-  $: tag = currentPath ? tagsMap[currentPath] : null;
-  $: tagId = tag?.id;
-  $: tree = buildTree(tags.map((tag) => tag.value));
-  $: {
+  let tags = $state<TagResponseDto[]>([]);
+  $effect(() => {
+    tags = data.tags;
+  });
+
+  let tagsMap = $derived(buildMap(tags));
+  let tag = $derived(currentPath ? tagsMap[currentPath] : null);
+  let tagId = $derived(tag?.id);
+  let tree = $derived(buildTree(tags.map((tag) => tag.value)));
+
+  $effect.pre(() => {
     void assetStore.updateOptions({ tagId });
-  }
+  });
 
   const handleNavigation = async (tag: string) => {
     await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
@@ -67,15 +74,15 @@
 
   const navigateToView = (path: string) => goto(getLink(path));
 
-  let isNewOpen = false;
-  let newTagValue = '';
+  let isNewOpen = $state(false);
+  let newTagValue = $state('');
   const handleCreate = () => {
     newTagValue = tag ? tag.value + '/' : '';
     isNewOpen = true;
   };
 
-  let isEditOpen = false;
-  let newTagColor = '';
+  let isEditOpen = $state(false);
+  let newTagColor = $state('');
   const handleEdit = () => {
     newTagColor = tag?.color ?? '';
     isEditOpen = true;
@@ -135,49 +142,66 @@
     const parentPath = pathSegments.slice(0, -1).join('/');
     await navigateToView(parentPath);
   };
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleSubmit();
+  };
 </script>
 
 <UserPageLayout title={data.meta.title} scrollbar={false}>
-  <SideBarSection slot="sidebar">
-    <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
+  {#snippet sidebar()}
+    <SideBarSection>
+      <SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
+      <section>
+        <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
+        <div class="h-full">
+          <TreeItems
+            icons={{ default: mdiTag, active: mdiTag }}
+            items={tree}
+            active={currentPath}
+            {getLink}
+            {getColor}
+          />
+        </div>
+      </section>
+    </SideBarSection>
+  {/snippet}
+
+  {#snippet buttons()}
     <section>
-      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
-      <div class="h-full">
-        <TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} />
-      </div>
+      <LinkButton onclick={handleCreate}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiPlus} size="18" />
+          <p class="hidden md:block">{$t('create_tag')}</p>
+        </div>
+      </LinkButton>
+
+      {#if pathSegments.length > 0 && tag}
+        <LinkButton onclick={handleEdit}>
+          <div class="flex place-items-center gap-2 text-sm">
+            <Icon path={mdiPencil} size="18" />
+            <p class="hidden md:block">{$t('edit_tag')}</p>
+          </div>
+        </LinkButton>
+        <LinkButton onclick={handleDelete}>
+          <div class="flex place-items-center gap-2 text-sm">
+            <Icon path={mdiTrashCanOutline} size="18" />
+            <p class="hidden md:block">{$t('delete_tag')}</p>
+          </div>
+        </LinkButton>
+      {/if}
     </section>
-  </SideBarSection>
-
-  <section slot="buttons">
-    <LinkButton on:click={handleCreate}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiPlus} size="18" />
-        <p class="hidden md:block">{$t('create_tag')}</p>
-      </div>
-    </LinkButton>
-
-    {#if pathSegments.length > 0 && tag}
-      <LinkButton on:click={handleEdit}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiPencil} size="18" />
-          <p class="hidden md:block">{$t('edit_tag')}</p>
-        </div>
-      </LinkButton>
-      <LinkButton on:click={handleDelete}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiTrashCanOutline} size="18" />
-          <p class="hidden md:block">{$t('delete_tag')}</p>
-        </div>
-      </LinkButton>
-    {/if}
-  </section>
+  {/snippet}
 
   <Breadcrumbs {pathSegments} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
 
   <section class="mt-2 h-full">
     {#if tag}
       <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
-        <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" />
+        {#snippet empty()}
+          <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} />
+        {/snippet}
       </AssetGrid>
     {:else}
       <TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
@@ -193,7 +217,7 @@
       </p>
     </div>
 
-    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
+    <form {onsubmit} autocomplete="off" id="create-tag-form">
       <div class="my-4 flex flex-col gap-2">
         <SettingInputField
           inputType={SettingInputFieldType.TEXT}
@@ -204,16 +228,17 @@
         />
       </div>
     </form>
-    <svelte:fragment slot="sticky-bottom">
-      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+
+    {#snippet stickyBottom()}
+      <Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button>
       <Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button>
-    </svelte:fragment>
+    {/snippet}
   </FullScreenModal>
 {/if}
 
 {#if isEditOpen}
   <FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}>
-    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form">
+    <form {onsubmit} autocomplete="off" id="edit-tag-form">
       <div class="my-4 flex flex-col gap-2">
         <SettingInputField
           inputType={SettingInputFieldType.COLOR}
@@ -222,9 +247,10 @@
         />
       </div>
     </form>
-    <svelte:fragment slot="sticky-bottom">
-      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+
+    {#snippet stickyBottom()}
+      <Button color="gray" fullwidth onclick={() => handleCancel()}>{$t('cancel')}</Button>
       <Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button>
-    </svelte:fragment>
+    {/snippet}
   </FullScreenModal>
 {/if}
diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 862d9382a4..8803ea38c8 100644
--- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -27,7 +27,11 @@
   import { t } from 'svelte-i18n';
   import { onDestroy } from 'svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   if (!$featureFlags.trash) {
     handlePromiseError(goto(AppRoute.PHOTOS));
@@ -99,26 +103,30 @@
 
 {#if $featureFlags.loaded && $featureFlags.trash}
   <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
-    <div class="flex place-items-center gap-2" slot="buttons">
-      <LinkButton on:click={handleRestoreTrash} disabled={$isMultiSelectState}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiHistory} size="18" />
-          {$t('restore_all')}
-        </div>
-      </LinkButton>
-      <LinkButton on:click={() => handleEmptyTrash()} disabled={$isMultiSelectState}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiDeleteForeverOutline} size="18" />
-          {$t('empty_trash')}
-        </div>
-      </LinkButton>
-    </div>
+    {#snippet buttons()}
+      <div class="flex place-items-center gap-2">
+        <LinkButton onclick={handleRestoreTrash} disabled={$isMultiSelectState}>
+          <div class="flex place-items-center gap-2 text-sm">
+            <Icon path={mdiHistory} size="18" />
+            {$t('restore_all')}
+          </div>
+        </LinkButton>
+        <LinkButton onclick={() => handleEmptyTrash()} disabled={$isMultiSelectState}>
+          <div class="flex place-items-center gap-2 text-sm">
+            <Icon path={mdiDeleteForeverOutline} size="18" />
+            {$t('empty_trash')}
+          </div>
+        </LinkButton>
+      </div>
+    {/snippet}
 
     <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
       <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
         {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
       </p>
-      <EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} slot="empty" />
+      {#snippet empty()}
+        <EmptyPlaceholder text={$t('trash_no_results_message')} src={empty3Url} />
+      {/snippet}
     </AssetGrid>
   </UserPageLayout>
 {/if}
diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte
index 4ed46b580f..53cc661a30 100644
--- a/web/src/routes/(user)/user-settings/+page.svelte
+++ b/web/src/routes/(user)/user-settings/+page.svelte
@@ -7,18 +7,22 @@
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
-  export let isShowKeyboardShortcut = false;
+  interface Props {
+    data: PageData;
+    isShowKeyboardShortcut?: boolean;
+  }
+
+  let { data, isShowKeyboardShortcut = $bindable(false) }: Props = $props();
 </script>
 
 <UserPageLayout title={data.meta.title}>
-  <svelte:fragment slot="buttons">
+  {#snippet buttons()}
     <CircleIconButton
       icon={mdiKeyboard}
       title={$t('show_keyboard_shortcuts')}
-      on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
+      onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
     />
-  </svelte:fragment>
+  {/snippet}
   <section class="mx-4 flex place-content-center">
     <div class="w-full max-w-3xl">
       <UserSettingsList keys={data.keys} sessions={data.sessions} />
diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte
index bf18b99436..6713fe4a4b 100644
--- a/web/src/routes/(user)/utilities/+page.svelte
+++ b/web/src/routes/(user)/utilities/+page.svelte
@@ -3,7 +3,11 @@
   import type { PageData } from './$types';
   import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 </script>
 
 <UserPageLayout title={data.meta.title}>
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
index e1029b7ccb..fd2bcb438c 100644
--- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -22,8 +22,12 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { locale } from '$lib/stores/preferences.store';
 
-  export let data: PageData;
-  export let isShowKeyboardShortcut = false;
+  interface Props {
+    data: PageData;
+    isShowKeyboardShortcut?: boolean;
+  }
+
+  let { data = $bindable(), isShowKeyboardShortcut = $bindable(false) }: Props = $props();
 
   interface Shortcuts {
     general: ExplainedShortcut[];
@@ -46,8 +50,8 @@
     ],
   };
 
-  $: hasDuplicates = data.duplicates.length > 0;
-
+  let duplicates = $state(data.duplicates);
+  let hasDuplicates = $derived(duplicates.length > 0);
   const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
     if (prompt && confirmText) {
       const isConfirmed = await dialogController.show({ prompt, confirmText });
@@ -82,7 +86,7 @@
         await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !$featureFlags.trash } });
         await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
 
-        data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
+        duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
 
         deletedNotification(trashIds.length);
       },
@@ -95,14 +99,12 @@
     await stackAssets(assets, false);
     const duplicateAssetIds = assets.map((asset) => asset.id);
     await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
-    data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
+    duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
   };
 
   const handleDeduplicateAll = async () => {
-    const idsToKeep = data.duplicates
-      .map((group) => suggestDuplicateByFileSize(group.assets))
-      .map((asset) => asset?.id);
-    const idsToDelete = data.duplicates.flatMap((group, i) =>
+    const idsToKeep = duplicates.map((group) => suggestDuplicateByFileSize(group.assets)).map((asset) => asset?.id);
+    const idsToDelete = duplicates.flatMap((group, i) =>
       group.assets.map((asset) => asset.id).filter((asset) => asset !== idsToKeep[i]),
     );
 
@@ -125,7 +127,7 @@
           },
         });
 
-        data.duplicates = [];
+        duplicates = [];
 
         deletedNotification(idsToDelete.length);
       },
@@ -135,12 +137,12 @@
   };
 
   const handleKeepAll = async () => {
-    const ids = data.duplicates.flatMap((group) => group.assets.map((asset) => asset.id));
+    const ids = duplicates.flatMap((group) => group.assets.map((asset) => asset.id));
     return withConfirmation(
       async () => {
         await updateAssets({ assetBulkUpdateDto: { ids, duplicateId: null } });
 
-        data.duplicates = [];
+        duplicates = [];
 
         notificationController.show({
           message: $t('resolved_all_duplicates'),
@@ -153,38 +155,40 @@
   };
 </script>
 
-<UserPageLayout title={data.meta.title + ` (${data.duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
-  <div class="flex place-items-center gap-2" slot="buttons">
-    <LinkButton on:click={() => handleDeduplicateAll()} disabled={!hasDuplicates}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiTrashCanOutline} size="18" />
-        {$t('deduplicate_all')}
-      </div>
-    </LinkButton>
-    <LinkButton on:click={() => handleKeepAll()} disabled={!hasDuplicates}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiCheckOutline} size="18" />
-        {$t('keep_all')}
-      </div>
-    </LinkButton>
-    <CircleIconButton
-      icon={mdiKeyboard}
-      title={$t('show_keyboard_shortcuts')}
-      on:click={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
-    />
-  </div>
+<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
+  {#snippet buttons()}
+    <div class="flex place-items-center gap-2">
+      <LinkButton onclick={() => handleDeduplicateAll()} disabled={!hasDuplicates}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiTrashCanOutline} size="18" />
+          {$t('deduplicate_all')}
+        </div>
+      </LinkButton>
+      <LinkButton onclick={() => handleKeepAll()} disabled={!hasDuplicates}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiCheckOutline} size="18" />
+          {$t('keep_all')}
+        </div>
+      </LinkButton>
+      <CircleIconButton
+        icon={mdiKeyboard}
+        title={$t('show_keyboard_shortcuts')}
+        onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
+      />
+    </div>
+  {/snippet}
 
   <div class="mt-4">
-    {#if data.duplicates && data.duplicates.length > 0}
+    {#if duplicates && duplicates.length > 0}
       <div class="mb-4 text-sm dark:text-white">
         <p>{$t('duplicates_description')}</p>
       </div>
-      {#key data.duplicates[0].duplicateId}
+      {#key duplicates[0].duplicateId}
         <DuplicatesCompareControl
-          assets={data.duplicates[0].assets}
+          assets={duplicates[0].assets}
           onResolve={(duplicateAssetIds, trashIds) =>
-            handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
-          onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)}
+            handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
+          onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
         />
       {/key}
     {:else}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 8f8bd033eb..a6e6727d39 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -1,4 +1,6 @@
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import { afterNavigate, beforeNavigate } from '$app/navigation';
   import { page } from '$app/stores';
   import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@@ -16,23 +18,21 @@
   import { user } from '$lib/stores/user.store';
   import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
   import { copyToClipboard, setKey } from '$lib/utils';
-  import { onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount, type Snippet } from 'svelte';
   import '../app.css';
   import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation';
   import DialogWrapper from '$lib/components/shared-components/dialog/dialog-wrapper.svelte';
   import { t } from 'svelte-i18n';
   import Error from '$lib/components/error.svelte';
   import { shortcut } from '$lib/actions/shortcut';
-
-  let showNavigationLoadingBar = false;
-  $: changeTheme($colorTheme);
-
-  $: if ($user) {
-    openWebsocketConnection();
-  } else {
-    closeWebsocketConnection();
+  interface Props {
+    children?: Snippet;
   }
 
+  let { children }: Props = $props();
+
+  let showNavigationLoadingBar = $state(false);
+
   const changeTheme = (theme: ThemeSetting) => {
     if (theme.system) {
       theme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
@@ -82,6 +82,16 @@
   afterNavigate(() => {
     showNavigationLoadingBar = false;
   });
+  run(() => {
+    changeTheme($colorTheme);
+  });
+  run(() => {
+    if ($user) {
+      openWebsocketConnection();
+    } else {
+      closeWebsocketConnection();
+    }
+  });
 </script>
 
 <svelte:head>
@@ -135,7 +145,7 @@
 {#if $page.data.error}
   <Error error={$page.data.error}></Error>
 {:else}
-  <slot />
+  {@render children?.()}
 {/if}
 
 {#if showNavigationLoadingBar}
diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte
index 16c2541e61..b323a136aa 100644
--- a/web/src/routes/admin/jobs-status/+page.svelte
+++ b/web/src/routes/admin/jobs-status/+page.svelte
@@ -18,13 +18,17 @@
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let jobs: AllJobStatusResponseDto;
+  let { data }: Props = $props();
+
+  let jobs: AllJobStatusResponseDto | undefined = $state();
 
   let running = true;
-  let isOpen = false;
-  let selectedJob: ComboBoxOption | undefined = undefined;
+  let isOpen = $state(false);
+  let selectedJob: ComboBoxOption | undefined = $state(undefined);
 
   onMount(async () => {
     while (running) {
@@ -58,23 +62,30 @@
       handleError(error, $t('errors.unable_to_submit_job'));
     }
   };
+
+  const onsubmit = async (event: Event) => {
+    event.preventDefault();
+    await handleCreate();
+  };
 </script>
 
 <UserPageLayout title={data.meta.title} admin>
-  <div class="flex justify-end" slot="buttons">
-    <LinkButton on:click={() => (isOpen = true)}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiPlus} size="18" />
-        {$t('admin.create_job')}
-      </div>
-    </LinkButton>
-    <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job">
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiCog} size="18" />
-        {$t('admin.manage_concurrency')}
-      </div>
-    </LinkButton>
-  </div>
+  {#snippet buttons()}
+    <div class="flex justify-end">
+      <LinkButton onclick={() => (isOpen = true)}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiPlus} size="18" />
+          {$t('admin.create_job')}
+        </div>
+      </LinkButton>
+      <LinkButton href="{AppRoute.ADMIN_SETTINGS}?isOpen=job">
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiCog} size="18" />
+          {$t('admin.manage_concurrency')}
+        </div>
+      </LinkButton>
+    </div>
+  {/snippet}
   <section id="setting-content" class="flex place-content-center sm:mx-4">
     <section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
       {#if jobs}
@@ -92,15 +103,17 @@
     onConfirm={handleCreate}
     onCancel={handleCancel}
   >
-    <form on:submit|preventDefault={handleCreate} autocomplete="off" id="create-tag-form" slot="prompt" class="w-full">
-      <div class="flex flex-col gap-1 text-left">
-        <Combobox
-          bind:selectedOption={selectedJob}
-          label={$t('jobs')}
-          {options}
-          placeholder={$t('admin.search_jobs')}
-        />
-      </div>
-    </form>
+    {#snippet promptSnippet()}
+      <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
+        <div class="flex flex-col gap-1 text-left">
+          <Combobox
+            bind:selectedOption={selectedJob}
+            label={$t('jobs')}
+            {options}
+            placeholder={$t('admin.search_jobs')}
+          />
+        </div>
+      </form>
+    {/snippet}
   </ConfirmDialog>
 {/if}
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte
index 6f61572b0e..b89e81ebf6 100644
--- a/web/src/routes/admin/library-management/+page.svelte
+++ b/web/src/routes/admin/library-management/+page.svelte
@@ -36,32 +36,36 @@
   import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
   import { locale } from '$lib/stores/preferences.store';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let libraries: LibraryResponseDto[] = [];
+  let { data }: Props = $props();
+
+  let libraries: LibraryResponseDto[] = $state([]);
 
   let stats: LibraryStatsResponseDto[] = [];
-  let owner: UserResponseDto[] = [];
+  let owner: UserResponseDto[] = $state([]);
   let photos: number[] = [];
   let videos: number[] = [];
-  let totalCount: number[] = [];
-  let diskUsage: number[] = [];
-  let diskUsageUnit: ByteUnit[] = [];
-  let editImportPaths: number | null;
-  let editScanSettings: number | null;
-  let renameLibrary: number | null;
+  let totalCount: number[] = $state([]);
+  let diskUsage: number[] = $state([]);
+  let diskUsageUnit: ByteUnit[] = $state([]);
+  let editImportPaths: number | undefined = $state();
+  let editScanSettings: number | undefined = $state();
+  let renameLibrary: number | undefined = $state();
   let updateLibraryIndex: number | null;
   let dropdownOpen: boolean[] = [];
-  let toCreateLibrary = false;
+  let toCreateLibrary = $state(false);
 
   onMount(async () => {
     await readLibraryList();
   });
 
   const closeAll = () => {
-    editImportPaths = null;
-    editScanSettings = null;
-    renameLibrary = null;
+    editImportPaths = undefined;
+    editScanSettings = undefined;
+    renameLibrary = undefined;
     updateLibraryIndex = null;
 
     for (let index = 0; index < dropdownOpen.length; index++) {
@@ -213,22 +217,24 @@
 {/if}
 
 <UserPageLayout title={data.meta.title} admin>
-  <div class="flex justify-end gap-2" slot="buttons">
-    {#if libraries.length > 0}
-      <LinkButton on:click={() => handleScanAll()}>
+  {#snippet buttons()}
+    <div class="flex justify-end gap-2">
+      {#if libraries.length > 0}
+        <LinkButton onclick={() => handleScanAll()}>
+          <div class="flex gap-1 text-sm">
+            <Icon path={mdiSync} size="18" />
+            <span>{$t('scan_all_libraries')}</span>
+          </div>
+        </LinkButton>
+      {/if}
+      <LinkButton onclick={() => (toCreateLibrary = true)}>
         <div class="flex gap-1 text-sm">
-          <Icon path={mdiSync} size="18" />
-          <span>{$t('scan_all_libraries')}</span>
+          <Icon path={mdiPlusBoxOutline} size="18" />
+          <span>{$t('create_library')}</span>
         </div>
       </LinkButton>
-    {/if}
-    <LinkButton on:click={() => (toCreateLibrary = true)}>
-      <div class="flex gap-1 text-sm">
-        <Icon path={mdiPlusBoxOutline} size="18" />
-        <span>{$t('create_library')}</span>
-      </div>
-    </LinkButton>
-  </div>
+    </div>
+  {/snippet}
   <section class="my-4">
     <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
       {#if libraries.length > 0}
@@ -311,13 +317,17 @@
               {#if renameLibrary === index}
                 <!-- svelte-ignore node_invalid_placement_ssr -->
                 <div transition:slide={{ duration: 250 }}>
-                  <LibraryRenameForm {library} onSubmit={handleUpdate} onCancel={() => (renameLibrary = null)} />
+                  <LibraryRenameForm {library} onSubmit={handleUpdate} onCancel={() => (renameLibrary = undefined)} />
                 </div>
               {/if}
               {#if editImportPaths === index}
                 <!-- svelte-ignore node_invalid_placement_ssr -->
                 <div transition:slide={{ duration: 250 }}>
-                  <LibraryImportPathsForm {library} onSubmit={handleUpdate} onCancel={() => (editImportPaths = null)} />
+                  <LibraryImportPathsForm
+                    {library}
+                    onSubmit={handleUpdate}
+                    onCancel={() => (editImportPaths = undefined)}
+                  />
                 </div>
               {/if}
               {#if editScanSettings === index}
@@ -326,7 +336,7 @@
                   <LibraryScanSettingsForm
                     {library}
                     onSubmit={handleUpdate}
-                    onCancel={() => (editScanSettings = null)}
+                    onCancel={() => (editScanSettings = undefined)}
                   />
                 </div>
               {/if}
diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte
index e8cb0649c2..9f19fddd03 100644
--- a/web/src/routes/admin/repair/+page.svelte
+++ b/web/src/routes/admin/repair/+page.svelte
@@ -19,7 +19,11 @@
   import { t } from 'svelte-i18n';
   import { locale } from '$lib/stores/preferences.store';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   interface UntrackedFile {
     filename: string;
@@ -33,12 +37,12 @@
 
   const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null }));
 
-  let checking = false;
-  let repairing = false;
+  let checking = $state(false);
+  let repairing = $state(false);
 
-  let orphans: FileReportItemDto[] = data.orphans;
-  let extras: UntrackedFile[] = normalize(data.extras);
-  let matches: Match[] = [];
+  let orphans: FileReportItemDto[] = $state(data.orphans);
+  let extras: UntrackedFile[] = $state(normalize(data.extras));
+  let matches: Match[] = $state([]);
 
   const handleDownload = () => {
     if (extras.length > 0) {
@@ -180,33 +184,34 @@
 </script>
 
 <UserPageLayout title={data.meta.title} admin>
-  <svelte:fragment slot="sidebar" />
-  <div class="flex justify-end gap-2" slot="buttons">
-    <LinkButton on:click={() => handleRepair()} disabled={matches.length === 0 || repairing}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiWrench} size="18" />
-        {$t('admin.repair_all')}
-      </div>
-    </LinkButton>
-    <LinkButton on:click={() => handleCheckAll()} disabled={extras.length === 0 || checking}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiCheckAll} size="18" />
-        {$t('admin.check_all')}
-      </div>
-    </LinkButton>
-    <LinkButton on:click={() => handleDownload()} disabled={extras.length + orphans.length === 0}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiDownload} size="18" />
-        {$t('export')}
-      </div>
-    </LinkButton>
-    <LinkButton on:click={() => handleRefresh()}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiRefresh} size="18" />
-        {$t('refresh')}
-      </div>
-    </LinkButton>
-  </div>
+  {#snippet buttons()}
+    <div class="flex justify-end gap-2">
+      <LinkButton onclick={() => handleRepair()} disabled={matches.length === 0 || repairing}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiWrench} size="18" />
+          {$t('admin.repair_all')}
+        </div>
+      </LinkButton>
+      <LinkButton onclick={() => handleCheckAll()} disabled={extras.length === 0 || checking}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiCheckAll} size="18" />
+          {$t('admin.check_all')}
+        </div>
+      </LinkButton>
+      <LinkButton onclick={() => handleDownload()} disabled={extras.length + orphans.length === 0}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiDownload} size="18" />
+          {$t('export')}
+        </div>
+      </LinkButton>
+      <LinkButton onclick={() => handleRefresh()}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiRefresh} size="18" />
+          {$t('refresh')}
+        </div>
+      </LinkButton>
+    </div>
+  {/snippet}
   <section id="setting-content" class="flex place-content-center sm:mx-4">
     <section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
       {#if matches.length + extras.length + orphans.length === 0}
@@ -238,7 +243,7 @@
                 <tr
                   class="w-full h-[75px] place-items-center border-[3px] border-transparent p-2 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
                   tabindex="0"
-                  on:click={() => handleSplit(match)}
+                  onclick={() => handleSplit(match)}
                 >
                   <td class="text-sm text-ellipsis flex flex-col gap-1 font-mono">
                     <span>{match.orphan.pathValue} =></span>
@@ -279,8 +284,8 @@
                   tabindex="0"
                   title={orphan.pathValue}
                 >
-                  <td on:click={() => copyToClipboard(orphan.pathValue)}>
-                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" />
+                  <td onclick={() => copyToClipboard(orphan.pathValue)}>
+                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
                   </td>
                   <td class="truncate text-sm font-mono text-left" title={orphan.pathValue}>
                     {orphan.pathValue}
@@ -318,11 +323,11 @@
                 <tr
                   class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-1 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 justify-between"
                   tabindex="0"
-                  on:click={() => handleCheckOne(extra.filename)}
+                  onclick={() => handleCheckOne(extra.filename)}
                   title={extra.filename}
                 >
-                  <td on:click={() => copyToClipboard(extra.filename)}>
-                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" />
+                  <td onclick={() => copyToClipboard(extra.filename)}>
+                    <CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
                   </td>
                   <td class="w-full text-md text-ellipsis flex justify-between pr-5">
                     <span class="text-ellipsis grow truncate font-mono text-sm pr-5" title={extra.filename}
diff --git a/web/src/routes/admin/server-status/+page.svelte b/web/src/routes/admin/server-status/+page.svelte
index 54f62b3adb..0aa4c3dd69 100644
--- a/web/src/routes/admin/server-status/+page.svelte
+++ b/web/src/routes/admin/server-status/+page.svelte
@@ -6,7 +6,11 @@
   import type { PageData } from './$types';
   import { asyncTimeout } from '$lib/utils';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data = $bindable() }: Props = $props();
 
   let running = true;
 
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte
index f724e2d145..6a71245051 100644
--- a/web/src/routes/admin/system-settings/+page.svelte
+++ b/web/src/routes/admin/system-settings/+page.svelte
@@ -27,7 +27,6 @@
   import { featureFlags } from '$lib/stores/server-config.store';
   import { copyToClipboard } from '$lib/utils';
   import { downloadBlob } from '$lib/utils/asset-utils';
-  import type { SystemConfigDto } from '@immich/sdk';
   import {
     mdiAccountOutline,
     mdiAlert,
@@ -53,19 +52,35 @@
   } from '@mdi/js';
   import type { PageData } from './$types';
   import { t } from 'svelte-i18n';
-  import type { ComponentType, SvelteComponent } from 'svelte';
+  import type { Component } from 'svelte';
   import type { SettingsComponentProps } from '$lib/components/admin-page/settings/admin-settings';
   import SearchBar from '$lib/components/elements/search-bar.svelte';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let config = data.configs;
-  let handleSave: (update: Partial<SystemConfigDto>) => Promise<void>;
+  let { data }: Props = $props();
 
-  type SettingsComponent = ComponentType<SvelteComponent<SettingsComponentProps>>;
+  let config = $state(data.configs);
+  let adminSettingElement = $state<ReturnType<typeof AdminSettings>>();
+
+  type SettingsComponent = Component<SettingsComponentProps>;
+
+  // https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
+  const jsonReplacer = (key: string, value: unknown) =>
+    value instanceof Object && !Array.isArray(value)
+      ? Object.keys(value)
+          .sort()
+          // eslint-disable-next-line unicorn/no-array-reduce
+          .reduce((sorted: { [key: string]: unknown }, key) => {
+            sorted[key] = (value as { [key: string]: unknown })[key];
+            return sorted;
+          }, {})
+      : value;
 
   const downloadConfig = () => {
-    const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
+    const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
     const downloadKey = 'immich-config.json';
     downloadManager.add(downloadKey, blob.size);
     downloadManager.update(downloadKey, blob.size);
@@ -73,7 +88,8 @@
     setTimeout(() => downloadManager.clear(downloadKey), 5000);
   };
 
-  let inputElement: HTMLInputElement;
+  let inputElement: HTMLInputElement | undefined = $state();
+
   const uploadConfig = (e: Event) => {
     const file = (e.target as HTMLInputElement).files?.[0];
     if (!file) {
@@ -82,7 +98,7 @@
     const reader = async () => {
       const text = await file.text();
       const newConfig = JSON.parse(text);
-      await handleSave(newConfig);
+      await adminSettingElement?.handleSave(newConfig);
     };
     reader().catch((error) => console.error('Error handling JSON config upload', error));
   };
@@ -215,15 +231,17 @@
     },
   ];
 
-  let searchQuery = '';
+  let searchQuery = $state('');
 
-  $: filteredSettings = settings.filter(({ title, subtitle }) => {
-    const query = searchQuery.toLowerCase();
-    return title.toLowerCase().includes(query) || subtitle.toLowerCase().includes(query);
-  });
+  let filteredSettings = $derived(
+    settings.filter(({ title, subtitle }) => {
+      const query = searchQuery.toLowerCase();
+      return title.toLowerCase().includes(query) || subtitle.toLowerCase().includes(query);
+    }),
+  );
 </script>
 
-<input bind:this={inputElement} type="file" accept=".json" style="display: none" on:change={uploadConfig} />
+<input bind:this={inputElement} type="file" accept=".json" style="display: none" onchange={uploadConfig} />
 
 <div class="h-svh flex flex-col overflow-hidden">
   {#if $featureFlags.configFile}
@@ -236,54 +254,58 @@
   {/if}
 
   <UserPageLayout title={data.meta.title} admin>
-    <div class="flex justify-end gap-2" slot="buttons">
-      <div class="hidden lg:block">
-        <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
-      </div>
-      <LinkButton on:click={() => copyToClipboard(JSON.stringify(config, null, 2))}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiContentCopy} size="18" />
-          {$t('copy_to_clipboard')}
+    {#snippet buttons()}
+      <div class="flex justify-end gap-2">
+        <div class="hidden lg:block">
+          <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
         </div>
-      </LinkButton>
-      <LinkButton on:click={() => downloadConfig()}>
-        <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiDownload} size="18" />
-          {$t('export_as_json')}
-        </div>
-      </LinkButton>
-      {#if !$featureFlags.configFile}
-        <LinkButton on:click={() => inputElement?.click()}>
+        <LinkButton onclick={() => copyToClipboard(JSON.stringify(config, jsonReplacer, 2))}>
           <div class="flex place-items-center gap-2 text-sm">
-            <Icon path={mdiUpload} size="18" />
-            {$t('import_from_json')}
+            <Icon path={mdiContentCopy} size="18" />
+            {$t('copy_to_clipboard')}
           </div>
         </LinkButton>
-      {/if}
-    </div>
-
-    <AdminSettings bind:config let:handleReset bind:handleSave let:savedConfig let:defaultConfig>
-      <section id="setting-content" class="flex place-content-center sm:mx-4">
-        <section class="w-full pb-28 sm:w-5/6 md:w-[896px]">
-          <div class="block lg:hidden">
-            <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
+        <LinkButton onclick={() => downloadConfig()}>
+          <div class="flex place-items-center gap-2 text-sm">
+            <Icon path={mdiDownload} size="18" />
+            {$t('export_as_json')}
           </div>
-          <SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
-            {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)}
-              <SettingAccordion {title} {subtitle} {key} {icon}>
-                <Component
-                  onSave={(config) => handleSave(config)}
-                  onReset={(options) => handleReset(options)}
-                  disabled={$featureFlags.configFile}
-                  {defaultConfig}
-                  {config}
-                  {savedConfig}
-                />
-              </SettingAccordion>
-            {/each}
-          </SettingAccordionState>
+        </LinkButton>
+        {#if !$featureFlags.configFile}
+          <LinkButton onclick={() => inputElement?.click()}>
+            <div class="flex place-items-center gap-2 text-sm">
+              <Icon path={mdiUpload} size="18" />
+              {$t('import_from_json')}
+            </div>
+          </LinkButton>
+        {/if}
+      </div>
+    {/snippet}
+
+    <AdminSettings bind:config bind:this={adminSettingElement}>
+      {#snippet children({ savedConfig, defaultConfig })}
+        <section id="setting-content" class="flex place-content-center sm:mx-4">
+          <section class="w-full pb-28 sm:w-5/6 md:w-[896px]">
+            <div class="block lg:hidden">
+              <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
+            </div>
+            <SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
+              {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)}
+                <SettingAccordion {title} {subtitle} {key} {icon}>
+                  <Component
+                    onSave={(config) => adminSettingElement?.handleSave(config)}
+                    onReset={(options) => adminSettingElement?.handleReset(options)}
+                    disabled={$featureFlags.configFile}
+                    bind:config
+                    {defaultConfig}
+                    {savedConfig}
+                  />
+                </SettingAccordion>
+              {/each}
+            </SettingAccordionState>
+          </section>
         </section>
-      </section>
+      {/snippet}
     </AdminSettings>
   </UserPageLayout>
 </div>
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index 80c0169176..d93a8a5731 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -27,16 +27,20 @@
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
 
-  let allUsers: UserAdminResponseDto[] = [];
-  let shouldShowEditUserForm = false;
-  let shouldShowCreateUserForm = false;
-  let shouldShowPasswordResetSuccess = false;
-  let shouldShowDeleteConfirmDialog = false;
-  let shouldShowRestoreDialog = false;
-  let selectedUser: UserAdminResponseDto;
-  let newPassword: string;
+  let { data }: Props = $props();
+
+  let allUsers: UserAdminResponseDto[] = $state([]);
+  let shouldShowEditUserForm = $state(false);
+  let shouldShowCreateUserForm = $state(false);
+  let shouldShowPasswordResetSuccess = $state(false);
+  let shouldShowDeleteConfirmDialog = $state(false);
+  let shouldShowRestoreDialog = $state(false);
+  let selectedUser = $state<UserAdminResponseDto>();
+  let newPassword = $state('');
 
   const refresh = async () => {
     allUsers = await searchUsersAdmin({ withDeleted: true });
@@ -117,7 +121,7 @@
         />
       {/if}
 
-      {#if shouldShowEditUserForm}
+      {#if shouldShowEditUserForm && selectedUser}
         <EditUserForm
           user={selectedUser}
           bind:newPassword
@@ -128,7 +132,7 @@
         />
       {/if}
 
-      {#if shouldShowDeleteConfirmDialog}
+      {#if shouldShowDeleteConfirmDialog && selectedUser}
         <DeleteConfirmDialog
           user={selectedUser}
           onSuccess={onUserDelete}
@@ -137,7 +141,7 @@
         />
       {/if}
 
-      {#if shouldShowRestoreDialog}
+      {#if shouldShowRestoreDialog && selectedUser}
         <RestoreDialogue
           user={selectedUser}
           onSuccess={onUserRestore}
@@ -155,7 +159,7 @@
           hideCancelButton={true}
           confirmColor="green"
         >
-          <svelte:fragment slot="prompt">
+          {#snippet promptSnippet()}
             <div class="flex flex-col gap-4">
               <p>{$t('admin.user_password_has_been_reset')}</p>
 
@@ -165,7 +169,7 @@
                 >
                   {newPassword}
                 </code>
-                <LinkButton on:click={() => copyToClipboard(newPassword)} title={$t('copy_password')}>
+                <LinkButton onclick={() => copyToClipboard(newPassword)} title={$t('copy_password')}>
                   <div class="flex place-items-center gap-2 text-sm">
                     <Icon path={mdiContentCopy} size="18" />
                   </div>
@@ -174,7 +178,7 @@
 
               <p>{$t('admin.user_password_reset_description')}</p>
             </div>
-          </svelte:fragment>
+          {/snippet}
         </ConfirmDialog>
       {/if}
 
@@ -223,7 +227,7 @@
                       title={$t('edit_user')}
                       color="primary"
                       size="16"
-                      on:click={() => editUserHandler(immichUser)}
+                      onclick={() => editUserHandler(immichUser)}
                     />
                     {#if immichUser.id !== $user.id}
                       <CircleIconButton
@@ -231,7 +235,7 @@
                         title={$t('delete_user')}
                         color="primary"
                         size="16"
-                        on:click={() => deleteUserHandler(immichUser)}
+                        onclick={() => deleteUserHandler(immichUser)}
                       />
                     {/if}
                   {/if}
@@ -243,7 +247,7 @@
                       })}
                       color="primary"
                       size="16"
-                      on:click={() => restoreUserHandler(immichUser)}
+                      onclick={() => restoreUserHandler(immichUser)}
                     />
                   {/if}
                 </td>
@@ -253,7 +257,7 @@
         </tbody>
       </table>
 
-      <Button size="sm" on:click={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
+      <Button size="sm" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
     </section>
   </section>
 </UserPageLayout>
diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte
index eaf5a88fe2..21226b9387 100644
--- a/web/src/routes/auth/change-password/+page.svelte
+++ b/web/src/routes/auth/change-password/+page.svelte
@@ -8,7 +8,11 @@
   import type { PageData } from './$types';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 
   const onSuccess = async () => {
     await goto(AppRoute.AUTH_LOGIN);
@@ -18,12 +22,14 @@
 </script>
 
 <FullscreenContainer title={data.meta.title}>
-  <p slot="message">
-    {$t('hi_user', { values: { name: $user.name, email: $user.email } })}
-    <br />
-    <br />
-    {$t('change_password_description')}
-  </p>
+  {#snippet message()}
+    <p>
+      {$t('hi_user', { values: { name: $user.name, email: $user.email } })}
+      <br />
+      <br />
+      {$t('change_password_description')}
+    </p>
+  {/snippet}
 
   <ChangePasswordForm {onSuccess} />
 </FullscreenContainer>
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte
index dd0f64c5a8..0ab506f5e3 100644
--- a/web/src/routes/auth/login/+page.svelte
+++ b/web/src/routes/auth/login/+page.svelte
@@ -6,15 +6,21 @@
   import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
   import type { PageData } from './$types';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 </script>
 
 {#if $featureFlags.loaded}
   <FullscreenContainer title={data.meta.title} showMessage={!!$serverConfig.loginPageMessage}>
-    <p slot="message">
-      <!-- eslint-disable-next-line svelte/no-at-html-tags -->
-      {@html $serverConfig.loginPageMessage}
-    </p>
+    {#snippet message()}
+      <p>
+        <!-- eslint-disable-next-line svelte/no-at-html-tags -->
+        {@html $serverConfig.loginPageMessage}
+      </p>
+    {/snippet}
 
     <LoginForm
       onSuccess={async () => await goto(AppRoute.PHOTOS, { invalidateAll: true })}
diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte
index ddb30d1b45..0c8e33e37b 100644
--- a/web/src/routes/auth/onboarding/+page.svelte
+++ b/web/src/routes/auth/onboarding/+page.svelte
@@ -1,4 +1,6 @@
 <script lang="ts">
+  import { run } from 'svelte/legacy';
+
   import { goto } from '$app/navigation';
   import { page } from '$app/stores';
   import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
@@ -9,7 +11,7 @@
   import { retrieveServerConfig } from '$lib/stores/server-config.store';
   import { updateAdminOnboarding } from '@immich/sdk';
 
-  let index = 0;
+  let index = $state(0);
 
   interface OnboardingStep {
     name: string;
@@ -27,11 +29,11 @@
     { name: 'storage', component: OnboadingStorageTemplate },
   ];
 
-  $: {
+  run(() => {
     const stepState = $page.url.searchParams.get('step');
     const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
     index = temporaryIndex >= 0 ? temporaryIndex : 0;
-  }
+  });
 
   const handleDoneClicked = async () => {
     if (index >= onboardingSteps.length - 1) {
@@ -50,6 +52,8 @@
       await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
     }
   };
+
+  const SvelteComponent = $derived(onboardingSteps[index].component);
 </script>
 
 <section id="onboarding-page" class="min-w-screen flex min-h-screen p-4">
@@ -61,11 +65,7 @@
       ></div>
     </div>
     <div class="w-full min-w-screen py-8 flex h-full place-content-center place-items-center">
-      <svelte:component
-        this={onboardingSteps[index].component}
-        onDone={handleDoneClicked}
-        onPrevious={handlePrevious}
-      />
+      <SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} />
     </div>
   </div>
 </section>
diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte
index 9c1f0ca6c4..2e55ba7435 100644
--- a/web/src/routes/auth/register/+page.svelte
+++ b/web/src/routes/auth/register/+page.svelte
@@ -4,13 +4,19 @@
   import type { PageData } from './$types';
   import { t } from 'svelte-i18n';
 
-  export let data: PageData;
+  interface Props {
+    data: PageData;
+  }
+
+  let { data }: Props = $props();
 </script>
 
 <FullscreenContainer title={data.meta.title}>
-  <p slot="message">
-    {$t('admin.registration_description')}
-  </p>
+  {#snippet message()}
+    <p>
+      {$t('admin.registration_description')}
+    </p>
+  {/snippet}
 
   <AdminRegistrationForm />
 </FullscreenContainer>