From ff5b0d532617c13f45cb8a241d88cd49f37876e9 Mon Sep 17 00:00:00 2001
From: Alex <alex.tran1502@gmail.com>
Date: Thu, 14 Nov 2024 08:43:25 -0600
Subject: [PATCH] chore(web): migration svelte 5 syntax (#13883)

---
 web/package-lock.json                         |   6 +-
 web/package.json                              |   6 +-
 .../actions/__test__/focus-trap-test.svelte   |  10 +-
 web/src/lib/actions/autogrow.ts               |   2 +-
 .../lib/actions/context-menu-navigation.ts    |   8 +-
 web/src/lib/actions/list-navigation.ts        |   9 +-
 .../admin-page/delete-confirm-dialogue.svelte |  35 ++-
 .../admin-page/jobs/job-tile-button.svelte    |  18 +-
 .../admin-page/jobs/job-tile-status.svelte    |  13 +-
 .../admin-page/jobs/job-tile.svelte           |  64 ++--
 .../admin-page/jobs/jobs-panel.svelte         |  12 +-
 .../jobs/storage-migration-description.svelte |  15 +-
 .../admin-page/restore-dialogue.svelte        |  22 +-
 .../server-stats/server-stats-panel.svelte    |  22 +-
 .../admin-page/server-stats/stats-card.svelte |  16 +-
 .../admin-page/settings/admin-settings.svelte |  24 +-
 .../settings/auth/auth-settings.svelte        |  79 ++---
 .../backup-settings/backup-settings.svelte    |  57 ++--
 .../settings/ffmpeg/ffmpeg-settings.svelte    |  71 +++--
 .../settings/image/image-settings.svelte      |  41 ++-
 .../settings/job-settings/job-settings.svelte |  31 +-
 .../library-settings/library-settings.svelte  |  65 ++--
 .../logging-settings/logging-settings.svelte  |  22 +-
 .../machine-learning-settings.svelte          |  53 ++--
 .../settings/map-settings/map-settings.svelte |  55 ++--
 .../metadata-settings.svelte                  |  22 +-
 .../new-version-check-settings.svelte         |  22 +-
 .../notification-settings.svelte              |  41 +--
 .../settings/server/server-settings.svelte    |  31 +-
 .../storage-template-settings.svelte          | 168 ++++++-----
 .../supported-datetime-panel.svelte           |   6 +-
 .../settings/theme/theme-settings.svelte      |  24 +-
 .../trash-settings/trash-settings.svelte      |  29 +-
 .../user-settings/user-settings.svelte        |  25 +-
 .../album-page/__tests__/album-card.spec.ts   |  15 +-
 .../album-page/album-card-group.svelte        |  37 ++-
 .../components/album-page/album-card.svelte   |  25 +-
 .../components/album-page/album-cover.svelte  |  17 +-
 .../album-page/album-description.svelte       |  10 +-
 .../album-page/album-options.svelte           |  38 ++-
 .../album-page/album-summary.svelte           |   9 +-
 .../components/album-page/album-title.svelte  |  20 +-
 .../components/album-page/album-viewer.svelte |  22 +-
 .../album-page/albums-controls.svelte         | 102 +++----
 .../components/album-page/albums-list.svelte  |  78 +++--
 .../album-page/albums-table-header.svelte     |  28 +-
 .../album-page/albums-table-row.svelte        |  18 +-
 .../components/album-page/albums-table.svelte |  13 +-
 .../album-page/share-info-modal.svelte        |  20 +-
 .../album-page/user-selection-modal.svelte    |  28 +-
 .../actions/add-to-album-action.svelte        |  12 +-
 .../actions/archive-action.svelte             |   8 +-
 .../asset-viewer/actions/close-action.svelte  |   8 +-
 .../asset-viewer/actions/delete-action.svelte |  12 +-
 .../actions/download-action.svelte            |  10 +-
 .../actions/favorite-action.svelte            |  13 +-
 .../actions/motion-photo-action.svelte        |  10 +-
 .../actions/next-asset-action.svelte          |   6 +-
 .../actions/previous-asset-action.svelte      |   6 +-
 .../actions/restore-action.svelte             |   8 +-
 .../actions/set-album-cover-action.svelte     |   8 +-
 .../actions/set-profile-picture-action.svelte |   8 +-
 .../asset-viewer/actions/share-action.svelte  |  15 +-
 .../actions/show-detail-action.svelte         |   8 +-
 .../actions/unstack-action.svelte             |   8 +-
 .../asset-viewer/activity-status.svelte       |  18 +-
 .../asset-viewer/activity-viewer.svelte       |  97 +++---
 .../album-list-item-details.svelte            |   6 +-
 .../asset-viewer/album-list-item.svelte       |  18 +-
 .../asset-viewer/asset-viewer-nav-bar.svelte  |  61 ++--
 .../asset-viewer/asset-viewer.svelte          | 152 +++++-----
 .../detail-panel-description.svelte           |  14 +-
 .../asset-viewer/detail-panel-location.svelte |  14 +-
 .../detail-panel-star-rating.svelte           |  10 +-
 .../asset-viewer/detail-panel-tags.svelte     |  16 +-
 .../asset-viewer/detail-panel.svelte          |  94 +++---
 .../asset-viewer/download-panel.svelte        |   2 +-
 .../editor/crop-tool/crop-area.svelte         |  26 +-
 .../editor/crop-tool/crop-preset.svelte       |  36 ++-
 .../editor/crop-tool/crop-tool.svelte         |  17 +-
 .../asset-viewer/editor/editor-panel.svelte   |  22 +-
 .../asset-viewer/navigation-area.svelte       |  15 +-
 .../asset-viewer/panorama-viewer.svelte       |  15 +-
 .../photo-sphere-viewer-adapter.svelte        |  20 +-
 .../asset-viewer/photo-viewer.svelte          |  80 +++--
 .../asset-viewer/slideshow-bar.svelte         |  56 ++--
 .../asset-viewer/video-native-viewer.svelte   |  75 +++--
 .../asset-viewer/video-wrapper-viewer.svelte  |  34 ++-
 .../lib/components/assets/broken-asset.svelte |  13 +-
 .../assets/thumbnail/image-thumbnail.svelte   |  80 +++--
 .../assets/thumbnail/thumbnail.svelte         | 131 ++++----
 .../assets/thumbnail/video-thumbnail.svelte   |  70 +++--
 web/src/lib/components/elements/badge.svelte  |  15 +-
 .../components/elements/buttons/button.svelte | 103 +++----
 .../buttons/circle-icon-button.svelte         | 114 +++----
 .../elements/buttons/link-button.svelte       |  29 +-
 .../elements/buttons/skip-link.svelte         |  22 +-
 .../lib/components/elements/checkbox.svelte   |  30 +-
 .../lib/components/elements/date-input.svelte |  30 +-
 .../lib/components/elements/dropdown.svelte   |  44 ++-
 .../lib/components/elements/group-tab.svelte  |  14 +-
 web/src/lib/components/elements/icon.svelte   |  51 +++-
 .../components/elements/radio-button.svelte   |  14 +-
 .../lib/components/elements/search-bar.svelte |  33 +-
 web/src/lib/components/elements/slider.svelte |  34 ++-
 web/src/lib/components/error.svelte           |   8 +-
 .../faces-page/assign-face-side-panel.svelte  |  42 +--
 .../faces-page/edit-name-input.svelte         |  36 ++-
 .../faces-page/face-thumbnail.svelte          |  28 +-
 .../manage-people-visibility.svelte           |  98 +++---
 .../faces-page/merge-face-selector.svelte     |  32 +-
 .../faces-page/merge-suggestion-modal.svelte  |  44 ++-
 .../components/faces-page/people-card.svelte  |  24 +-
 .../faces-page/people-infinite-scroll.svelte  |  27 +-
 .../components/faces-page/people-list.svelte  |  26 +-
 .../faces-page/people-search.svelte           |  52 ++--
 .../faces-page/person-side-panel.svelte       |  48 +--
 .../faces-page/set-birth-date-modal.svelte    |  24 +-
 .../faces-page/unmerge-face-selector.svelte   |  48 +--
 .../forms/admin-registration-form.svelte      |  23 +-
 .../lib/components/forms/api-key-form.svelte  |  36 ++-
 .../components/forms/api-key-secret.svelte    |  16 +-
 .../forms/change-password-form.svelte         |  25 +-
 .../components/forms/create-user-form.svelte  |  56 ++--
 .../components/forms/edit-album-form.svelte   |  32 +-
 .../components/forms/edit-user-form.svelte    |  46 ++-
 .../library-exclusion-pattern-form.svelte     |  48 ++-
 .../forms/library-import-path-form.svelte     |  54 +++-
 .../forms/library-import-paths-form.svelte    |  37 ++-
 .../forms/library-rename-form.svelte          |  19 +-
 .../forms/library-scan-settings-form.svelte   |  33 +-
 .../forms/library-user-picker-form.svelte     |  26 +-
 .../lib/components/forms/login-form.svelte    |  31 +-
 .../components/forms/tag-asset-form.svelte    |  42 ++-
 .../i18n/__test__/format-tag-b.svelte         |  18 +-
 .../i18n/format-bold-message.svelte           |  18 +-
 .../lib/components/i18n/format-message.svelte |  19 +-
 .../layouts/user-page-layout.svelte           |  58 ++--
 .../map-page/map-settings-modal.svelte        |  34 ++-
 .../memory-page/memory-viewer.svelte          |  91 +++---
 .../onboarding-page/onboarding-card.svelte    |  12 +-
 .../onboarding-page/onboarding-hello.svelte   |   8 +-
 .../onboarding-page/onboarding-privacy.svelte |  79 ++---
 .../onboarding-storage-template.svelte        |  87 +++---
 .../onboarding-page/onboarding-theme.svelte   |  12 +-
 .../photos-page/actions/add-to-album.svelte   |  10 +-
 .../photos-page/actions/archive-action.svelte |  19 +-
 .../actions/asset-job-actions.svelte          |  14 +-
 .../actions/change-date-action.svelte         |   8 +-
 .../actions/change-location-action.svelte     |   8 +-
 .../actions/create-shared-link.svelte         |   4 +-
 .../photos-page/actions/delete-assets.svelte  |  20 +-
 .../actions/download-action.svelte            |  12 +-
 .../actions/favorite-action.svelte            |  19 +-
 .../actions/link-live-photo-action.svelte     |  22 +-
 .../actions/remove-from-album.svelte          |  12 +-
 .../actions/remove-from-shared-link.svelte    |   8 +-
 .../photos-page/actions/restore-assets.svelte |  10 +-
 .../actions/select-all-assets.svelte          |  12 +-
 .../photos-page/actions/stack-action.svelte   |  10 +-
 .../photos-page/actions/tag-action.svelte     |  14 +-
 .../components/photos-page/asset-grid.svelte  | 281 ++++++++++--------
 .../asset-select-control-bar.svelte           |  28 +-
 .../photos-page/delete-asset-dialog.svelte    |  22 +-
 .../photos-page/measure-date-group.svelte     |  12 +-
 .../components/photos-page/memory-lane.svelte |  28 +-
 .../components/photos-page/skeleton.svelte    |   8 +-
 .../individual-shared-viewer.svelte           |  32 +-
 .../album-selection-modal.svelte              |  38 +--
 .../autogrow-textarea.svelte                  |  28 +-
 .../shared-components/change-date.spec.ts     |  10 +
 .../shared-components/change-date.svelte      |  64 ++--
 .../shared-components/change-location.svelte  | 192 ++++++------
 .../shared-components/combobox.svelte         |  82 ++---
 .../context-menu/button-context-menu.svelte   |  87 +++---
 .../context-menu/context-menu.svelte          |  49 ++-
 .../context-menu/menu-option.svelte           |  35 ++-
 .../right-click-context-menu.svelte           |  55 ++--
 .../shared-components/control-app-bar.svelte  |  42 ++-
 .../coordinates-input.svelte                  |  10 +-
 .../create-shared-link-modal.svelte           |  68 +++--
 .../dialog/confirm-dialog.svelte              |  53 ++--
 .../drag-and-drop-upload-overlay.svelte       |  45 ++-
 .../empty-placeholder.svelte                  |  18 +-
 .../full-screen-modal.svelte                  |  68 +++--
 .../fullscreen-container.svelte               |  15 +-
 .../gallery-viewer/gallery-viewer.svelte      |  70 +++--
 .../help-and-feedback-modal.svelte            |   7 +-
 .../immich-logo-small-link.svelte             |   6 +-
 .../shared-components/immich-logo.svelte      |   8 +-
 .../shared-components/loading-spinner.svelte  |   6 +-
 .../shared-components/map/map.svelte          | 237 ++++++++-------
 .../shared-components/modal-header.svelte     |  34 ++-
 .../navigation-bar/account-info-panel.svelte  |  18 +-
 .../navigation-bar/avatar-selector.svelte     |  12 +-
 .../navigation-bar/navigation-bar.svelte      |  37 ++-
 .../navigation-loading-bar.svelte             |   2 +-
 .../notification-component-test.svelte        |   6 +-
 .../notification/notification-card.svelte     |  25 +-
 .../number-range-input.svelte                 |  49 ++-
 .../shared-components/password-field.svelte   |  14 +-
 .../shared-components/portal/portal.svelte    |  19 +-
 .../profile-image-cropper.svelte              |  25 +-
 .../progress-bar/progress-bar.svelte          |  60 ++--
 .../purchase-activation-success.svelte        |   8 +-
 .../purchasing/purchase-content.svelte        |  15 +-
 .../purchasing/purchase-modal.svelte          |   8 +-
 .../scrubber/scrubber.svelte                  |  79 +++--
 .../search-bar/search-bar.svelte              |  89 +++---
 .../search-bar/search-camera-section.svelte   |  25 +-
 .../search-bar/search-date-section.svelte     |   8 +-
 .../search-bar/search-display-section.svelte  |   8 +-
 .../search-bar/search-filter-modal.svelte     |  35 ++-
 .../search-bar/search-history-box.svelte      |  57 ++--
 .../search-bar/search-location-section.svelte |  31 +-
 .../search-bar/search-media-section.svelte    |   6 +-
 .../search-bar/search-people-section.svelte   |  16 +-
 .../search-bar/search-text-section.svelte     |   8 +-
 .../server-about-modal.svelte                 |   9 +-
 .../settings/setting-accordion-state.svelte   |  34 ++-
 .../settings/setting-accordion.svelte         |  50 +++-
 .../settings/setting-buttons-row.svelte       |  18 +-
 .../settings/setting-checkboxes.svelte        |  28 +-
 .../settings/setting-combobox.svelte          |  31 +-
 .../settings/setting-dropdown.svelte          |  27 +-
 .../settings/setting-input-field.spec.ts      |   4 +-
 .../settings/setting-input-field.svelte       |  77 +++--
 .../settings/setting-select.svelte            |  34 ++-
 .../settings/setting-switch.svelte            |  32 +-
 .../settings/setting-textarea.svelte          |  36 ++-
 .../shared-components/show-shortcuts.svelte   |  42 +--
 .../side-bar/more-information-albums.svelte   |   6 +-
 .../side-bar/more-information-assets.svelte   |   6 +-
 .../side-bar/purchase-info.svelte             |  54 ++--
 .../side-bar/server-status.svelte             |  19 +-
 .../side-bar/side-bar-link.svelte             |  45 ++-
 .../side-bar/side-bar-section.svelte          |   9 +-
 .../side-bar/side-bar.svelte                  |  40 +--
 .../side-bar/storage-space.svelte             |  18 +-
 .../side-bar/supporter-badge.svelte           |   8 +-
 .../shared-components/single-grid-row.svelte  |  24 +-
 .../shared-components/star-rating.svelte      |  38 ++-
 .../shared-components/theme-button.svelte     |  17 +-
 .../shared-components/tree/breadcrumbs.svelte |  16 +-
 .../tree/tree-item-thumbnails.svelte          |  12 +-
 .../shared-components/tree/tree-items.svelte  |  16 +-
 .../shared-components/tree/tree.svelte        |  42 +--
 .../upload-asset-preview.svelte               |  12 +-
 .../shared-components/upload-panel.svelte     |  30 +-
 .../shared-components/user-avatar.svelte      |  59 ++--
 .../version-announcement-box.svelte           |  43 +--
 .../actions/shared-link-copy.svelte           |  10 +-
 .../actions/shared-link-delete.svelte         |  10 +-
 .../actions/shared-link-edit.svelte           |  10 +-
 .../covers/asset-cover.svelte                 |  17 +-
 .../sharedlinks-page/covers/no-cover.svelte   |  11 +-
 .../covers/share-cover.svelte                 |  11 +-
 .../sharedlinks-page/shared-link-card.svelte  |  14 +-
 .../lib/components/slideshow-settings.svelte  |  22 +-
 .../user-settings-page/app-settings.svelte    |  42 +--
 .../change-password-settings.svelte           |  19 +-
 .../user-settings-page/device-card.svelte     |  10 +-
 .../user-settings-page/device-list.svelte     |  12 +-
 .../download-settings.svelte                  |  19 +-
 .../feature-settings.svelte                   |  24 +-
 .../notifications-settings.svelte             |  14 +-
 .../user-settings-page/oauth-settings.svelte  |  12 +-
 .../partner-selection-modal.svelte            |  18 +-
 .../partner-settings.svelte                   |  14 +-
 .../user-api-key-list.svelte                  |  18 +-
 .../user-profile-settings.svelte              |  14 +-
 .../user-purchase-settings.svelte             |   9 +-
 .../user-settings-list.svelte                 |   8 +-
 .../duplicates/duplicate-asset.svelte         |  20 +-
 .../duplicates-compare-control.svelte         |  29 +-
 web/src/lib/constants.ts                      |  27 ++
 web/src/routes/(user)/+layout.svelte          |  14 +-
 web/src/routes/(user)/albums/+page.svelte     |  22 +-
 .../[[assetId=id]]/+page.svelte               | 236 +++++++--------
 .../[[assetId=id]]/+page.svelte               |  12 +-
 web/src/routes/(user)/buy/+page.svelte        |   8 +-
 web/src/routes/(user)/explore/+page.svelte    |  73 +++--
 .../[[assetId=id]]/+page.svelte               |  12 +-
 .../[[assetId=id]]/+page.svelte               |  48 +--
 .../[[assetId=id]]/+page.svelte               |  22 +-
 .../[[assetId=id]]/+page.svelte               |  10 +-
 web/src/routes/(user)/people/+page.svelte     | 117 ++++----
 .../[[assetId=id]]/+page.svelte               | 143 +++++----
 .../(user)/photos/[[assetId=id]]/+page.svelte |  17 +-
 web/src/routes/(user)/places/+page.svelte     |  12 +-
 .../[[assetId=id]]/+page.svelte               | 119 ++++----
 .../[[assetId=id]]/+page.svelte               |  32 +-
 web/src/routes/(user)/sharing/+page.svelte    |  40 ++-
 .../(user)/sharing/sharedlinks/+page.svelte   |   8 +-
 .../[[assetId=id]]/+page.svelte               | 138 +++++----
 .../[[assetId=id]]/+page.svelte               |  40 ++-
 .../routes/(user)/user-settings/+page.svelte  |  14 +-
 web/src/routes/(user)/utilities/+page.svelte  |   6 +-
 .../[[assetId=id]]/+page.svelte               |  80 ++---
 web/src/routes/+layout.svelte                 |  28 +-
 web/src/routes/admin/jobs-status/+page.svelte |  69 +++--
 .../admin/library-management/+page.svelte     |  68 +++--
 web/src/routes/admin/repair/+page.svelte      |  83 +++---
 .../routes/admin/server-status/+page.svelte   |   6 +-
 .../routes/admin/system-settings/+page.svelte | 122 ++++----
 .../routes/admin/user-management/+page.svelte |  42 +--
 .../routes/auth/change-password/+page.svelte  |  20 +-
 web/src/routes/auth/login/+page.svelte        |  16 +-
 web/src/routes/auth/onboarding/+page.svelte   |  16 +-
 web/src/routes/auth/register/+page.svelte     |  14 +-
 310 files changed, 6441 insertions(+), 4182 deletions(-)

diff --git a/web/package-lock.json b/web/package-lock.json
index 63e7c05ca4995..e9f26bee8067d 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -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,7 +68,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"
       }
     },
diff --git a/web/package.json b/web/package.json
index af5e87c57e55a..c0c600f5bc809 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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 207c880cd9d8f..e1cb6fa4fba79 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 ff80454ef3e84..664039cb2a6bd 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 3b45e7fe527f0..89b7b76d24f4f 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 8f8ed62ed009e..cd4214f700570 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 a2fbbe787a1e9..6eb603263ecec 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 69d3706230de9..f71d8a3e447af 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 ca367647973f4..5bffa45b89eb1 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 81c23e927b684..0e39647c75974 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 67d672d398806..9b4f3ffdd646a 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 8a74d2c5ad0be..b47df1daaec86 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 25afbc6d4b9b6..a72ada2ca5d88 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 35afc0962d414..feab6a9c6d072 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;
+  }
+
+  let {
+    stats = {
+      photos: 0,
+      videos: 0,
+      usage: 0,
+      usageByUser: [],
+    },
+  }: Props = $props();
 
-  $: zeros = (value: number) => {
+  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 31baa0afdd780..14d32c055f23a 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 19a8580d6bb90..199db0b57112a 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 { config = $bindable(), children }: Props = $props();
 
-  let savedConfig: SystemConfigDto;
-  let defaultConfig: SystemConfigDto;
+  let savedConfig: SystemConfigDto | undefined = $state();
+  let defaultConfig: SystemConfigDto | undefined = $state();
 
-  const handleReset = async (options: SettingsResetOptions) => {
+  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 9b0e4b32706b5..7f94dfa253a4d 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 05543f112465e..3ec477e29c645 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 8f5b587ae6bf2..702ec1c1710b9 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 50ae494570bfc..2f2bcbca64276 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 e09fde8baea31..356de6ae860c5 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 b494dca53ffd5..b1012c028720e 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 6e71ba926c79f..29a1c65162359 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 aac8cd52123be..13678a31c1b63 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';
-
-  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;
+  import { SettingInputFieldType } from '$lib/constants';
+
+  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 7c2c5c856aedc..4a4b23ded28a2 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 c28050e0229cb..1ba82b2eb916b 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 76c238df823ba..1a6f0ab8669bd 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 fcd26c684b4ba..28187978f9581 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 f021c99f24a1c..14d5624c5ff73 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 4ebf4ed118d89..74d240a4a6a8a 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';
+
+  interface Props {
+    savedConfig: SystemConfigDto;
+    defaultConfig: SystemConfigDto;
+    config: SystemConfigDto;
+    disabled?: boolean;
+    minified?: boolean;
+    onReset: SettingsResetEvent;
+    onSave: SettingsSaveEvent;
+    duration?: number;
+    children?: Snippet;
+  }
 
-  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;
+  let {
+    savedConfig,
+    defaultConfig,
+    config = $bindable(),
+    disabled = false,
+    minified = false,
+    onReset,
+    onSave,
+    duration = 500,
+    children,
+  }: Props = $props();
 
-  let templateOptions: SystemConfigTemplateStorageOptionDto;
-  let selectedPreset = '';
+  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 renderTemplate = (templateString: string) => {
     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 10f22c18057f3..379e366df60f1 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 84a12e05c9674..ca5b4c934b7bb 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 8f287d48e04ed..05979bf9f037d 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 21453cbc7023c..f96c3808a8059 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 8da9fbfd4591f..9e396bec3ecc8 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 f899cebd8c430..ae2b27efac1da 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 f574c65f0b736..cec4919e4e9d5 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 d0444f35990e2..3f71bbe632c9f 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 b3ad688a30512..46b424f93ae0d 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 3ec1842757201..884de8c2a2b83 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 0277035d5c97d..f2cd23f616469 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 1e69ecf1a3c2d..74786c1ea42f6 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 87b3d8e2c50f1..1dc43c5b61188 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 34563eddd3987..85a7260f40d39 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;
     }
-  }
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: {
-    if (selectedGroupOption.id === AlbumGroupBy.None) {
-      groupIcon = mdiFolderRemoveOutline;
-    } else {
-      groupIcon =
-        $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
-    }
-  }
-
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: sortIcon = $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'),
-    };
-  })();
-
-  // 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'),
-    };
-  })();
+    return $albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
+  });
+
+  let albumFilterNames: Record<AlbumFilter, string> = $derived({
+    [AlbumFilter.All]: $t('all'),
+    [AlbumFilter.Owned]: $t('owned'),
+    [AlbumFilter.Shared]: $t('shared'),
+  });
+
+  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);
+
+  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'),
+  });
+
+  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 3858dd23b7809..178190dc340b4 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';
+
+  interface Props {
+    ownedAlbums?: AlbumResponseDto[];
+    sharedAlbums?: AlbumResponseDto[];
+    searchQuery?: string;
+    userSettings: AlbumViewSettings;
+    allowEdit?: boolean;
+    showOwner?: boolean;
+    albumGroupIds?: string[];
+    empty?: Snippet;
+  }
 
-  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[] = [];
+  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 84e32b82f55e8..4c018f7454b3d 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 3e9027de3dbfd..c900930f8abad 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 d9ffe8595bf11..bd7c7fd7f5a46 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 ee98d5a82186f..778943af3a592 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 ee0a5c7410ab2..fca244ac75425 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 cd4e8091afa0f..ab0da059d0395 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 3e2c453f392e1..6337b278924f7 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 647ad61e4fb10..26cb81edd8061 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 ae5f83c456952..c0f163634a83b 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 88c0eeadf2869..d7f4f563527d7 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 488ed7ecb2d89..0cc3188d518ce 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 fd519a05d470d..f629a42db7b95 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 cc074f3b6c2d9..355f816a6b9f2 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 9f8c638e1220a..1770bc673ab7b 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 c000dad9a1cfe..abcae5c4c919d 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 f20c4872bca47..c015c224ff654 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 23c147815c1f3..a35ff11c48079 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 f0b2177128471..6fd5aa456eeff 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 66e5d0e10ff8e..5613114cad3a5 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 bd18e0e8bffde..f2a50cce1351e 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 fe6ee793630d4..494c6fcbf7de9 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 4f4fdb264961a..34940aee56c9d 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;
+  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;
+  }
 
-  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;
+  let {
+    reactions = $bindable(),
+    user,
+    assetId = undefined,
+    albumId,
+    assetType = undefined,
+    albumOwnerId,
+    disabled,
+    isLiked,
+    onDeleteComment,
+    onDeleteLike,
+    onAddComment,
+    onClose,
+  }: Props = $props();
 
-  $: {
-    if (innerHeight && activityHeight) {
-      divHeight = innerHeight - activityHeight;
-    }
-  }
+  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);
 
-  $: {
-    if (assetId && previousAssetId != assetId) {
-      handlePromiseError(getReactions());
-      previousAssetId = assetId;
-    }
-  }
   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 ecc38b7c24b82..08dd105ca1f6e 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 8e9f6f6b5a736..43352a4904b6f 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 { album, searchQuery = '', onAlbumClick }: Props = $props();
 
-  let albumNameArray: string[] = ['', '', ''];
+  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 1a12a938a9681..8bea15e2a77c1 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
@@ -45,25 +45,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 &&
@@ -89,10 +108,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
@@ -100,11 +119,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}
@@ -123,7 +142,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 e4c2b3aaecb84..e27c7e94c481c 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 zoomToggle = () => void 0;
-  let copyImage: () => Promise<void>;
+  let selectedEditType: string = $state('');
+  let stack: StackResponseDto | null = $state(null);
 
-  $: 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;
@@ -396,6 +389,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 />
@@ -422,11 +437,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}
@@ -443,7 +459,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)}
@@ -461,7 +477,7 @@
             {preloadAssets}
             onPreviousAsset={() => navigateAsset('previous')}
             onNextAsset={() => navigateAsset('next')}
-            on:close={closeViewer}
+            onClose={closeViewer}
             haveFadeTransition={false}
             {sharedLink}
           />
@@ -473,9 +489,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}
@@ -490,8 +506,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()
@@ -507,7 +522,7 @@
               {preloadAssets}
               onPreviousAsset={() => navigateAsset('previous')}
               onNextAsset={() => navigateAsset('next')}
-              on:close={closeViewer}
+              onClose={closeViewer}
               {sharedLink}
               haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
             />
@@ -520,9 +535,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)}
@@ -575,7 +590,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]'
@@ -588,7 +603,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 b916733476618..0eba78b0c0682 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 7d5d86b4435dc..9e59243aa11b5 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 { isOwner, asset = $bindable() }: Props = $props();
 
-  let isShowChangeLocation = false;
+  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 b73fe7171619e..4c5bfd71a839f 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 449f61183fb33..c1175f5eb4a6d 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 ab84896b7b019..9908630233b01 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));
-
-  $: latlng = (() => {
-    const lat = asset.exifInfo?.latitude;
-    const lng = asset.exifInfo?.longitude;
-
-    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 =
+  $effect(() => {
+    handlePromiseError(handleNewAsset(asset));
+  });
+
+  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)) };
+      }
+    })(),
+  );
+
+  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 db46e1eff0bb8..17f5e7e6a8a8b 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 c35fd915197aa..2b7153ed4e51e 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;
+  }
+
+  let { asset }: Props = $props();
 
-  $: imgElement.set(img);
+  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 667191274faaa..eb788b2d16da1 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 dba3be5d671ff..363bec7c1f71c 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 78d5ca26e0ec5..133d9c9021a4f 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 { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
 
-  let selectedType: string = editTypes[0].name;
-  // svelte-ignore reactive_declaration_non_reactive_property
-  $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
+  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 e69d93b6b6339..88f0baf0bc1a8 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 396685e351eaa..b17f9fdea77d2 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 1745cd66b66eb..c18e6bd14bd1e 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 d7595f6b7e849..e24751b3c83fd 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 1acc06f21bb2c..95e08cb310cf8 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 58012ccfce5e5..d019ef273f677 100644
--- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
@@ -4,30 +4,52 @@
   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 = () => {};
+  interface Props {
+    assetId: string;
+    loopVideo: boolean;
+    checksum: string;
+    onPreviousAsset?: () => void;
+    onNextAsset?: () => void;
+    onVideoEnded?: () => void;
+    onVideoStarted?: () => void;
+    onClose?: () => void;
+  }
 
-  let element: HTMLVideoElement | undefined = undefined;
-  let isVideoLoading = true;
-  let assetFileUrl: string;
-  let forceMuted = false;
+  let {
+    assetId,
+    loopVideo,
+    checksum,
+    onPreviousAsset = () => {},
+    onNextAsset = () => {},
+    onVideoEnded = () => {},
+    onVideoStarted = () => {},
+    onClose = () => {},
+  }: Props = $props();
 
-  $: if (element) {
-    assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
-    forceMuted = false;
-    element.load();
-  }
+  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 {
@@ -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 5f03784c42258..3ee4791b073db 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 dd54afba01ee9..31acb832e5d26 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 282d152e90ae2..9d69bdeeb2704 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 4c2cf7451821d..536ea90163973 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;
-  } = {};
-
-  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;
+    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;
+  }
 
-  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 14f99ac3314e5..9188ab9a4f649 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 da305e40f9349..0db6e3fa407f1 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 cdd7463445b48..7e8418e2f5da0 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;
-  };
-
-  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;
+    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;
+  }
 
-  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 8af3f75ade296..4b984154f3b9d 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 b8e81f4469627..a39e2608cfbe0 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 d1ad667379bfc..858d296c30a8c 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 3407262551c08..4595c06bfb313 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 f42fff43596ed..687e9442e7664 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 80689ef1fe33b..b146f347dc836 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 f5e2f79350ba2..021d5ca96fa80 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 5965928718276..4bc55b3247628 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 a3c47e5fbc376..1d110ff644d81 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 7668152d35460..c852be3b68bbe 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 4c19696372084..5c80eb2a9e11a 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 cbc8c26bd8521..54466b5a55047 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 ce184321e3767..b6c9beb43a140 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 d9e961c13f5f6..ebb44c4008d79 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 cce91b466931a..cc3fffe5d7e81 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 90e20a1e5b429..295f629736d7e 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 52daa36a99354..c638691080e16 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 f869790ebab0b..a4ac76f19813d 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 6791a26232e48..a83d1180f954e 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 aefd6fe9578bc..0de084c4b2608 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 { people, hasNextPage = undefined, loadNextPage, children }: Props = $props();
 
-  let lastPersonContainer: HTMLElement | undefined;
+  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 10626a6a93888..511792e536c35 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;
+  interface Props {
+    screenHeight: number;
+    people: PersonResponseDto[];
+    peopleToNotShow: PersonResponseDto[];
+    onSelect: (person: PersonResponseDto) => void;
+  }
+
+  let { screenHeight, people, peopleToNotShow, onSelect }: Props = $props();
 
-  let searchedPeopleLocal: PersonResponseDto[] = [];
+  let searchedPeopleLocal: PersonResponseDto[] = $state([]);
 
-  let name = '';
-  let showPeople: PersonResponseDto[];
+  let name = $state('');
 
-  $: {
-    showPeople = name ? searchedPeopleLocal : people;
-    showPeople = showPeople.filter(
+  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 2a952b8145b25..835f4188c47e3 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 13f356dfc043c..8bbfaaafcf5f1 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 d38c519911dee..f5ecbfabf0af2 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 70a360bea0b0a..06c53f3618031 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 { assetIds, personAssets, onConfirm, onClose, header, merge }: Props = $props();
 
-  let people: PersonResponseDto[] = [];
-  let selectedPerson: PersonResponseDto | null = null;
-  let disableButtons = false;
-  let showLoadingSpinnerCreate = false;
-  let showLoadingSpinnerReassign = false;
-  let hasSelection = false;
-  let screenHeight: number;
+  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);
 
-  $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets];
+  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 d49ab554397c1..b4ecd56283195 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 5b1341db44add..086d7708c3c58 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 dbbefe0d7184c..fd0503e8506e7 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 cbf2ff07f0f00..94dbb5841f07a 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 { onSuccess }: Props = $props();
 
-  let errorMessage: string;
+  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 0687912542067..b1599a24b2099 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 { onClose, onSubmit, onCancel, oauthEnabled = false }: Props = $props();
 
-  let error: string;
-  let success: string;
+  let error = $state('');
+  let success = $state('');
 
-  let email = '';
-  let password = '';
-  let confirmPassword = '';
-  let name = '';
-  let shouldChangePassword = true;
-  let notify = true;
+  let email = $state('');
+  let password = $state('');
+  let confirmPassword = $state('');
+  let name = $state('');
+  let shouldChangePassword = $state(true);
+  let notify = $state(true);
 
-  let canCreateUser = false;
-  let quotaSize: number | undefined;
-  let isCreatingUser = false;
+  let canCreateUser = $state(false);
+  let quotaSize: number | undefined = $state();
+  let isCreatingUser = $state(false);
 
-  $: quotaSizeInBytes = quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null;
-  $: quotaSizeWarning = quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw;
+  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 e33774245b192..bd61cd20684c5 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 0079a695bc3f7..e95ed641355b4 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 05d47c0a0fbee..e79b60d2659bb 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 8bfca80aecb32..33e763f0f0fc3 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 9e7ae11a63b48..3acd46520f314 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 1f93fb028bdea..3f20709474057 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 a9a42c31f7b24..68e99641e8f36 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 e5334ff9e9026..137a49921a707 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 b1af7a01f4b1a..be40210e17e39 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 b5e358ec9664e..84a8c1a40942e 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 122358c6b7a58..6e8b2412e10e2 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 052b220edc615..0381ec5e98881 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 48c59478c6154..b8909e34de2a2 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 ed232b80cda28..9be2db2691e28 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 35df9f2285f1a..270978e120ad5 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 f4715ce57c15f..bca3b2024d6bc 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 9b2378ccd8465..54951dfa09455 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 466e1d29f702f..102465f0196d2 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 da36f741f1619..8ff8a9200dac7 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 69809dd39d1c9..b692a6f2de03e 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 975dbd1ec32b7..4229cf9f678c9 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 8c467644088b3..10917a1d90f39 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 792b80b7026dc..868a5ddd6da7f 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 13b51638f4a52..b383729ecdec7 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 114315348d203..3232cbd2b400d 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 3fe1db4327ae0..0ad93e5d81a40 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 7436ff2177514..1b99627ea9952 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 6d3275c74d594..bdd442e50c3ff 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 7716fbe36d53d..89eca9c6a83f6 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 1d723b1a9dc8c..1bc676415735a 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 24107b9f88c2e..27ac6cf04287a 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 2384f95d2e0a1..19c1e54cfa64b 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 e838f0813d461..e884a929a3874 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 19e1c206fdba9..037e3239ef8ca 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 93df51a6a01cb..cc27f3ebbeb5b 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 c1f2bf212ff50..fe4f066a0e320 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 77e91d7235aa4..32cdaec16a0c7 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 6c534e5116698..775b2a9282667 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;
-
-  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 = () => {};
+    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;
+  }
+
+  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;
-
-  $: if (!lastAssetMouseEvent) {
-    assetInteractionStore.clearAssetSelectionCandidates();
-  }
+  let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
 
-  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 79a0ea75e6736..2ab8f1e9c2e45 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 3eff428a7bb5e..3053600a4718d 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 f458fe40dd84b..80ad7640fb938 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 ed6ef4f3a73d4..3a6ac7e8cf0b1 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 07836eb4dbed2..601a40cce2115 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 1b5368b1336e1..245a90f9f3195 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 65f39ccb162f7..3400864efd61f 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 efbcf218e6c09..5bb4637e05e62 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 { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
 
-  let textarea: HTMLTextAreaElement;
-  $: newContent = content;
+  let textarea: HTMLTextAreaElement | undefined = $state();
+  let newContent = $state(content);
 
-  $: {
-    // re-visit with svelte 5. runes will make this better.
-    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-    newContent;
+  $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 112e900c02890..815acac5abcc8 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 8ceda5f1d6cee..13b2752f0c1d5 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} />
-    </div>
-    <div>
-      <Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
+  <!-- @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>
+  {/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 573c9ab38b5d1..fa050d39c2dcb 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 { asset = undefined, onCancel, onConfirm }: Props = $props();
 
-  let places: PlacesResponseDto[] = [];
-  let suggestedPlaces: PlacesResponseDto[] = [];
-  let searchWord: string;
+  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>
-    </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>
+
+      <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}
-      {: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>
 
-    <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 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>
-  </div>
+  {/snippet}
 </ConfirmDialog>
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index c89d0d34f2d90..b17644f137360 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 f1ee93cc50726..46dc17b9ad6b3 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 {
+    icon,
+    title,
+    align = 'top-left',
+    direction = 'right',
+    color = 'transparent',
+    size = undefined,
+    padding = undefined,
+    buttonClass = undefined,
+    hideContent = false,
+    children,
+  }: Props = $props();
 
-  let isOpen = false;
-  let contextMenuPosition = { x: 0, y: 0 };
-  let menuContainer: HTMLUListElement;
-  let buttonContainer: HTMLDivElement;
+  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 8f5ebfa2cfedc..aff583d1fc575 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 e7ff4c626ee60..5d3c29dc3ce41 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 f0b0408ff9950..f0d8f0213a70e 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 { title, direction = 'right', x = 0, y = 0, isOpen = false, onClose, children }: Props = $props();
 
-  let uniqueKey = {};
-  let menuContainer: HTMLUListElement;
-  let triggerElement: HTMLElement | undefined = undefined;
+  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 228cd88a86e75..c78edaa601060 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 f5ad120a7bec3..d39cea2fd1bd1 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 ea5f801e29829..443e8f06b1d47 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 = () => {};
-
-  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;
+  interface Props {
+    onClose: () => void;
+    albumId?: string | undefined;
+    assetIds?: string[];
+    editingLink?: SharedLinkResponseDto | undefined;
+    onCreated?: () => void;
+  }
+
+  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 50d5fe56ce31f..3efc56dc4166b 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 6f92d81886485..620064ca1e86c 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 781f7821f1f96..922d7ad92fd01 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 ab851552762a2..1263aed03bfba 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 6d577f60bd30a..64ee41a2255cc 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 b595a6bb620d5..aa84bd69f0846 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);
@@ -100,23 +114,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,
-      },
-    );
-
-    return {
-      ...justifiedLayoutResult,
-      containerWidth: calculateWidth(justifiedLayoutResult.boxes),
-    };
-  })();
+  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),
+      };
+    })(),
+  );
 </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 19e12a51f9eea..c122e0f23e8a2 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 9f1dd9714e2d4..cd3149e6de124 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 952960ef3fb9e..7046ea689ee4b 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 48626a50f485a..e81d2225b74d8 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 85d927d166e63..7644064d9dd1e 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} />
-
-    {#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 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);
-          }
+    {#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 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}
+
+      <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 efd87b476cb68..53f3fbdabbb15 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 bf0ca26d6124a..478b43b19055b 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 77a6e3a2d03bc..d762c7ba88971 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 2f8d0e2574aa0..1bbf34316c8d5 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 b6913ae025282..f5879cf0a1e05 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 dfa305a19db18..4dea3709522dc 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 f5e70d856aca3..5054c1869528a 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 2e7dca878129e..6ee993cf88bae 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 e623d08423043..8519f84134b48 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 7a9e577083015..60ccc993af4e6 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 3dabd86d4f489..b8ac86676115d 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 81de8a24a1bb7..0ccb8f9556961 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;
-
-  /**
-   * Progress bar status
-   */
-  export let status: ProgressBarStatus = ProgressBarStatus.Paused;
-
-  export let hidden = false;
-
-  export let duration = 5;
-
-  export let onDone: () => void;
-  export let onPlaying: () => void = () => {};
-  export let onPaused: () => void = () => {};
+  interface Props {
+    /**
+     * Autoplay on mount
+     * @default false
+     */
+    autoplay?: boolean;
+    /**
+     * Progress bar status
+     */
+    status?: ProgressBarStatus;
+    hidden?: boolean;
+    duration?: number;
+    onDone: () => void;
+    onPlaying?: () => void;
+    onPaused?: () => void;
+  }
 
-  const onChange = async () => {
-    progress = setDuration(duration);
+  let {
+    autoplay = false,
+    status = $bindable(),
+    hidden = false,
+    duration = 5,
+    onDone,
+    onPlaying = () => {},
+    onPaused = () => {},
+  }: Props = $props();
+
+  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 3bd462f9976ff..00800ab489a5c 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 0d782f85b34f7..6a4e7f1a4b9db 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 52757bc32a290..0334fb9e99848 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 a55ad1a69cc78..bdcca509bbc30 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 {
+    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 = false;
-  let isDragging = false;
-  let hoverLabel: string | undefined;
+  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 67c3cfe757ddb..d92bd1806c83b 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 3ac8cb8d5aa4f..08ed57d70e744 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 6b661b6c036d7..ea27142074f47 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 00a540306807c..06fa3c5bdfad4 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 3ec539ad976b4..4b53f60b5fff4 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 ca25ef5691ec9..92a2f8847e17f 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;
+  }
+
+  let {
+    id,
+    searchQuery = '',
+    isSearchSuggestions = $bindable(false),
+    isOpen = false,
+    onSelectSearchTerm,
+    onClearSearchTerm,
+    onClearAllSearchTerms,
+    onActiveSelectionChange,
+  }: Props = $props();
+
+  let filteredSearchTerms = $derived(
+    $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())),
+  );
+
+  $effect(() => {
+    isSearchSuggestions = filteredSearchTerms.length > 0;
+  });
 
-  $: filteredSearchTerms = $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase()));
-  $: isSearchSuggestions = filteredSearchTerms.length > 0;
-  $: showClearAll = searchQuery === '';
-  $: suggestionCount = showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length;
+  let showClearAll = $derived(searchQuery === '');
+  let suggestionCount = $derived(showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length);
 
-  let selectedIndex: number | undefined = undefined;
-  let element: HTMLDivElement;
+  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 71912264ed7ac..d68578276c7d4 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 b78868d6146c9..37fa4292ae128 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 0c8d32a1ae0da..8e5059cbbf17b 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 c3145b2f0c16d..2f118e6567704 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 1373a98d3f527..cf935cd3143b3 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 a6257fce297a0..7fbab302d259b 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 d8b50b21320e6..0fe1c9dc14a2e 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 97bcb1d4993be..95edac6dfbae4 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 3def0ce08d0f4..09f0ea438b55c 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 722af048a5d99..5314ad7193d4d 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 20324fe4f8084..57e78e6c6f2fe 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 642492dda5ac7..80cb920074960 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 410adc6458407..1463cc48407b8 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';
+
+  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;
+  }
 
-  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';
+  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;
+  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 92cabbff2534c..44f03075da8c7 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 11716526f85dc..29c1f213d3526 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 5c7b138388ed0..9f9f885263e48 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 2bd1b8976bd17..a3cfd83ad51d6 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 68c58ab155e6d..58ce0c85747a9 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 1da245390b3a5..5e4589be18564 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 a284c7efc1b95..2c4ab8818c201 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 9774c07c637b5..2a0e6a082140f 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 4590b122554aa..d3fd94ae081cb 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 233010153fd92..37867da7affd7 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 fab7c6ed6dce9..54607e1779f9b 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 c62b73e1b2872..c0de9378acaff 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 f2cb326c399ec..3d5e815996a39 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 90020f2922d24..7764b9eb17038 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 { class: className = '', itemCount = $bindable(1), children }: Props = $props();
 
-  let container: HTMLElement | undefined;
-  let contentRect: DOMRectReadOnly | undefined;
+  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 ee1b2b743357a..333248c227ad3 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 f5ba87799b4ef..446668256f98f 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 a3c49a1430c4e..1d841339bc0a5 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 759a3e5e6579e..1b4d30d0500f8 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 4bdc95db9f0f6..c6db9fec8d684 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 5c4b367a5482f..c6a13ec197e80 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 7765e2ce5ce95..bd3b7856d10ef 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 d5360532862b1..7dd6d25596471 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 74750953b5211..938f5695089a7 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 fb5466e7aea87..62e9baf7793d4 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 f955d8479a293..9ec9fc76cea66 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 d458d5d77aced..8c81e736bbfba 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 49c610563218c..0a0c5a4736127 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 bf5031e39d037..76822cc786f4f 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 087204d6a5df0..1e09c6bcfa9bc 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 09f32d7dacebb..6f15cca45fc54 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 13beec0ec077a..70f62475334da 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 6f0397be98f19..723960d9141cb 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 4bfd9a4e0e2cf..63209ca289c7e 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 39fd78e037cf7..b31cbac34f8b5 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 d43977ea08a77..5248a6d119be7 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 26e03c35d8acd..bb202b36061ed 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 f5b94ebee8f2b..97da347fb7c0f 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 d04bbc3e7d3c9..9a60f83647de5 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 275f628f0a552..bec0633964542 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 10e71e64eb467..8dbe3539b8b27 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 8ab747aa276da..070246b612d37 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 050e2c42f3cac..7d575105478a6 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 { user }: Props = $props();
 
-  let createPartnerFlag = false;
+  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 a63bdb3ca9cb6..c5c6bae9e5478 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 { keys = $bindable() }: Props = $props();
 
-  let newKey: { name: string } | null = null;
-  let editKey: ApiKeyResponseDto | null = null;
-  let secret = '';
+  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 1f3b59bfdd8ce..a49eabcf13ffc 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 71f76d07c0a85..240865123498e 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 f355c3105cba2..6f8a0ce4dc270 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 2103250b54da9..19190745d1427 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 fd5b68d8c38a6..6b9bc93c1e9a1 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 9e83baef09b04..db127bbf109ba 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -334,3 +334,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 bf24d0e7e48c9..feda36fa0124e 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 35402ce331d49..29079a48b8bb5 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 9ccb2b7182e7d..2a78b02aa66ee 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,33 @@
   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;
-
-  $: album = data.album;
-  $: albumId = album.id;
-  $: albumKey = `${albumId}_${albumOrder}`;
+  let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
 
-  $: {
-    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 = $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 globalWidth: number = $state(0);
+  let assetGridWidth: number = $derived(isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth);
+  let albumOrder: AssetOrder | undefined = $state(data.album.order);
 
-  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;
 
@@ -171,12 +135,15 @@
 
   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,6 +394,40 @@
     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}>
@@ -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,26 +542,28 @@
                 {$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}
@@ -572,7 +574,7 @@
     >
       <!-- 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 +588,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 +618,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 +641,7 @@
                         color="gray"
                         size="20"
                         icon={mdiDotsVertical}
-                        on:click={() => (viewMode = ViewMode.VIEW_USERS)}
+                        onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}
                       />
                     {/if}
 
@@ -648,7 +650,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 +667,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 +719,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 +750,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 2ce13093513cc..3402dff9600ca 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 1f71269c117d3..eb0194c4472f3 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 ebd2e96b5a1e9..c31c0538e4ef3 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 13e70c9161540..8699582f9ae22 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 2cd3d8c9f3108..255a4373caf65 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 adbc3cfe699a3..613ae4d66bed7 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 2caab9de82508..4332e5339eaec 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 b6d25c48bf937..5b3fbeea03718 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;
-
-  $: 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;
-
+  interface Props {
+    data: PageData;
+  }
+
+  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 d68367d106cb5..d9b7c6a08feba 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;
+  interface Props {
+    data: PageData;
+  }
 
-  let numberOfAssets = data.statistics.assets;
-  let { isViewing: showAssetViewer } = assetViewingStore;
+  let { data = $bindable() }: Props = $props();
 
-  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',
-  }
+  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 b44c58bc76284..7e233fcd17b94 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 28c8e95cb1331..1808755482672 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 4605a2207eb47..0b6fba1613656 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 f4fac282bae76..dfe465f94defd 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 35279a02dbde2..1e59a2720db15 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 67e80f4703858..8ed4ae9fb6a1a 100644
--- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
+++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte
@@ -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();
@@ -53,7 +53,9 @@
 </script>
 
 <ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(AppRoute.SHARING)}>
-  <svelte:fragment slot="leading">{$t('shared_links')}</svelte:fragment>
+  {#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 ce91abb451b8d..c52f0acb9e399 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;
+  }
+
+  let { data }: Props = $props();
 
-  $: pathSegments = data.path ? data.path.split('/') : [];
-  $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
+  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')} />
-    <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>
-
-  <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>
+  {#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>
-      </LinkButton>
-      <LinkButton on:click={handleDelete}>
+      </section>
+    </SideBarSection>
+  {/snippet}
+
+  {#snippet buttons()}
+    <section>
+      <LinkButton onclick={handleCreate}>
         <div class="flex place-items-center gap-2 text-sm">
-          <Icon path={mdiTrashCanOutline} size="18" />
-          <p class="hidden md:block">{$t('delete_tag')}</p>
+          <Icon path={mdiPlus} size="18" />
+          <p class="hidden md:block">{$t('create_tag')}</p>
         </div>
       </LinkButton>
-    {/if}
-  </section>
+
+      {#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>
+  {/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 862d9382a462f..8803ea38c826a 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 4ed46b580fd31..53cc661a30bd8 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 bf18b99436aa9..6713fe4a4b6b5 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 e1029b7ccbfff..fd2bcb438c582 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 8f8bd033eb412..a6e6727d39c3b 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,22 +18,20 @@
   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';
+  interface Props {
+    children?: Snippet;
+  }
 
-  let showNavigationLoadingBar = false;
-  $: changeTheme($colorTheme);
+  let { children }: Props = $props();
 
-  $: if ($user) {
-    openWebsocketConnection();
-  } else {
-    closeWebsocketConnection();
-  }
+  let showNavigationLoadingBar = $state(false);
 
   const changeTheme = (theme: ThemeSetting) => {
     if (theme.system) {
@@ -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 16c2541e61b53..b323a136aad1b 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 6f61572b0edfe..b89e81ebf687d 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 { data }: Props = $props();
 
-  let libraries: LibraryResponseDto[] = [];
+  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 e8cb0649c2d72..9f19fddd03662 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 54f62b3adb753..0aa4c3dd6909d 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 9eb7351060048..6a712450514fd 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,16 +52,20 @@
   } 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) =>
@@ -85,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) {
@@ -94,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));
   };
@@ -227,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}
@@ -248,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, jsonReplacer, 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 80c0169176107..d93a8a5731d73 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 eaf5a88fe20be..21226b9387ee3 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 dd0f64c5a844e..0ab506f5e3ca6 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 ddb30d1b45eff..0c8e33e37b968 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 9c1f0ca6c49d4..2e55ba7435ecd 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>