From 124eb8251bc74eb98f0a4cf9d7a0be7ebc8bab3b Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Fri, 20 Sep 2024 23:02:58 +0200
Subject: [PATCH] chore: migrate away from event dispatcher (#12820)

---
 .../album-page/album-options.svelte           | 16 +++-----
 .../album-page/albums-controls.svelte         |  4 +-
 .../components/album-page/albums-list.svelte  |  6 +--
 .../album-page/share-info-modal.svelte        | 13 +++----
 .../album-page/user-selection-modal.svelte    | 19 ++++------
 .../actions/add-to-album-action.svelte        |  4 +-
 .../asset-viewer/actions/delete-action.svelte |  2 +-
 .../asset-viewer/activity-status.svelte       | 12 ++----
 .../asset-viewer/activity-viewer.svelte       | 21 +++++-----
 .../asset-viewer/album-list-item.svelte       |  9 ++---
 .../asset-viewer/asset-viewer.svelte          | 34 ++++++++---------
 .../asset-viewer/detail-panel-location.svelte |  6 +--
 .../asset-viewer/detail-panel.svelte          | 18 +++------
 .../asset-viewer/slideshow-bar.svelte         |  2 +-
 .../asset-viewer/video-native-viewer.svelte   | 14 +++----
 .../asset-viewer/video-wrapper-viewer.svelte  | 10 +----
 .../lib/components/elements/dropdown.svelte   | 12 ++----
 .../lib/components/elements/search-bar.svelte | 13 +++----
 .../faces-page/assign-face-side-panel.svelte  | 23 ++++-------
 .../faces-page/edit-name-input.svelte         |  8 +---
 .../faces-page/face-thumbnail.svelte          | 12 +-----
 .../faces-page/merge-face-selector.svelte     | 22 ++++-------
 .../faces-page/merge-suggestion-modal.svelte  | 20 ++++------
 .../components/faces-page/people-card.svelte  | 32 ++++------------
 .../components/faces-page/people-list.svelte  | 17 ++-------
 .../faces-page/person-side-panel.svelte       | 27 +++++--------
 .../faces-page/set-birth-date-modal.svelte    | 19 +++-------
 .../faces-page/unmerge-face-selector.svelte   | 21 ++++------
 .../components/forms/api-key-secret.svelte    | 11 ++----
 .../forms/change-password-form.svelte         |  9 ++---
 .../components/forms/create-user-form.svelte  | 11 ++----
 .../components/forms/edit-user-form.svelte    | 13 ++-----
 .../library-exclusion-pattern-form.svelte     | 20 ++++------
 .../forms/library-import-path-form.svelte     | 20 ++++------
 .../forms/library-import-paths-form.svelte    | 33 +++++-----------
 .../forms/library-rename-form.svelte          | 19 ++--------
 .../forms/library-scan-settings-form.svelte   | 34 +++++------------
 .../forms/library-user-picker-form.svelte     | 19 +++-------
 .../layouts/user-page-layout.svelte           |  2 +-
 .../map-page/map-settings-modal.svelte        | 17 +++------
 .../memory-page/memory-viewer.svelte          |  2 +-
 .../photos-page/actions/add-to-album.svelte   |  4 +-
 .../actions/change-date-action.svelte         |  6 +--
 .../actions/change-location-action.svelte     |  5 +--
 .../photos-page/actions/delete-assets.svelte  |  4 +-
 .../photos-page/asset-date-group.svelte       | 16 ++++----
 .../components/photos-page/asset-grid.svelte  | 32 ++++++++--------
 .../asset-select-control-bar.svelte           |  2 +-
 .../photos-page/delete-asset-dialog.svelte    | 12 ++----
 .../album-selection-modal.svelte              | 24 ++++--------
 .../shared-components/change-date.svelte      | 14 ++-----
 .../shared-components/change-location.svelte  | 38 +++++--------------
 .../shared-components/combobox.svelte         | 17 ++++-----
 .../shared-components/control-app-bar.svelte  |  9 ++---
 .../create-shared-link-modal.svelte           |  8 +---
 .../gallery-viewer/gallery-viewer.svelte      |  6 +--
 .../shared-components/map/map.svelte          | 14 +++----
 .../navigation-bar/account-info-panel.svelte  | 22 +++--------
 .../navigation-bar/navigation-bar.svelte      | 11 ++----
 .../progress-bar/progress-bar.svelte          | 18 ++++-----
 .../settings/setting-dropdown.svelte          |  2 +-
 .../settings/setting-select.svelte            |  6 +--
 .../settings/setting-switch.svelte            |  5 +--
 .../user-api-key-list.svelte                  |  2 +-
 .../duplicates-compare-control.svelte         |  6 +--
 .../[[assetId=id]]/+page.svelte               | 26 ++++++-------
 .../[[assetId=id]]/+page.svelte               | 14 +++----
 web/src/routes/(user)/people/+page.svelte     | 18 ++++-----
 .../[[assetId=id]]/+page.svelte               | 18 ++++-----
 .../admin/library-management/+page.svelte     | 21 +++-------
 .../routes/admin/user-management/+page.svelte |  8 ++--
 .../routes/auth/change-password/+page.svelte  |  2 +-
 72 files changed, 360 insertions(+), 656 deletions(-)

diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte
index 84a2873788..ebcf835649 100644
--- a/web/src/lib/components/album-page/album-options.svelte
+++ b/web/src/lib/components/album-page/album-options.svelte
@@ -2,7 +2,6 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
   import { mdiArrowDownThin, mdiArrowUpThin, mdiPlus } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
@@ -16,6 +15,9 @@
   export let order: AssetOrder | undefined;
   export let user: UserResponseDto;
   export let onChangeOrder: (order: AssetOrder) => void;
+  export let onClose: () => void;
+  export let onToggleEnabledActivity: () => void;
+  export let onShowSelectSharedUser: () => void;
 
   const options: Record<AssetOrder, RenderedOption> = {
     [AssetOrder.Asc]: { icon: mdiArrowUpThin, title: $t('oldest_first') },
@@ -24,12 +26,6 @@
 
   $: selectedOption = order ? options[order] : options[AssetOrder.Desc];
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-    toggleEnableActivity: void;
-    showSelectSharedUser: void;
-  }>();
-
   const handleToggle = async (returnedOption: RenderedOption) => {
     if (selectedOption === returnedOption) {
       return;
@@ -51,7 +47,7 @@
   };
 </script>
 
-<FullScreenModal title={$t('options')} onClose={() => dispatch('close')}>
+<FullScreenModal title={$t('options')} {onClose}>
   <div class="items-center justify-center">
     <div class="py-2">
       <h2 class="text-gray text-sm mb-2">{$t('settings').toUpperCase()}</h2>
@@ -68,14 +64,14 @@
           title={$t('comments_and_likes')}
           subtitle={$t('let_others_respond')}
           checked={album.isActivityEnabled}
-          on:toggle={() => dispatch('toggleEnableActivity')}
+          onToggle={onToggleEnabledActivity}
         />
       </div>
     </div>
     <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={() => dispatch('showSelectSharedUser')}>
+        <button type="button" class="flex items-center gap-2" on:click={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/albums-controls.svelte b/web/src/lib/components/album-page/albums-controls.svelte
index ae8178a805..14faee5e7f 100644
--- a/web/src/lib/components/album-page/albums-controls.svelte
+++ b/web/src/lib/components/album-page/albums-controls.svelte
@@ -154,7 +154,7 @@
   title={$t('sort_albums_by')}
   options={Object.values(sortOptionsMetadata)}
   selectedOption={selectedSortOption}
-  on:select={({ detail }) => handleChangeSortBy(detail)}
+  onSelect={handleChangeSortBy}
   render={({ id }) => ({
     title: albumSortByNames[id],
     icon: sortIcon,
@@ -166,7 +166,7 @@
   title={$t('group_albums_by')}
   options={Object.values(groupOptionsMetadata)}
   selectedOption={selectedGroupOption}
-  on:select={({ detail }) => handleChangeGroupBy(detail)}
+  onSelect={handleChangeGroupBy}
   render={({ id, isDisabled }) => ({
     title: albumGroupByNames[id],
     icon: groupIcon,
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte
index 5e3499bd10..a13e37c2b5 100644
--- a/web/src/lib/components/album-page/albums-list.svelte
+++ b/web/src/lib/components/album-page/albums-list.svelte
@@ -394,13 +394,13 @@
       <CreateSharedLinkModal
         albumId={albumToShare.id}
         onClose={() => closeShareModal()}
-        on:created={() => albumToShare && handleSharedLinkCreated(albumToShare)}
+        onCreated={() => albumToShare && handleSharedLinkCreated(albumToShare)}
       />
     {:else}
       <UserSelectionModal
         album={albumToShare}
-        on:select={({ detail: users }) => handleAddUsers(users)}
-        on:share={() => (showShareByURLModal = true)}
+        onSelect={handleAddUsers}
+        onShare={() => (showShareByURLModal = true)}
         onClose={() => closeShareModal()}
       />
     {/if}
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 c9ac224992..ee98d5a821 100644
--- a/web/src/lib/components/album-page/share-info-modal.svelte
+++ b/web/src/lib/components/album-page/share-info-modal.svelte
@@ -8,7 +8,7 @@
     AlbumUserRole,
   } from '@immich/sdk';
   import { mdiDotsVertical } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { handleError } from '../../utils/handle-error';
   import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
@@ -20,11 +20,8 @@
 
   export let album: AlbumResponseDto;
   export let onClose: () => void;
-
-  const dispatch = createEventDispatcher<{
-    remove: string;
-    refreshAlbum: void;
-  }>();
+  export let onRemove: (userId: string) => void;
+  export let onRefreshAlbum: () => void;
 
   let currentUser: UserResponseDto;
   let selectedRemoveUser: UserResponseDto | null = null;
@@ -52,7 +49,7 @@
 
     try {
       await removeUserFromAlbum({ id: album.id, userId });
-      dispatch('remove', userId);
+      onRemove(userId);
       const message =
         userId === 'me'
           ? $t('album_user_left', { values: { album: album.albumName } })
@@ -71,7 +68,7 @@
       const message = $t('user_role_set', {
         values: { user: user.name, role: role == AlbumUserRole.Viewer ? $t('role_viewer') : $t('role_editor') },
       });
-      dispatch('refreshAlbum');
+      onRefreshAlbum();
       notificationController.show({ type: NotificationType.Info, message });
     } catch (error) {
       handleError(error, $t('errors.unable_to_change_album_user_role'));
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 5521d52173..ee0a5c7410 100644
--- a/web/src/lib/components/album-page/user-selection-modal.svelte
+++ b/web/src/lib/components/album-page/user-selection-modal.svelte
@@ -13,13 +13,16 @@
     type UserResponseDto,
   } from '@immich/sdk';
   import { mdiCheck, mdiEye, mdiLink, mdiPencil, mdiShareCircle } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import { t } from 'svelte-i18n';
 
   export let album: AlbumResponseDto;
   export let onClose: () => void;
+  export let onSelect: (selectedUsers: AlbumUserAddDto[]) => void;
+  export let onShare: () => void;
+
   let users: UserResponseDto[] = [];
   let selectedUsers: Record<string, { user: UserResponseDto; role: AlbumUserRole }> = {};
 
@@ -29,10 +32,6 @@
     { title: $t('remove_user'), value: 'none' },
   ];
 
-  const dispatch = createEventDispatcher<{
-    select: AlbumUserAddDto[];
-    share: void;
-  }>();
   let sharedLinks: SharedLinkResponseDto[] = [];
   onMount(async () => {
     await getSharedLinks();
@@ -99,7 +98,7 @@
                 title={$t('role')}
                 options={roleOptions}
                 render={({ title, icon }) => ({ title, icon })}
-                on:select={({ detail: { value } }) => handleChangeRole(user, value)}
+                onSelect={({ value }) => handleChangeRole(user, value)}
               />
             </div>
           {/key}
@@ -152,10 +151,8 @@
         rounded="full"
         disabled={Object.keys(selectedUsers).length === 0}
         on:click={() =>
-          dispatch(
-            'select',
-            Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
-          )}>{$t('add')}</Button
+          onSelect(Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })))}
+        >{$t('add')}</Button
       >
     </div>
   {/if}
@@ -166,7 +163,7 @@
     <button
       type="button"
       class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
-      on:click={() => dispatch('share')}
+      on:click={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 15d3b6accc..cd4e8091af 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
@@ -40,8 +40,8 @@
   <Portal target="body">
     <AlbumSelectionModal
       {shared}
-      on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
-      on:album={({ detail }) => handleAddToAlbum(detail)}
+      onNewAlbum={handleAddToNewAlbum}
+      onAlbumClick={handleAddToAlbum}
       onClose={() => (showSelectionModal = false)}
     />
   </Portal>
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 1e3cfdd28d..ae5f83c456 100644
--- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte
+++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte
@@ -82,6 +82,6 @@
 
 {#if showConfirmModal}
   <Portal target="body">
-    <DeleteAssetDialog size={1} on:cancel={() => (showConfirmModal = false)} on:confirm={() => deleteAsset()} />
+    <DeleteAssetDialog size={1} onCancel={() => (showConfirmModal = false)} onConfirm={deleteAsset} />
   </Portal>
 {/if}
diff --git a/web/src/lib/components/asset-viewer/activity-status.svelte b/web/src/lib/components/asset-viewer/activity-status.svelte
index 8646570fec..fe6ee79363 100644
--- a/web/src/lib/components/asset-viewer/activity-status.svelte
+++ b/web/src/lib/components/asset-viewer/activity-status.svelte
@@ -2,26 +2,22 @@
   import { locale } from '$lib/stores/preferences.store';
   import type { ActivityResponseDto } from '@immich/sdk';
   import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import Icon from '../elements/icon.svelte';
 
   export let isLiked: ActivityResponseDto | null;
   export let numberOfComments: number | undefined;
   export let disabled: boolean;
-
-  const dispatch = createEventDispatcher<{
-    openActivityTab: void;
-    favorite: void;
-  }>();
+  export let onOpenActivityTab: () => void;
+  export let onFavorite: () => void;
 </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={() => dispatch('favorite')} {disabled}>
+  <button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={onFavorite} {disabled}>
     <div class="items-center justify-center">
       <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
     </div>
   </button>
-  <button type="button" on:click={() => dispatch('openActivityTab')}>
+  <button type="button" on:click={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 050802e1d0..3a02454315 100644
--- a/web/src/lib/components/asset-viewer/activity-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte
@@ -17,7 +17,7 @@
   } from '@immich/sdk';
   import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js';
   import * as luxon from 'luxon';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
@@ -55,6 +55,10 @@
   export let albumOwnerId: string;
   export let disabled: boolean;
   export let isLiked: ActivityResponseDto | null;
+  export let onDeleteComment: () => void;
+  export let onDeleteLike: () => void;
+  export let onAddComment: () => void;
+  export let onClose: () => void;
 
   let textArea: HTMLTextAreaElement;
   let innerHeight: number;
@@ -65,13 +69,6 @@
   let message = '';
   let isSendingMessage = false;
 
-  const dispatch = createEventDispatcher<{
-    deleteComment: void;
-    deleteLike: void;
-    addComment: void;
-    close: void;
-  }>();
-
   $: {
     if (innerHeight && activityHeight) {
       divHeight = innerHeight - activityHeight;
@@ -111,9 +108,9 @@
       reactions.splice(index, 1);
       reactions = reactions;
       if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
-        dispatch('deleteLike');
+        onDeleteLike();
       } else {
-        dispatch('deleteComment');
+        onDeleteComment();
       }
 
       const deleteMessages: Record<ReactionType, string> = {
@@ -141,7 +138,7 @@
       reactions.push(data);
       textArea.style.height = '18px';
       message = '';
-      dispatch('addComment');
+      onAddComment();
       // Re-render the activity feed
       reactions = reactions;
     } catch (error) {
@@ -160,7 +157,7 @@
       bind:clientHeight={activityHeight}
     >
       <div class="flex place-items-center gap-2">
-        <CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} title={$t('close')} />
+        <CircleIconButton on:click={onClose} icon={mdiClose} title={$t('close')} />
 
         <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
       </div>
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 ab049da652..8e9f6f6b5a 100644
--- a/web/src/lib/components/asset-viewer/album-list-item.svelte
+++ b/web/src/lib/components/asset-viewer/album-list-item.svelte
@@ -1,16 +1,13 @@
 <script lang="ts">
   import { getAssetThumbnailUrl } from '$lib/utils';
   import { type AlbumResponseDto } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import { normalizeSearchString } from '$lib/utils/string-utils.js';
   import AlbumListItemDetails from './album-list-item-details.svelte';
 
-  const dispatch = createEventDispatcher<{
-    album: void;
-  }>();
-
   export let album: AlbumResponseDto;
   export let searchQuery = '';
+  export let onAlbumClick: () => void;
+
   let albumNameArray: string[] = ['', '', ''];
 
   // This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
@@ -29,7 +26,7 @@
 
 <button
   type="button"
-  on:click={() => dispatch('album')}
+  on:click={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.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index 69d35b9aa4..850a7c159f 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -32,7 +32,7 @@
     type AssetResponseDto,
     type StackResponseDto,
   } from '@immich/sdk';
-  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import { fly } from 'svelte/transition';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
@@ -56,8 +56,10 @@
   export let isShared = false;
   export let album: AlbumResponseDto | null = null;
   export let onAction: OnAction | undefined = undefined;
-
-  let reactions: ActivityResponseDto[] = [];
+  export let reactions: ActivityResponseDto[] = [];
+  export let onClose: (dto: { asset: AssetResponseDto }) => void;
+  export let onNext: () => void;
+  export let onPrevious: () => void;
 
   const { setAsset } = assetViewingStore;
   const {
@@ -67,13 +69,6 @@
     slideshowState,
   } = slideshowStore;
 
-  const dispatch = createEventDispatcher<{
-    action: { type: AssetAction; asset: AssetResponseDto };
-    close: { asset: AssetResponseDto };
-    next: void;
-    previous: void;
-  }>();
-
   let appearsInAlbums: AlbumResponseDto[] = [];
   let shouldPlayMotionPhoto = false;
   let sharedLink = getSharedLink();
@@ -267,7 +262,7 @@
   };
 
   const closeViewer = () => {
-    dispatch('close', { asset });
+    onClose({ asset });
   };
 
   const closeEditor = () => {
@@ -316,7 +311,8 @@
     }
 
     e?.stopPropagation();
-    dispatch(order);
+    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+    order === 'previous' ? onPrevious() : onNext();
   };
 
   // const showEditorHandler = () => {
@@ -533,8 +529,8 @@
               disabled={!album?.isActivityEnabled}
               {isLiked}
               {numberOfComments}
-              on:favorite={handleFavorite}
-              on:openActivityTab={handleOpenActivity}
+              onFavorite={handleFavorite}
+              onOpenActivityTab={handleOpenActivity}
             />
           </div>
         {/if}
@@ -555,7 +551,7 @@
       class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
       translate="yes"
     >
-      <DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} on:close={() => ($isShowDetail = false)} />
+      <DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
     </div>
   {/if}
 
@@ -625,10 +621,10 @@
         assetId={asset.id}
         {isLiked}
         bind:reactions
-        on:addComment={handleAddComment}
-        on:deleteComment={handleRemoveComment}
-        on:deleteLike={() => (isLiked = null)}
-        on:close={() => (isShowActivity = false)}
+        onAddComment={handleAddComment}
+        onDeleteComment={handleRemoveComment}
+        onDeleteLike={() => (isLiked = null)}
+        onClose={() => (isShowActivity = false)}
       />
     </div>
   {/if}
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 a93c90d0d4..7d5d86b443 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte
@@ -81,10 +81,6 @@
 
 {#if isShowChangeLocation}
   <Portal>
-    <ChangeLocation
-      {asset}
-      on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
-      on:cancel={() => (isShowChangeLocation = false)}
-    />
+    <ChangeLocation {asset} onConfirm={handleConfirmChangeLocation} onCancel={() => (isShowChangeLocation = false)} />
   </Portal>
 {/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 340007d206..ee2da9fc3f 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -36,7 +36,6 @@
     mdiPencil,
   } from '@mdi/js';
   import { DateTime } from 'luxon';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import { slide } from 'svelte/transition';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
@@ -49,6 +48,7 @@
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
   export let currentAlbum: AlbumResponseDto | null = null;
+  export let onClose: () => void;
 
   const getDimensions = (exifInfo: ExifResponseDto) => {
     const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
@@ -106,10 +106,6 @@
       ? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
       : fromLocalDateTime(asset.localDateTime);
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-  }>();
-
   const getMegapixel = (width: number, height: number): number | undefined => {
     const megapixel = Math.round((height * width) / 1_000_000);
 
@@ -144,7 +140,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={() => dispatch('close')} />
+    <CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
     <p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
   </div>
 
@@ -332,8 +328,8 @@
       <ChangeDate
         initialDate={dateTime}
         initialTimeZone={timeZone ?? ''}
-        on:confirm={({ detail: date }) => handleConfirmChangeDate(date)}
-        on:cancel={() => (isShowChangeDate = false)}
+        onConfirm={handleConfirmChangeDate}
+        onCancel={() => (isShowChangeDate = false)}
       />
     {/if}
 
@@ -511,9 +507,7 @@
   <PersonSidePanel
     assetId={asset.id}
     assetType={asset.type}
-    on:close={() => {
-      showEditFaces = false;
-    }}
-    on:refresh={handleRefreshPeople}
+    onClose={() => (showEditFaces = false)}
+    onRefresh={handleRefreshPeople}
   />
 {/if}
diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
index 63e501f6dd..1acc06f21b 100644
--- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte
+++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte
@@ -139,5 +139,5 @@
   duration={$slideshowDelay}
   bind:this={progressBar}
   bind:status={progressBarStatus}
-  on:done={handleDone}
+  onDone={handleDone}
 />
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 e0adcdfc9d..d44cb94bfb 100644
--- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte
@@ -4,7 +4,7 @@
   import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
   import { handleError } from '$lib/utils/handle-error';
   import { AssetMediaSize } from '@immich/sdk';
-  import { createEventDispatcher, tick } from 'svelte';
+  import { tick } from 'svelte';
   import { swipe } from 'svelte-gestures';
   import type { SwipeCustomEvent } from 'svelte-gestures';
   import { fade } from 'svelte/transition';
@@ -13,8 +13,10 @@
   export let assetId: string;
   export let loopVideo: boolean;
   export let checksum: string;
-  export let onPreviousAsset: () => void;
-  export let onNextAsset: () => void;
+  export let onPreviousAsset: () => void = () => {};
+  export let onNextAsset: () => void = () => {};
+  export let onVideoEnded: () => void = () => {};
+  export let onVideoStarted: () => void = () => {};
 
   let element: HTMLVideoElement | undefined = undefined;
   let isVideoLoading = true;
@@ -27,12 +29,10 @@
     element.load();
   }
 
-  const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
-
   const handleCanPlay = async (video: HTMLVideoElement) => {
     try {
       await video.play();
-      dispatch('onVideoStarted');
+      onVideoStarted();
     } catch (error) {
       if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) {
         await tryForceMutedPlay(video);
@@ -75,7 +75,7 @@
     use:swipe
     on:swipe={onSwipe}
     on:canplay={(e) => handleCanPlay(e.currentTarget)}
-    on:ended={() => dispatch('onVideoEnded')}
+    on:ended={onVideoEnded}
     on:volumechange={(e) => {
       if (!forceMuted) {
         $videoViewerMuted = e.currentTarget.muted;
diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
index 5f03784c42..ae9fda8c69 100644
--- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte
@@ -15,13 +15,5 @@
 {#if projectionType === ProjectionType.EQUIRECTANGULAR}
   <PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
 {:else}
-  <VideoNativeViewer
-    {loopVideo}
-    {checksum}
-    {assetId}
-    {onPreviousAsset}
-    {onNextAsset}
-    on:onVideoEnded
-    on:onVideoStarted
-  />
+  <VideoNativeViewer {loopVideo} {checksum} {assetId} {onPreviousAsset} {onNextAsset} />
 {/if}
diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte
index d98fb517af..0016bbe538 100644
--- a/web/src/lib/components/elements/dropdown.svelte
+++ b/web/src/lib/components/elements/dropdown.svelte
@@ -19,22 +19,18 @@
   import LinkButton from './buttons/link-button.svelte';
   import { clickOutside } from '$lib/actions/click-outside';
   import { fly } from 'svelte/transition';
-  import { createEventDispatcher } from 'svelte';
 
   let className = '';
   export { className as class };
 
-  const dispatch = createEventDispatcher<{
-    select: T;
-    'click-outside': void;
-  }>();
-
   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;
 
@@ -43,11 +39,11 @@
       showMenu = false;
     }
 
-    dispatch('click-outside');
+    onClickOutside();
   };
 
   const handleSelectOption = (option: T) => {
-    dispatch('select', option);
+    onSelect(option);
     selectedOption = option;
 
     showMenu = false;
diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
index 686e5691ed..7668152d35 100644
--- a/web/src/lib/components/elements/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -1,6 +1,5 @@
 <script lang="ts">
   import { mdiClose, mdiMagnify } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import type { SearchOptions } from '$lib/utils/dipatch';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
@@ -10,20 +9,20 @@
   export let roundedBottom = true;
   export let showLoadingSpinner: boolean;
   export let placeholder: string;
+  export let onSearch: (options: SearchOptions) => void = () => {};
+  export let onReset: () => void = () => {};
 
   let inputRef: HTMLElement;
 
-  const dispatch = createEventDispatcher<{ search: SearchOptions; reset: void }>();
-
   const resetSearch = () => {
     name = '';
-    dispatch('reset');
+    onReset();
     inputRef?.focus();
   };
 
   const handleSearch = (event: KeyboardEvent) => {
     if (event.key === 'Enter') {
-      dispatch('search', { force: true });
+      onSearch({ force: true });
     }
   };
 </script>
@@ -38,7 +37,7 @@
     title={$t('search')}
     size="16"
     padding="2"
-    on:click={() => dispatch('search', { force: true })}
+    on:click={() => onSearch({ force: true })}
   />
   <input
     class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
@@ -47,7 +46,7 @@
     bind:value={name}
     bind:this={inputRef}
     on:keydown={handleSearch}
-    on:input={() => dispatch('search', { force: false })}
+    on:input={() => onSearch({ force: false })}
   />
   {#if showLoadingSpinner}
     <div class="flex place-items-center">
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 eba26e6e61..ce184321e3 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
@@ -4,7 +4,6 @@
   import { getPeopleThumbnailUrl } from '$lib/utils';
   import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
   import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import { linear } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import { photoViewer } from '$lib/stores/assets.store';
@@ -19,6 +18,9 @@
   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;
 
   // loading spinners
   let isShowLoadingNewPerson = false;
@@ -31,25 +33,16 @@
 
   $: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-    createPerson: string | null;
-    reassign: PersonResponseDto;
-  }>();
-  const handleBackButton = () => {
-    dispatch('close');
-  };
-
   const handleCreatePerson = async () => {
     const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
 
     const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer);
 
-    dispatch('createPerson', newFeaturePhoto);
+    onCreatePerson(newFeaturePhoto);
 
     clearTimeout(timeout);
     isShowLoadingNewPerson = false;
-    dispatch('createPerson', newFeaturePhoto);
+    onCreatePerson(newFeaturePhoto);
   };
 </script>
 
@@ -60,7 +53,7 @@
   <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={handleBackButton} />
+        <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={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">
@@ -80,7 +73,7 @@
         {/if}
       </div>
     {:else}
-      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} />
       <div class="w-full flex">
         <SearchPeople
           type="input"
@@ -103,7 +96,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={() => dispatch('reassign', person)}>
+            <button type="button" class="w-[90px]" on:click={() => 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 f48a3de15a..d9e961c13f 100644
--- a/web/src/lib/components/faces-page/edit-name-input.svelte
+++ b/web/src/lib/components/faces-page/edit-name-input.svelte
@@ -1,6 +1,5 @@
 <script lang="ts">
   import { type PersonResponseDto } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
@@ -11,10 +10,7 @@
   export let suggestedPeople: PersonResponseDto[];
   export let thumbnailData: string;
   export let isSearchingPeople: boolean;
-
-  const dispatch = createEventDispatcher<{
-    change: string;
-  }>();
+  export let onChange: (name: string) => void;
 </script>
 
 <div
@@ -26,7 +22,7 @@
   <form
     class="ml-4 flex w-full justify-between gap-16"
     autocomplete="off"
-    on:submit|preventDefault={() => dispatch('change', name)}
+    on:submit|preventDefault={() => onChange(name)}
   >
     <SearchPeople
       bind:searchName={name}
diff --git a/web/src/lib/components/faces-page/face-thumbnail.svelte b/web/src/lib/components/faces-page/face-thumbnail.svelte
index 58e1e0e39b..f30029ac8f 100644
--- a/web/src/lib/components/faces-page/face-thumbnail.svelte
+++ b/web/src/lib/components/faces-page/face-thumbnail.svelte
@@ -1,7 +1,6 @@
 <script lang="ts">
   import { getPeopleThumbnailUrl } from '$lib/utils';
   import { type PersonResponseDto } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
 
   export let person: PersonResponseDto;
@@ -10,20 +9,13 @@
   export let thumbnailSize: number | null = null;
   export let circle = false;
   export let border = false;
-
-  let dispatch = createEventDispatcher<{
-    click: PersonResponseDto;
-  }>();
-
-  const handleOnClicked = () => {
-    dispatch('click', person);
-  };
+  export let onClick: (person: PersonResponseDto) => void = () => {};
 </script>
 
 <button
   type="button"
   class="relative rounded-lg transition-all"
-  on:click={handleOnClicked}
+  on:click={() => 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/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte
index 75f3420424..9cb3078ec0 100644
--- a/web/src/lib/components/faces-page/merge-face-selector.svelte
+++ b/web/src/lib/components/faces-page/merge-face-selector.svelte
@@ -6,7 +6,7 @@
   import { handleError } from '$lib/utils/handle-error';
   import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
   import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { flip } from 'svelte/animate';
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
@@ -20,15 +20,13 @@
   import { t } from 'svelte-i18n';
 
   export let person: PersonResponseDto;
+  export let onBack: () => void;
+  export let onMerge: (mergedPerson: PersonResponseDto) => void;
+
   let people: PersonResponseDto[] = [];
   let selectedPeople: PersonResponseDto[] = [];
   let screenHeight: number;
 
-  let dispatch = createEventDispatcher<{
-    back: void;
-    merge: PersonResponseDto;
-  }>();
-
   $: hasSelection = selectedPeople.length > 0;
   $: peopleToNotShow = [...selectedPeople, person];
 
@@ -37,10 +35,6 @@
     people = data.people;
   });
 
-  const onClose = () => {
-    dispatch('back');
-  };
-
   const handleSwapPeople = async () => {
     [person, selectedPeople[0]] = [selectedPeople[0], person];
     $page.url.searchParams.set(QueryParameter.ACTION, ActionQueryParameterValue.MERGE);
@@ -88,7 +82,7 @@
         message: $t('merged_people_count', { values: { count } }),
         type: NotificationType.Info,
       });
-      dispatch('merge', mergedPerson);
+      onMerge(mergedPerson);
     } catch (error) {
       handleError(error, $t('cannot_merge_people'));
     }
@@ -101,7 +95,7 @@
   transition:fly={{ y: 500, duration: 100, easing: quintOut }}
   class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
 >
-  <ControlAppBar on:close={onClose}>
+  <ControlAppBar onClose={onBack}>
     <svelte:fragment slot="leading">
       {#if hasSelection}
         {$t('selected_count', { values: { count: selectedPeople.length } })}
@@ -125,7 +119,7 @@
         <div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
           {#each selectedPeople as person (person.id)}
             <div animate:flip={{ duration: 250, easing: quintOut }}>
-              <FaceThumbnail border circle {person} selectable thumbnailSize={120} on:click={() => onSelect(person)} />
+              <FaceThumbnail border circle {person} selectable thumbnailSize={120} onClick={() => onSelect(person)} />
             </div>
           {/each}
 
@@ -152,7 +146,7 @@
         </div>
       </div>
 
-      <PeopleList {people} {peopleToNotShow} {screenHeight} on:select={({ detail }) => onSelect(detail)} />
+      <PeopleList {people} {peopleToNotShow} {screenHeight} {onSelect} />
     </section>
   </section>
 </section>
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 d781e1cc56..f869790eba 100644
--- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
+++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte
@@ -4,7 +4,6 @@
   import { getPeopleThumbnailUrl } from '$lib/utils';
   import { type PersonResponseDto } from '@immich/sdk';
   import { mdiArrowLeft, mdiMerge } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
@@ -13,25 +12,22 @@
   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;
 
   let choosePersonToMerge = false;
 
   const title = personMerge2.name;
 
-  const dispatch = createEventDispatcher<{
-    reject: void;
-    confirm: [PersonResponseDto, PersonResponseDto];
-    close: void;
-  }>();
-
-  const changePersonToMerge = (newperson: PersonResponseDto) => {
-    const index = potentialMergePeople.indexOf(newperson);
+  const changePersonToMerge = (newPerson: PersonResponseDto) => {
+    const index = potentialMergePeople.indexOf(newPerson);
     [potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]];
     choosePersonToMerge = false;
   };
 </script>
 
-<FullScreenModal title="{$t('merge_people')} - {title}" onClose={() => dispatch('close')}>
+<FullScreenModal title="{$t('merge_people')} - {title}" {onClose}>
   <div class="flex items-center justify-center py-4 md:h-36 md:py-4">
     {#if !choosePersonToMerge}
       <div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
@@ -105,7 +101,7 @@
     <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={() => dispatch('reject')}>{$t('no')}</Button>
-    <Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>{$t('yes')}</Button>
+    <Button fullwidth color="gray" on:click={onReject}>{$t('no')}</Button>
+    <Button fullwidth on:click={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte
index 21f48e42eb..6791a26232 100644
--- a/web/src/lib/components/faces-page/people-card.svelte
+++ b/web/src/lib/components/faces-page/people-card.svelte
@@ -9,7 +9,6 @@
     mdiDotsVertical,
     mdiEyeOffOutline,
   } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
   import { t } from 'svelte-i18n';
@@ -18,19 +17,12 @@
 
   export let person: PersonResponseDto;
   export let preload = false;
-
-  type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person';
-  let dispatch = createEventDispatcher<{
-    'change-name': void;
-    'set-birth-date': void;
-    'merge-people': void;
-    'hide-person': void;
-  }>();
+  export let onChangeName: () => void;
+  export let onSetBirthDate: () => void;
+  export let onMergePeople: () => void;
+  export let onHidePerson: () => void;
 
   let showVerticalDots = false;
-  const onMenuClick = (event: MenuItemEvent) => {
-    dispatch(event);
-  };
 </script>
 
 <div
@@ -76,18 +68,10 @@
         icon={mdiDotsVertical}
         title={$t('show_person_options')}
       >
-        <MenuOption onClick={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
-        <MenuOption onClick={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
-        <MenuOption
-          onClick={() => onMenuClick('set-birth-date')}
-          icon={mdiCalendarEditOutline}
-          text={$t('set_date_of_birth')}
-        />
-        <MenuOption
-          onClick={() => onMenuClick('merge-people')}
-          icon={mdiAccountMultipleCheckOutline}
-          text={$t('merge_people')}
-        />
+        <MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
+        <MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
+        <MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
+        <MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
       </ButtonContextMenu>
     </div>
   {/if}
diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte
index 5130baf30b..230c8750ae 100644
--- a/web/src/lib/components/faces-page/people-list.svelte
+++ b/web/src/lib/components/faces-page/people-list.svelte
@@ -1,6 +1,5 @@
 <script lang="ts">
   import { type PersonResponseDto } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import FaceThumbnail from './face-thumbnail.svelte';
   import SearchPeople from '$lib/components/faces-page/people-search.svelte';
   import { t } from 'svelte-i18n';
@@ -8,15 +7,13 @@
   export let screenHeight: number;
   export let people: PersonResponseDto[];
   export let peopleToNotShow: PersonResponseDto[];
+  export let onSelect: (person: PersonResponseDto) => void;
+
   let searchedPeopleLocal: PersonResponseDto[] = [];
 
   let name = '';
   let showPeople: PersonResponseDto[];
 
-  let dispatch = createEventDispatcher<{
-    select: PersonResponseDto;
-  }>();
-
   $: {
     showPeople = name ? searchedPeopleLocal : people;
     showPeople = showPeople.filter(
@@ -35,15 +32,7 @@
 >
   <div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
     {#each showPeople as person (person.id)}
-      <FaceThumbnail
-        {person}
-        on:click={() => {
-          dispatch('select', person);
-        }}
-        circle
-        border
-        selectable
-      />
+      <FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
     {/each}
   </div>
 </div>
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 fd4fbdf964..23682f65f4 100644
--- a/web/src/lib/components/faces-page/person-side-panel.svelte
+++ b/web/src/lib/components/faces-page/person-side-panel.svelte
@@ -18,7 +18,7 @@
   import { mdiAccountOff } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { linear } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
@@ -31,6 +31,8 @@
 
   export let assetId: string;
   export let assetType: AssetTypeEnum;
+  export let onClose: () => void;
+  export let onRefresh: () => void;
 
   // keep track of the changes
   let peopleToCreate: string[] = [];
@@ -56,11 +58,6 @@
 
   const thumbnailWidth = '90px';
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-    refresh: void;
-  }>();
-
   async function loadPeople() {
     const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
     try {
@@ -85,7 +82,7 @@
     ) {
       clearTimeout(loaderLoadingDoneTimeout);
       clearTimeout(automaticRefreshTimeout);
-      dispatch('refresh');
+      onRefresh();
     }
   };
 
@@ -98,10 +95,6 @@
     return b.every((valueB) => a.includes(valueB));
   };
 
-  const handleBackButton = () => {
-    dispatch('close');
-  };
-
   const handleReset = (id: string) => {
     if (selectedPersonToReassign[id]) {
       delete selectedPersonToReassign[id];
@@ -153,9 +146,9 @@
     isShowLoadingDone = false;
     if (peopleToCreate.length === 0) {
       clearTimeout(loaderLoadingDoneTimeout);
-      dispatch('refresh');
+      onRefresh();
     } else {
-      automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000);
+      automaticRefreshTimeout = setTimeout(onRefresh, 15_000);
     }
   };
 
@@ -185,7 +178,7 @@
 >
   <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={handleBackButton} />
+      <CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={onClose} />
       <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('edit_faces')}</p>
     </div>
     {#if !isShowLoadingDone}
@@ -336,8 +329,8 @@
     {editedFace}
     {assetId}
     {assetType}
-    on:close={() => (showSelectedFaces = false)}
-    on:createPerson={(event) => handleCreatePerson(event.detail)}
-    on:reassign={(event) => handleReassignFace(event.detail)}
+    onClose={() => (showSelectedFaces = false)}
+    onCreatePerson={handleCreatePerson}
+    onReassign={handleReassignFace}
   />
 {/if}
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 b670f34dfd..d38c519911 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
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiCake } from '@mdi/js';
@@ -7,28 +6,20 @@
   import { t } from 'svelte-i18n';
 
   export let birthDate: string;
-
-  const dispatch = createEventDispatcher<{
-    close: void;
-    updated: string;
-  }>();
+  export let onClose: () => void;
+  export let onUpdate: (birthDate: string) => void;
 
   const todayFormatted = new Date().toISOString().split('T')[0];
-
-  const handleCancel = () => dispatch('close');
-  const handleSubmit = () => {
-    dispatch('updated', birthDate);
-  };
 </script>
 
-<FullScreenModal title={$t('set_date_of_birth')} icon={mdiCake} onClose={handleCancel}>
+<FullScreenModal title={$t('set_date_of_birth')} icon={mdiCake} {onClose}>
   <div class="text-immich-primary dark:text-immich-dark-primary">
     <p class="text-sm dark:text-immich-dark-fg">
       {$t('birthdate_set_description')}
     </p>
   </div>
 
-  <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="set-birth-date-form">
+  <form on:submit|preventDefault={() => onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form">
     <div class="my-4 flex flex-col gap-2">
       <DateInput
         class="immich-form-input"
@@ -41,7 +32,7 @@
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button color="gray" fullwidth on:click={onClose}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth form="set-birth-date-form">{$t('set')}</Button>
   </svelte:fragment>
 </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 c89c8338d3..753e46c219 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 { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
@@ -23,6 +23,8 @@
 
   export let assetIds: string[];
   export let personAssets: PersonResponseDto;
+  export let onConfirm: () => void;
+  export let onClose: () => void;
 
   let people: PersonResponseDto[] = [];
   let selectedPerson: PersonResponseDto | null = null;
@@ -34,11 +36,6 @@
 
   $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets];
 
-  let dispatch = createEventDispatcher<{
-    confirm: void;
-    close: void;
-  }>();
-
   const selectedPeople: AssetFaceUpdateItem[] = [];
 
   for (const assetId of assetIds) {
@@ -50,10 +47,6 @@
     people = data.people;
   });
 
-  const onClose = () => {
-    dispatch('close');
-  };
-
   const handleSelectedPerson = (person: PersonResponseDto) => {
     if (selectedPerson && selectedPerson.id === person.id) {
       handleRemoveSelectedPerson();
@@ -87,7 +80,7 @@
     }
 
     showLoadingSpinnerCreate = false;
-    dispatch('confirm');
+    onConfirm();
   };
 
   const handleReassign = async () => {
@@ -113,7 +106,7 @@
     }
 
     showLoadingSpinnerReassign = false;
-    dispatch('confirm');
+    onConfirm();
   };
 </script>
 
@@ -123,7 +116,7 @@
   transition:fly={{ y: 500, duration: 100, easing: quintOut }}
   class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
 >
-  <ControlAppBar on:close={onClose}>
+  <ControlAppBar {onClose}>
     <svelte:fragment slot="leading">
       <slot name="header" />
       <div />
@@ -180,7 +173,7 @@
           </div>
         </div>
       {/if}
-      <PeopleList {people} {peopleToNotShow} {screenHeight} on:select={({ detail }) => handleSelectedPerson(detail)} />
+      <PeopleList {people} {peopleToNotShow} {screenHeight} onSelect={handleSelectedPerson} />
     </section>
   </section>
 </section>
diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte
index b7bf8e1836..f43e1da38e 100644
--- a/web/src/lib/components/forms/api-key-secret.svelte
+++ b/web/src/lib/components/forms/api-key-secret.svelte
@@ -1,20 +1,15 @@
 <script lang="ts">
   import { copyToClipboard } from '$lib/utils';
   import { mdiKeyVariant } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { t } from 'svelte-i18n';
 
   export let secret = '';
-
-  const dispatch = createEventDispatcher<{
-    done: void;
-  }>();
-  const handleDone = () => dispatch('done');
+  export let onDone: () => void;
 </script>
 
-<FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={() => handleDone()}>
+<FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={onDone}>
   <div class="text-immich-primary dark:text-immich-dark-primary">
     <p class="text-sm dark:text-immich-dark-fg">
       {$t('api_key_description')}
@@ -28,6 +23,6 @@
 
   <svelte:fragment slot="sticky-bottom">
     <Button on:click={() => copyToClipboard(secret)} fullwidth>{$t('copy_to_clipboard')}</Button>
-    <Button on:click={() => handleDone()} fullwidth>{$t('done')}</Button>
+    <Button on:click={onDone} fullwidth>{$t('done')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte
index 799dde7ef3..cbf2ff07f0 100644
--- a/web/src/lib/components/forms/change-password-form.svelte
+++ b/web/src/lib/components/forms/change-password-form.svelte
@@ -1,10 +1,11 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import PasswordField from '../shared-components/password-field.svelte';
   import { updateMyUser } from '@immich/sdk';
   import { t } from 'svelte-i18n';
 
+  export let onSuccess: () => void;
+
   let errorMessage: string;
   let success: string;
 
@@ -23,17 +24,13 @@
     }
   }
 
-  const dispatch = createEventDispatcher<{
-    success: void;
-  }>();
-
   async function changePassword() {
     if (valid) {
       errorMessage = '';
 
       await updateMyUser({ userUpdateMeDto: { password: String(password) } });
 
-      dispatch('success');
+      onSuccess();
     }
   }
 </script>
diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte
index 8f049685a4..9c4b83002b 100644
--- a/web/src/lib/components/forms/create-user-form.svelte
+++ b/web/src/lib/components/forms/create-user-form.svelte
@@ -5,13 +5,14 @@
   import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
   import { handleError } from '$lib/utils/handle-error';
   import { createUserAdmin } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import Button from '../elements/buttons/button.svelte';
   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;
 
   let error: string;
   let success: string;
@@ -39,10 +40,6 @@
       canCreateUser = true;
     }
   }
-  const dispatch = createEventDispatcher<{
-    submit: void;
-    cancel: void;
-  }>();
 
   async function registerUser() {
     if (canCreateUser && !isCreatingUser) {
@@ -63,7 +60,7 @@
 
         success = $t('new_user_created');
 
-        dispatch('submit');
+        onSubmit();
 
         return;
       } catch (error) {
@@ -132,7 +129,7 @@
     {/if}
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => dispatch('cancel')}>{$t('cancel')}</Button>
+    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
     <Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">{$t('create')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte
index b326565122..0079a695bc 100644
--- a/web/src/lib/components/forms/edit-user-form.svelte
+++ b/web/src/lib/components/forms/edit-user-form.svelte
@@ -5,7 +5,6 @@
   import { handleError } from '$lib/utils/handle-error';
   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
   import { mdiAccountEditOutline } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import { t } from 'svelte-i18n';
@@ -15,6 +14,8 @@
   export let canResetPassword = true;
   export let newPassword: string;
   export let onClose: () => void;
+  export let onResetPasswordSuccess: () => void;
+  export let onEditSuccess: () => void;
 
   let error: string;
   let success: string;
@@ -27,12 +28,6 @@
     !!quotaSize &&
     convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw;
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-    resetPasswordSuccess: void;
-    editSuccess: void;
-  }>();
-
   const editUser = async () => {
     try {
       const { id, email, name, storageLabel } = user;
@@ -46,7 +41,7 @@
         },
       });
 
-      dispatch('editSuccess');
+      onEditSuccess();
     } catch (error) {
       handleError(error, $t('errors.unable_to_update_user'));
     }
@@ -72,7 +67,7 @@
         },
       });
 
-      dispatch('resetPasswordSuccess');
+      onResetPasswordSuccess();
     } catch (error) {
       handleError(error, $t('errors.unable_to_reset_password'));
     }
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 c09f1fbaf6..05d47c0a0f 100644
--- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
+++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiFolderRemove } from '@mdi/js';
@@ -10,6 +9,9 @@
   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 = () => {};
 
   onMount(() => {
     if (isEditing) {
@@ -19,18 +21,10 @@
 
   $: isDuplicate = exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern);
   $: canSubmit = exclusionPattern && !exclusionPatterns.includes(exclusionPattern);
-
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: { excludePattern: string };
-    delete: void;
-  }>();
-  const handleCancel = () => dispatch('cancel');
-  const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
 </script>
 
-<FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={handleCancel}>
-  <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form">
+<FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={onCancel}>
+  <form on:submit|preventDefault={() => onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form">
     <p class="py-5 text-sm">
       {$t('admin.exclusion_pattern_description')}
       <br /><br />
@@ -53,9 +47,9 @@
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={() => dispatch('delete')}>{$t('delete')}</Button>
+      <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="add-exclusion-pattern-form">{submitText}</Button>
   </svelte:fragment>
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 f82d573386..8bfca80aec 100644
--- a/web/src/lib/components/forms/library-import-path-form.svelte
+++ b/web/src/lib/components/forms/library-import-path-form.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiFolderSync } from '@mdi/js';
@@ -12,6 +11,9 @@
   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 = () => {};
 
   onMount(() => {
     if (isEditing) {
@@ -21,18 +23,10 @@
 
   $: isDuplicate = importPath !== null && importPaths.includes(importPath);
   $: canSubmit = importPath !== '' && importPath !== null && !importPaths.includes(importPath);
-
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: { importPath: string | null };
-    delete: void;
-  }>();
-  const handleCancel = () => dispatch('cancel');
-  const handleSubmit = () => dispatch('submit', { importPath });
 </script>
 
-<FullScreenModal {title} icon={mdiFolderSync} onClose={handleCancel}>
-  <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="library-import-path-form">
+<FullScreenModal {title} icon={mdiFolderSync} onClose={onCancel}>
+  <form on:submit|preventDefault={() => onSubmit(importPath)} 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">
@@ -47,9 +41,9 @@
     </div>
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
+    <Button color="gray" fullwidth on:click={onCancel}>{cancelText}</Button>
     {#if isEditing}
-      <Button color="red" fullwidth on:click={() => dispatch('delete')}>{$t('delete')}</Button>
+      <Button color="red" fullwidth on:click={onDelete}>{$t('delete')}</Button>
     {/if}
     <Button type="submit" disabled={!canSubmit} fullwidth form="library-import-path-form">{submitText}</Button>
   </svelte:fragment>
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 a2bb3a9686..9e7ae11a63 100644
--- a/web/src/lib/components/forms/library-import-paths-form.svelte
+++ b/web/src/lib/components/forms/library-import-paths-form.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { handleError } from '../../utils/handle-error';
   import Button from '../elements/buttons/button.svelte';
   import LibraryImportPathForm from './library-import-path-form.svelte';
@@ -12,6 +12,8 @@
   import { t } from 'svelte-i18n';
 
   export let library: LibraryResponseDto;
+  export let onCancel: () => void;
+  export let onSubmit: (library: LibraryResponseDto) => void;
 
   let addImportPath = false;
   let editImportPath: number | null = null;
@@ -65,19 +67,6 @@
     }
   };
 
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: Partial<LibraryResponseDto>;
-  }>();
-
-  const handleCancel = () => {
-    dispatch('cancel');
-  };
-
-  const handleSubmit = () => {
-    dispatch('submit', { ...library });
-  };
-
   const handleAddImportPath = async () => {
     if (!addImportPath || !importPathToAdd) {
       return;
@@ -153,8 +142,8 @@
     submitText={$t('add')}
     bind:importPath={importPathToAdd}
     {importPaths}
-    on:submit={handleAddImportPath}
-    on:cancel={() => {
+    onSubmit={handleAddImportPath}
+    onCancel={() => {
       addImportPath = false;
       importPathToAdd = null;
     }}
@@ -168,15 +157,13 @@
     isEditing={true}
     bind:importPath={editedImportPath}
     {importPaths}
-    on:submit={handleEditImportPath}
-    on:delete={handleDeleteImportPath}
-    on:cancel={() => {
-      editImportPath = null;
-    }}
+    onSubmit={handleEditImportPath}
+    onDelete={handleDeleteImportPath}
+    onCancel={() => (editImportPath = null)}
   />
 {/if}
 
-<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-4">
+<form on:submit|preventDefault={() => onSubmit({ ...library })} 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}
@@ -251,7 +238,7 @@
       >
     </div>
     <div class="justify-end gap-2">
-      <Button size="sm" color="gray" on:click={() => handleCancel()}>{$t('cancel')}</Button>
+      <Button size="sm" color="gray" on:click={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 e09e0a4f2b..1f93fb028b 100644
--- a/web/src/lib/components/forms/library-rename-form.svelte
+++ b/web/src/lib/components/forms/library-rename-form.svelte
@@ -1,31 +1,20 @@
 <script lang="ts">
   import type { LibraryResponseDto } from '@immich/sdk';
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import { t } from 'svelte-i18n';
 
   export let library: Partial<LibraryResponseDto>;
-
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: Partial<LibraryResponseDto>;
-  }>();
-  const handleCancel = () => {
-    dispatch('cancel');
-  };
-
-  const handleSubmit = () => {
-    dispatch('submit', { ...library });
-  };
+  export let onCancel: () => void;
+  export let onSubmit: (library: Partial<LibraryResponseDto>) => void;
 </script>
 
-<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2">
+<form on:submit|preventDefault={() => onSubmit({ ...library })} 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={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button size="sm" color="gray" on:click={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 5e025a406a..a9a42c31f7 100644
--- a/web/src/lib/components/forms/library-scan-settings-form.svelte
+++ b/web/src/lib/components/forms/library-scan-settings-form.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
   import { type LibraryResponseDto } from '@immich/sdk';
   import { mdiPencilOutline } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { handleError } from '../../utils/handle-error';
   import Button from '../elements/buttons/button.svelte';
   import LibraryExclusionPatternForm from './library-exclusion-pattern-form.svelte';
@@ -9,6 +9,8 @@
   import { t } from 'svelte-i18n';
 
   export let library: Partial<LibraryResponseDto>;
+  export let onCancel: () => void;
+  export let onSubmit: (library: Partial<LibraryResponseDto>) => void;
 
   let addExclusionPattern = false;
   let editExclusionPattern: number | null = null;
@@ -26,18 +28,6 @@
     }
   });
 
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: Partial<LibraryResponseDto>;
-  }>();
-  const handleCancel = () => {
-    dispatch('cancel');
-  };
-
-  const handleSubmit = () => {
-    dispatch('submit', library);
-  };
-
   const handleAddExclusionPattern = () => {
     if (!addExclusionPattern) {
       return;
@@ -106,10 +96,8 @@
     submitText={$t('add')}
     bind:exclusionPattern={exclusionPatternToAdd}
     {exclusionPatterns}
-    on:submit={handleAddExclusionPattern}
-    on:cancel={() => {
-      addExclusionPattern = false;
-    }}
+    onSubmit={handleAddExclusionPattern}
+    onCancel={() => (addExclusionPattern = false)}
   />
 {/if}
 
@@ -119,15 +107,13 @@
     isEditing={true}
     bind:exclusionPattern={editedExclusionPattern}
     {exclusionPatterns}
-    on:submit={handleEditExclusionPattern}
-    on:delete={handleDeleteExclusionPattern}
-    on:cancel={() => {
-      editExclusionPattern = null;
-    }}
+    onSubmit={handleEditExclusionPattern}
+    onDelete={handleDeleteExclusionPattern}
+    onCancel={() => (editExclusionPattern = null)}
   />
 {/if}
 
-<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-4">
+<form on:submit|preventDefault={() => onSubmit(library)} 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}
@@ -178,7 +164,7 @@
   </table>
 
   <div class="flex w-full justify-end gap-4">
-    <Button size="sm" color="gray" on:click={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button size="sm" color="gray" on:click={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 4b4e7308dc..e5334ff9e9 100644
--- a/web/src/lib/components/forms/library-user-picker-form.svelte
+++ b/web/src/lib/components/forms/library-user-picker-form.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import Button from '../elements/buttons/button.svelte';
   import FullScreenModal from '../shared-components/full-screen-modal.svelte';
   import { mdiFolderSync } from '@mdi/js';
@@ -9,6 +8,9 @@
   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;
+
   let ownerId: string = $user.id;
 
   let userOptions: { value: string; text: string }[] = [];
@@ -17,25 +19,16 @@
     const users = await searchUsersAdmin({});
     userOptions = users.map((user) => ({ value: user.id, text: user.name }));
   });
-
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    submit: { ownerId: string };
-    delete: void;
-  }>();
-
-  const handleCancel = () => dispatch('cancel');
-  const handleSubmit = () => dispatch('submit', { ownerId });
 </script>
 
-<FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={handleCancel}>
-  <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="select-library-owner-form">
+<FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={onCancel}>
+  <form on:submit|preventDefault={() => onSubmit(ownerId)} 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={() => handleCancel()}>{$t('cancel')}</Button>
+    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
     <Button type="submit" fullwidth form="select-library-owner-form">{$t('create')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte
index 5bca13b060..ed232b80cd 100644
--- a/web/src/lib/components/layouts/user-page-layout.svelte
+++ b/web/src/lib/components/layouts/user-page-layout.svelte
@@ -21,7 +21,7 @@
 
 <header>
   {#if !hideNavbar}
-    <NavigationBar {showUploadButton} on:uploadClicked={() => openFileUploadDialog()} />
+    <NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
   {/if}
 
   <slot name="header" />
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 b442396c84..35df9f2285 100644
--- a/web/src/lib/components/map-page/map-settings-modal.svelte
+++ b/web/src/lib/components/map-page/map-settings-modal.svelte
@@ -4,7 +4,6 @@
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import type { MapSettings } from '$lib/stores/preferences.store';
   import { Duration } from 'luxon';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import { fly } from 'svelte/transition';
   import Button from '../elements/buttons/button.svelte';
@@ -12,19 +11,15 @@
   import DateInput from '../elements/date-input.svelte';
 
   export let settings: MapSettings;
+  export let onClose: () => void;
+  export let onSave: (settings: MapSettings) => void;
+
   let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
-
-  const dispatch = createEventDispatcher<{
-    close: void;
-    save: MapSettings;
-  }>();
-
-  const handleClose = () => dispatch('close');
 </script>
 
-<FullScreenModal title={$t('map_settings')} onClose={handleClose}>
+<FullScreenModal title={$t('map_settings')} {onClose}>
   <form
-    on:submit|preventDefault={() => dispatch('save', settings)}
+    on:submit|preventDefault={() => onSave(settings)}
     class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
     id="map-settings-form"
   >
@@ -108,7 +103,7 @@
     {/if}
   </form>
   <svelte:fragment slot="sticky-bottom">
-    <Button color="gray" size="sm" fullwidth on:click={handleClose}>{$t('cancel')}</Button>
+    <Button color="gray" size="sm" fullwidth on:click={onClose}>{$t('cancel')}</Button>
     <Button type="submit" size="sm" fullwidth form="map-settings-form">{$t('save')}</Button>
   </svelte:fragment>
 </FullScreenModal>
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte
index ae6416873e..919433f79b 100644
--- a/web/src/lib/components/memory-page/memory-viewer.svelte
+++ b/web/src/lib/components/memory-page/memory-viewer.svelte
@@ -250,7 +250,7 @@
 
 <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
   {#if current && current.memory.assets.length > 0}
-    <ControlAppBar on:close={() => goto(AppRoute.PHOTOS)} forceDark>
+    <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark>
       <svelte:fragment slot="leading">
         <p class="text-lg">
           {$memoryLaneTitle(current.memory.yearsAgo)}
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 976f4bd9cf..d3998510cd 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
@@ -40,8 +40,8 @@
 {#if showAlbumPicker}
   <AlbumSelectionModal
     {shared}
-    on:newAlbum={({ detail }) => handleAddToNewAlbum(detail)}
-    on:album={({ detail }) => handleAddToAlbum(detail)}
+    onNewAlbum={handleAddToNewAlbum}
+    onAlbumClick={handleAddToAlbum}
     onClose={handleHideAlbumPicker}
   />
 {/if}
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 6ee775fa69..114315348d 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
@@ -31,9 +31,5 @@
   <MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
 {/if}
 {#if isShowChangeDate}
-  <ChangeDate
-    initialDate={DateTime.now()}
-    on:confirm={({ detail: date }) => handleConfirm(date)}
-    on:cancel={() => (isShowChangeDate = false)}
-  />
+  <ChangeDate initialDate={DateTime.now()} onConfirm={handleConfirm} onCancel={() => (isShowChangeDate = false)} />
 {/if}
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 0e19696a42..3fe1db4327 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
@@ -35,8 +35,5 @@
   />
 {/if}
 {#if isShowChangeLocation}
-  <ChangeLocation
-    on:confirm={({ detail: point }) => handleConfirm(point)}
-    on:cancel={() => (isShowChangeLocation = false)}
-  />
+  <ChangeLocation onConfirm={handleConfirm} onCancel={() => (isShowChangeLocation = false)} />
 {/if}
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 5c79e7b221..6d3275c74d 100644
--- a/web/src/lib/components/photos-page/actions/delete-assets.svelte
+++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte
@@ -49,7 +49,7 @@
 {#if isShowConfirmation}
   <DeleteAssetDialog
     size={getOwnedAssets().size}
-    on:confirm={handleDelete}
-    on:cancel={() => (isShowConfirmation = false)}
+    onConfirm={handleDelete}
+    onCancel={() => (isShowConfirmation = false)}
   />
 {/if}
diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte
index 240b6c2ba2..b2780cc1a0 100644
--- a/web/src/lib/components/photos-page/asset-date-group.svelte
+++ b/web/src/lib/components/photos-page/asset-date-group.svelte
@@ -8,7 +8,7 @@
   import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
   import type { AssetResponseDto } from '@immich/sdk';
   import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
-  import { createEventDispatcher, onDestroy } from 'svelte';
+  import { onDestroy } from 'svelte';
   import { fly } from 'svelte/transition';
   import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
   import { TUNABLES } from '$lib/utils/tunables';
@@ -29,6 +29,9 @@
 
   export let onScrollTarget: ScrollTargetListener | undefined = undefined;
   export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
+  export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
+  export let onSelectAssets: (asset: AssetResponseDto) => void;
+  export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
 
   const componentId = generateId();
   $: bucketDate = bucket.bucketDate;
@@ -41,11 +44,6 @@
   const TITLE_HEIGHT = 51;
 
   const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
-  const dispatch = createEventDispatcher<{
-    select: { title: string; assets: AssetResponseDto[] };
-    selectAssets: AssetResponseDto;
-    selectAssetCandidates: AssetResponseDto | null;
-  }>();
 
   let isMouseOverGroup = false;
   let hoveredDateGroup = '';
@@ -65,10 +63,10 @@
     }
   };
 
-  const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
+  const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
 
   const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
-    dispatch('selectAssets', asset);
+    onSelectAssets(asset);
 
     // Check if all assets are selected in a group to toggle the group selection's icon
     let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length;
@@ -86,7 +84,7 @@
     hoveredDateGroup = groupTitle;
 
     if ($isMultiSelectState) {
-      dispatch('selectAssetCandidates', asset);
+      onSelectAssetCandidates(asset);
     }
   };
 
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index 3bf0c65bc9..6de36c803e 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -28,7 +28,7 @@
   import { TUNABLES } from '$lib/utils/tunables';
   import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
   import { throttle } from 'lodash-es';
-  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount } 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';
@@ -64,6 +64,8 @@
   export let isShared = false;
   export let album: AlbumResponseDto | null = null;
   export let isShowDeleteConfirmation = false;
+  export let onSelect: (asset: AssetResponseDto) => void = () => {};
+  export let onEscape: () => void = () => {};
 
   let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
   const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
@@ -127,8 +129,6 @@
     },
   } = TUNABLES;
 
-  const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
-
   const isViewportOrigin = () => {
     return viewport.height === 0 && viewport.width === 0;
   };
@@ -447,7 +447,7 @@
     const ids = await stackAssets(Array.from($selectedAssets));
     if (ids) {
       $assetStore.removeAssets(ids);
-      dispatch('escape');
+      onEscape();
     }
   };
 
@@ -471,7 +471,7 @@
     }
 
     const shortcuts: ShortcutOptions[] = [
-      { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
+      { 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) },
@@ -539,7 +539,7 @@
     return !!nextAsset;
   };
 
-  const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => {
+  const handleClose = async ({ asset }: { asset: AssetResponseDto }) => {
     assetViewingStore.showAssetViewer(false);
     showSkeleton = true;
     $gridScrollTarget = { at: asset.id };
@@ -554,7 +554,7 @@
       case AssetAction.DELETE: {
         // find the next asset to show or close the viewer
         // eslint-disable-next-line @typescript-eslint/no-unused-expressions
-        (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } }));
+        (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
 
         // delete after find the next one
         assetStore.removeAssets([action.asset.id]);
@@ -649,7 +649,7 @@
       return;
     }
 
-    dispatch('select', asset);
+    onSelect(asset);
 
     if (singleSelect) {
       element.scrollTop = 0;
@@ -754,8 +754,8 @@
 {#if isShowDeleteConfirmation}
   <DeleteAssetDialog
     size={idsSelectedAssets.length}
-    on:cancel={() => (isShowDeleteConfirmation = false)}
-    on:confirm={() => handlePromiseError(trashOrDelete(true))}
+    onCancel={() => (isShowDeleteConfirmation = false)}
+    onConfirm={() => handlePromiseError(trashOrDelete(true))}
   />
 {/if}
 
@@ -847,9 +847,9 @@
             {onAssetInGrid}
             {bucket}
             viewport={safeViewport}
-            on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
-            on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
-            on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
+            onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
+            onSelectAssetCandidates={handleSelectAssetCandidates}
+            onSelectAssets={handleSelectAssets}
           />
         {/if}
       </div>
@@ -869,9 +869,9 @@
         {isShared}
         {album}
         onAction={handleAction}
-        on:previous={handlePrevious}
-        on:next={handleNext}
-        on:close={handleClose}
+        onPrevious={handlePrevious}
+        onNext={handleNext}
+        onClose={handleClose}
       />
     {/await}
   {/if}
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 c802c53454..79a0ea75e6 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
@@ -30,7 +30,7 @@
   });
 </script>
 
-<ControlAppBar on:close={clearSelect} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
+<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>
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 84782b2d7f..3eff428a7b 100644
--- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte
+++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
   import { showDeleteModal } from '$lib/stores/preferences.store';
   import Checkbox from '$lib/components/elements/checkbox.svelte';
@@ -7,19 +6,16 @@
   import FormatMessage from '$lib/components/i18n/format-message.svelte';
 
   export let size: number;
+  export let onConfirm: () => void;
+  export let onCancel: () => void;
 
   let checked = false;
 
-  const dispatch = createEventDispatcher<{
-    confirm: void;
-    cancel: void;
-  }>();
-
   const handleConfirm = () => {
     if (checked) {
       $showDeleteModal = false;
     }
-    dispatch('confirm');
+    onConfirm();
   };
 </script>
 
@@ -27,7 +23,7 @@
   title={$t('permanently_delete_assets_count', { values: { count: size } })}
   confirmText={$t('delete')}
   onConfirm={handleConfirm}
-  onCancel={() => dispatch('cancel')}
+  {onCancel}
 >
   <svelte:fragment slot="prompt">
     <p>
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 0690374c01..6d28bd12c0 100644
--- a/web/src/lib/components/shared-components/album-selection-modal.svelte
+++ b/web/src/lib/components/shared-components/album-selection-modal.svelte
@@ -2,7 +2,7 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
   import { mdiPlus } from '@mdi/js';
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import AlbumListItem from '../asset-viewer/album-list-item.svelte';
   import { normalizeSearchString } from '$lib/utils/string-utils';
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@@ -11,17 +11,15 @@
   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[] = [];
   let recentAlbums: AlbumResponseDto[] = [];
   let filteredAlbums: AlbumResponseDto[] = [];
   let loading = true;
   let search = '';
 
-  const dispatch = createEventDispatcher<{
-    newAlbum: string;
-    album: AlbumResponseDto;
-  }>();
-
   export let shared: boolean;
   export let onClose: () => void;
 
@@ -40,14 +38,6 @@
     { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder },
   );
 
-  const handleSelect = (album: AlbumResponseDto) => {
-    dispatch('album', album);
-  };
-
-  const handleNew = () => {
-    dispatch('newAlbum', search.length > 0 ? search : '');
-  };
-
   const getTitle = () => {
     if (shared) {
       return $t('add_to_shared_album');
@@ -81,7 +71,7 @@
       <div class="immich-scrollbar overflow-y-auto">
         <button
           type="button"
-          on:click={handleNew}
+          on:click={() => 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">
@@ -96,7 +86,7 @@
           {#if !shared && search.length === 0}
             <p class="px-5 py-3 text-xs">{$t('recent').toUpperCase()}</p>
             {#each recentAlbums as album (album.id)}
-              <AlbumListItem {album} on:album={() => handleSelect(album)} />
+              <AlbumListItem {album} onAlbumClick={() => onAlbumClick(album)} />
             {/each}
           {/if}
 
@@ -106,7 +96,7 @@
             </p>
           {/if}
           {#each filteredAlbums as album (album.id)}
-            <AlbumListItem {album} searchQuery={search} on:album={() => handleSelect(album)} />
+            <AlbumListItem {album} searchQuery={search} onAlbumClick={() => onAlbumClick(album)} />
           {/each}
         {:else if albums.length > 0}
           <p class="px-5 py-1 text-sm">{$t('no_albums_with_name_yet')}</p>
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte
index 80eaa3d819..8ceda5f1d6 100644
--- a/web/src/lib/components/shared-components/change-date.svelte
+++ b/web/src/lib/components/shared-components/change-date.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import { DateTime } from 'luxon';
   import ConfirmDialog from './dialog/confirm-dialog.svelte';
   import Combobox from './combobox.svelte';
@@ -8,6 +7,8 @@
 
   export let initialDate: DateTime = DateTime.now();
   export let initialTimeZone: string = '';
+  export let onCancel: () => void;
+  export let onConfirm: (date: string) => void;
 
   type ZoneOption = {
     /**
@@ -118,17 +119,10 @@
     return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' });
   }
 
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    confirm: string;
-  }>();
-
-  const handleCancel = () => dispatch('cancel');
-
   const handleConfirm = () => {
     const value = date.toISO();
     if (value) {
-      dispatch('confirm', value);
+      onConfirm(value);
     }
   };
 </script>
@@ -139,7 +133,7 @@
   prompt="Please select a new date:"
   disabled={!date.isValid}
   onConfirm={handleConfirm}
-  onCancel={handleCancel}
+  {onCancel}
 >
   <div class="flex flex-col text-left gap-2" slot="prompt">
     <div class="flex flex-col">
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte
index 3b0cb7bcc1..573c9ab38b 100644
--- a/web/src/lib/components/shared-components/change-location.svelte
+++ b/web/src/lib/components/shared-components/change-location.svelte
@@ -1,5 +1,4 @@
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
   import ConfirmDialog from './dialog/confirm-dialog.svelte';
   import { timeDebounceOnSearch } from '$lib/constants';
   import { handleError } from '$lib/utils/handle-error';
@@ -14,13 +13,15 @@
   import { t } from 'svelte-i18n';
   import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
 
-  export let asset: AssetResponseDto | undefined = undefined;
-
   interface Point {
     lng: number;
     lat: number;
   }
 
+  export let asset: AssetResponseDto | undefined = undefined;
+  export let onCancel: () => void;
+  export let onConfirm: (point: Point) => void;
+
   let places: PlacesResponseDto[] = [];
   let suggestedPlaces: PlacesResponseDto[] = [];
   let searchWord: string;
@@ -30,11 +31,6 @@
   let hideSuggestion = false;
   let addClipMapMarker: (long: number, lat: number) => void;
 
-  const dispatch = createEventDispatcher<{
-    cancel: void;
-    confirm: Point;
-  }>();
-
   $: lat = asset?.exifInfo?.latitude ?? undefined;
   $: lng = asset?.exifInfo?.longitude ?? undefined;
   $: zoom = lat !== undefined && lng !== undefined ? 12.5 : 1;
@@ -50,17 +46,11 @@
 
   let point: Point | null = null;
 
-  const handleCancel = () => dispatch('cancel');
-
-  const handleSelect = (selected: Point) => {
-    point = selected;
-  };
-
   const handleConfirm = () => {
     if (point) {
-      dispatch('confirm', point);
+      onConfirm(point);
     } else {
-      dispatch('cancel');
+      onCancel();
     }
   };
 
@@ -108,13 +98,7 @@
   };
 </script>
 
-<ConfirmDialog
-  confirmColor="primary"
-  title={$t('change_location')}
-  width="wide"
-  onConfirm={handleConfirm}
-  onCancel={handleCancel}
->
+<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"
@@ -126,10 +110,8 @@
           placeholder={$t('search_places')}
           bind:name={searchWord}
           {showLoadingSpinner}
-          on:reset={() => {
-            suggestedPlaces = [];
-          }}
-          on:search={handleSearchPlaces}
+          onReset={() => (suggestedPlaces = [])}
+          onSearch={handleSearchPlaces}
           roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
         />
       </button>
@@ -180,7 +162,7 @@
           center={lat && lng ? { lat, lng } : undefined}
           simplified={true}
           clickable={true}
-          on:clickedPoint={({ detail: point }) => handleSelect(point)}
+          onClickPoint={(selected) => (point = selected)}
         />
       {/await}
     </div>
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index 7c71fe8aea..241f937be0 100644
--- a/web/src/lib/components/shared-components/combobox.svelte
+++ b/web/src/lib/components/shared-components/combobox.svelte
@@ -21,7 +21,7 @@
   import { fly } from 'svelte/transition';
   import Icon from '$lib/components/elements/icon.svelte';
   import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
-  import { createEventDispatcher, tick } from 'svelte';
+  import { tick } from 'svelte';
   import type { FormEventHandler } from 'svelte/elements';
   import { shortcuts } from '$lib/actions/shortcut';
   import { focusOutside } from '$lib/actions/focus-outside';
@@ -35,6 +35,7 @@
   export let options: ComboBoxOption[] = [];
   export let selectedOption: ComboBoxOption | undefined = undefined;
   export let placeholder = '';
+  export let onSelect: (option: ComboBoxOption | undefined) => void = () => {};
 
   /**
    * Unique identifier for the combobox.
@@ -61,10 +62,6 @@
     searchQuery = selectedOption ? selectedOption.label : '';
   }
 
-  const dispatch = createEventDispatcher<{
-    select: ComboBoxOption | undefined;
-  }>();
-
   const activate = () => {
     isActive = true;
     searchQuery = '';
@@ -105,10 +102,10 @@
     optionRefs[0]?.scrollIntoView({ block: 'nearest' });
   };
 
-  let onSelect = (option: ComboBoxOption) => {
+  let handleSelect = (option: ComboBoxOption) => {
     selectedOption = option;
     searchQuery = option.label;
-    dispatch('select', option);
+    onSelect(option);
     closeDropdown();
   };
 
@@ -117,7 +114,7 @@
     selectedIndex = undefined;
     selectedOption = undefined;
     searchQuery = '';
-    dispatch('select', selectedOption);
+    onSelect(selectedOption);
   };
 </script>
 
@@ -188,7 +185,7 @@
           shortcut: { key: 'Enter' },
           onShortcut: () => {
             if (selectedIndex !== undefined && filteredOptions.length > 0) {
-              onSelect(filteredOptions[selectedIndex]);
+              handleSelect(filteredOptions[selectedIndex]);
             }
             closeDropdown();
           },
@@ -245,7 +242,7 @@
           bind:this={optionRefs[index]}
           class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
           id={`${listboxId}-${index}`}
-          on:click={() => onSelect(option)}
+          on:click={() => handleSelect(option)}
           role="option"
         >
           {option.label}
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 cf128104d1..228cd88a86 100644
--- a/web/src/lib/components/shared-components/control-app-bar.svelte
+++ b/web/src/lib/components/shared-components/control-app-bar.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
   import { browser } from '$app/environment';
 
-  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
+  import { onDestroy, onMount } from 'svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import { fly } from 'svelte/transition';
   import { mdiClose } from '@mdi/js';
@@ -12,13 +12,10 @@
   export let backIcon = mdiClose;
   export let tailwindClasses = '';
   export let forceDark = false;
+  export let onClose: () => void = () => {};
 
   let appBarBorder = 'bg-immich-bg border border-transparent';
 
-  const dispatch = createEventDispatcher<{
-    close: void;
-  }>();
-
   const onScroll = () => {
     if (window.pageYOffset > 80) {
       appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
@@ -33,7 +30,7 @@
 
   const handleClose = () => {
     $isSelectingAllAssets = false;
-    dispatch('close');
+    onClose();
   };
 
   onMount(() => {
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 c50a07ad37..05fe8447a0 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
@@ -7,7 +7,6 @@
   import { handleError } from '$lib/utils/handle-error';
   import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
   import { mdiContentCopy, mdiLink } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import { NotificationType, notificationController } from '../notification/notification';
   import SettingInputField, { SettingInputFieldType } from '../settings/setting-input-field.svelte';
   import SettingSwitch from '../settings/setting-switch.svelte';
@@ -21,6 +20,7 @@
   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 = '';
@@ -32,10 +32,6 @@
   let shouldChangeExpirationTime = false;
   let enablePassword = false;
 
-  const dispatch = createEventDispatcher<{
-    created: void;
-  }>();
-
   const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
     [30, 'minutes'],
     [1, 'hour'],
@@ -97,7 +93,7 @@
         },
       });
       sharedLink = makeSharedLinkUrl($serverConfig.externalDomain, data.key);
-      dispatch('created');
+      onCreated();
     } catch (error) {
       handleError(error, $t('errors.failed_to_create_shared_link'));
     }
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 d64d784177..b595a6bb62 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
@@ -163,9 +163,9 @@
     <AssetViewer
       asset={$viewingAsset}
       onAction={handleAction}
-      on:previous={handlePrevious}
-      on:next={handleNext}
-      on:close={() => {
+      onPrevious={handlePrevious}
+      onNext={handleNext}
+      onClose={() => {
         assetViewingStore.showAssetViewer(false);
         handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
       }}
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index d1da7a273b..83ea3016fd 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -13,7 +13,6 @@
   import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
   import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl';
   import maplibregl from 'maplibre-gl';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import {
     AttributionControl,
@@ -52,6 +51,8 @@
   }
 
   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;
@@ -62,16 +63,11 @@
       key: getKey(),
     }) as Promise<StyleSpecification>)();
 
-  const dispatch = createEventDispatcher<{
-    selected: string[];
-    clickedPoint: { lat: number; lng: number };
-  }>();
-
   function handleAssetClick(assetId: string, map: Map | null) {
     if (!map) {
       return;
     }
-    dispatch('selected', [assetId]);
+    onSelect([assetId]);
   }
 
   async function handleClusterClick(clusterId: number, map: Map | null) {
@@ -82,13 +78,13 @@
     const mapSource = map?.getSource('geojson') as GeoJSONSource;
     const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
     const ids = leaves.map((leaf) => leaf.properties?.id);
-    dispatch('selected', ids);
+    onSelect(ids);
   }
 
   function handleMapClick(event: maplibregl.MapMouseEvent) {
     if (clickable) {
       const { lng, lat } = event.lngLat;
-      dispatch('clickedPoint', { lng, lat });
+      onClickPoint({ lng, lat });
 
       if (marker) {
         marker.remove();
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 c38893376e..bf0ca26d61 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
@@ -9,19 +9,16 @@
   import { handleError } from '$lib/utils/handle-error';
   import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk';
   import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
   import { NotificationType, notificationController } from '../notification/notification';
   import UserAvatar from '../user-avatar.svelte';
   import AvatarSelector from './avatar-selector.svelte';
 
-  let isShowSelectAvatar = false;
+  export let onLogout: () => void;
+  export let onClose: () => void = () => {};
 
-  const dispatch = createEventDispatcher<{
-    logout: void;
-    close: void;
-  }>();
+  let isShowSelectAvatar = false;
 
   const handleSaveProfile = async (color: UserAvatarColor) => {
     try {
@@ -75,14 +72,7 @@
     </div>
 
     <div class="flex flex-col gap-1">
-      <Button
-        href={AppRoute.USER_SETTINGS}
-        on:click={() => dispatch('close')}
-        color="dark-gray"
-        size="sm"
-        shadow={false}
-        border
-      >
+      <Button href={AppRoute.USER_SETTINGS} on:click={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')}
@@ -91,7 +81,7 @@
       {#if $user.isAdmin}
         <Button
           href={AppRoute.ADMIN_USER_MANAGEMENT}
-          on:click={() => dispatch('close')}
+          on:click={onClose}
           color="dark-gray"
           size="sm"
           shadow={false}
@@ -111,7 +101,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={() => dispatch('logout')}
+      on:click={onLogout}
     >
       <Icon path={mdiLogout} size={24} />
       {$t('sign_out')}</button
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 58a4c23d74..28f8d7bd60 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
@@ -10,7 +10,6 @@
   import { handleLogout } from '$lib/utils/auth';
   import { logout } from '@immich/sdk';
   import { mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
   import { AppRoute } from '../../../constants';
@@ -21,13 +20,11 @@
   import AccountInfoPanel from './account-info-panel.svelte';
 
   export let showUploadButton = true;
+  export let onUploadClick: () => void;
 
   let shouldShowAccountInfo = false;
   let shouldShowAccountInfoPanel = false;
   let innerWidth: number;
-  const dispatch = createEventDispatcher<{
-    uploadClicked: void;
-  }>();
 
   const onLogout = async () => {
     const { redirectUri } = await logout();
@@ -67,14 +64,14 @@
         <ThemeButton padding="2" />
 
         {#if !$page.url.pathname.includes('/admin') && showUploadButton}
-          <LinkButton on:click={() => dispatch('uploadClicked')} class="hidden lg:block">
+          <LinkButton on:click={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={() => dispatch('uploadClicked')}
+            on:click={onUploadClick}
             title={$t('upload')}
             icon={mdiTrayArrowUp}
             class="lg:hidden"
@@ -114,7 +111,7 @@
           {/if}
 
           {#if shouldShowAccountInfoPanel}
-            <AccountInfoPanel on:logout={onLogout} />
+            <AccountInfoPanel {onLogout} />
           {/if}
         </div>
       </section>
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 817cccac38..1c799ced11 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
@@ -8,7 +8,7 @@
 <script lang="ts">
   import { handlePromiseError } from '$lib/utils';
 
-  import { createEventDispatcher, onMount } from 'svelte';
+  import { onMount } from 'svelte';
   import { tweened } from 'svelte/motion';
 
   /**
@@ -26,6 +26,10 @@
 
   export let duration = 5;
 
+  export let onDone: () => void;
+  export let onPlaying: () => void = () => {};
+  export let onPaused: () => void = () => {};
+
   const onChange = async () => {
     progress = setDuration(duration);
     await play();
@@ -39,16 +43,10 @@
 
   $: {
     if ($progress === 1) {
-      dispatch('done');
+      onDone();
     }
   }
 
-  const dispatch = createEventDispatcher<{
-    done: void;
-    playing: void;
-    paused: void;
-  }>();
-
   onMount(async () => {
     if (autoplay) {
       await play();
@@ -57,13 +55,13 @@
 
   export const play = async () => {
     status = ProgressBarStatus.Playing;
-    dispatch('playing');
+    onPlaying();
     await progress.set(1);
   };
 
   export const pause = async () => {
     status = ProgressBarStatus.Paused;
-    dispatch('paused');
+    onPaused();
     await progress.set($progress);
   };
 
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 5243a14931..20324fe4f8 100644
--- a/web/src/lib/components/shared-components/settings/setting-dropdown.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-dropdown.svelte
@@ -43,7 +43,7 @@
           icon: option.icon,
         };
       }}
-      on:select={({ detail }) => onToggle(detail)}
+      onSelect={onToggle}
     />
   </div>
 </div>
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 c5b9e2c02e..92cabbff25 100644
--- a/web/src/lib/components/shared-components/settings/setting-select.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-select.svelte
@@ -1,7 +1,6 @@
 <script lang="ts">
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
-  import { createEventDispatcher } from 'svelte';
   import { t } from 'svelte-i18n';
   import Icon from '$lib/components/elements/icon.svelte';
   import { mdiChevronDown } from '@mdi/js';
@@ -14,15 +13,14 @@
   export let isEdited = false;
   export let number = false;
   export let disabled = false;
-
-  const dispatch = createEventDispatcher<{ select: string | number }>();
+  export let onSelect: (setting: string | number) => void = () => {};
 
   const handleChange = (e: Event) => {
     value = (e.target as HTMLInputElement).value;
     if (number) {
       value = Number.parseInt(value);
     }
-    dispatch('select', value);
+    onSelect(value);
   };
 </script>
 
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 24b539f0a1..11716526f8 100644
--- a/web/src/lib/components/shared-components/settings/setting-switch.svelte
+++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte
@@ -1,7 +1,6 @@
 <script lang="ts">
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
-  import { createEventDispatcher } from 'svelte';
   import Slider from '$lib/components/elements/slider.svelte';
   import { generateId } from '$lib/utils/generate-id';
   import { t } from 'svelte-i18n';
@@ -11,14 +10,12 @@
   export let checked = false;
   export let disabled = false;
   export let isEdited = false;
+  export let onToggle: (isChecked: boolean) => void = () => {};
 
   let id: string = generateId();
 
   $: sliderId = `${id}-slider`;
   $: subtitleId = subtitle ? `${id}-subtitle` : undefined;
-
-  const dispatch = createEventDispatcher<{ toggle: boolean }>();
-  const onToggle = (isChecked: boolean) => dispatch('toggle', isChecked);
 </script>
 
 <div class="flex place-items-center justify-between">
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 13ec440082..a63bdb3ca9 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
@@ -102,7 +102,7 @@
 {/if}
 
 {#if secret}
-  <APIKeySecret {secret} on:done={() => (secret = '')} />
+  <APIKeySecret {secret} onDone={() => (secret = '')} />
 {/if}
 
 {#if editKey}
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 2f1efc487c..fd5b68d8c3 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
@@ -151,15 +151,15 @@
       <AssetViewer
         asset={$viewingAsset}
         showNavigation={assets.length > 1}
-        on:next={() => {
+        onNext={() => {
           const index = getAssetIndex($viewingAsset.id) + 1;
           setAsset(assets[index % assets.length]);
         }}
-        on:previous={() => {
+        onPrevious={() => {
           const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
           setAsset(assets[index % assets.length]);
         }}
-        on:close={() => {
+        onClose={() => {
           assetViewingStore.showAssetViewer(false);
           handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
         }}
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 6e75273f3b..57d09ed53a 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
@@ -674,8 +674,8 @@
               disabled={!album.isActivityEnabled}
               {isLiked}
               numberOfComments={$numberOfComments}
-              on:favorite={handleFavorite}
-              on:openActivityTab={handleOpenAndCloseActivityTab}
+              onFavorite={handleFavorite}
+              onOpenActivityTab={handleOpenAndCloseActivityTab}
             />
           </div>
         {/if}
@@ -697,10 +697,10 @@
           albumId={album.id}
           {isLiked}
           bind:reactions
-          on:addComment={() => updateNumberOfComments(1)}
-          on:deleteComment={() => updateNumberOfComments(-1)}
-          on:deleteLike={() => (isLiked = null)}
-          on:close={handleOpenAndCloseActivityTab}
+          onAddComment={() => updateNumberOfComments(1)}
+          onDeleteComment={() => updateNumberOfComments(-1)}
+          onDeleteLike={() => (isLiked = null)}
+          onClose={handleOpenAndCloseActivityTab}
         />
       </div>
     </div>
@@ -709,8 +709,8 @@
 {#if viewMode === ViewMode.SELECT_USERS}
   <UserSelectionModal
     {album}
-    on:select={({ detail: users }) => handleAddUsers(users)}
-    on:share={() => (viewMode = ViewMode.LINK_SHARING)}
+    onSelect={handleAddUsers}
+    onShare={() => (viewMode = ViewMode.LINK_SHARING)}
     onClose={() => (viewMode = ViewMode.VIEW)}
   />
 {/if}
@@ -723,8 +723,8 @@
   <ShareInfoModal
     onClose={() => (viewMode = ViewMode.VIEW)}
     {album}
-    on:remove={({ detail: userId }) => handleRemoveUser(userId)}
-    on:refreshAlbum={refreshAlbum}
+    onRemove={handleRemoveUser}
+    onRefreshAlbum={refreshAlbum}
   />
 {/if}
 
@@ -737,9 +737,9 @@
       albumOrder = order;
       await setModeToView();
     }}
-    on:close={() => (viewMode = ViewMode.VIEW)}
-    on:toggleEnableActivity={handleToggleEnableActivity}
-    on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
+    onClose={() => (viewMode = ViewMode.VIEW)}
+    onToggleEnabledActivity={handleToggleEnableActivity}
+    onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
   />
 {/if}
 
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 0ea0ed18bb..2e109823ed 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
@@ -122,9 +122,9 @@
         <AssetViewer
           asset={$viewingAsset}
           showNavigation={viewingAssets.length > 1}
-          on:next={navigateNext}
-          on:previous={navigatePrevious}
-          on:close={() => {
+          onNext={navigateNext}
+          onPrevious={navigatePrevious}
+          onClose={() => {
             assetViewingStore.showAssetViewer(false);
             handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
           }}
@@ -137,11 +137,11 @@
   {#if showSettingsModal}
     <MapSettingsModal
       settings={{ ...$mapSettings }}
-      on:close={() => (showSettingsModal = false)}
-      on:save={async ({ detail }) => {
-        const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
+      onClose={() => (showSettingsModal = false)}
+      onSave={async (settings) => {
+        const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
         showSettingsModal = false;
-        $mapSettings = detail;
+        $mapSettings = settings;
 
         if (shouldUpdate) {
           mapMarkers = await loadMapMarkers();
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte
index f1a2674e24..b6d25c48bf 100644
--- a/web/src/routes/(user)/people/+page.svelte
+++ b/web/src/routes/(user)/people/+page.svelte
@@ -302,9 +302,9 @@
     {personMerge1}
     {personMerge2}
     {potentialMergePeople}
-    on:close={() => (showMergeModal = false)}
-    on:reject={() => changeName()}
-    on:confirm={(event) => handleMergeSamePerson(event.detail)}
+    onClose={() => (showMergeModal = false)}
+    onReject={changeName}
+    onConfirm={handleMergeSamePerson}
   />
 {/if}
 
@@ -349,10 +349,10 @@
       <PeopleCard
         {person}
         preload={index < 20}
-        on:change-name={() => handleChangeName(person)}
-        on:set-birth-date={() => handleSetBirthDate(person)}
-        on:merge-people={() => handleMergePeople(person)}
-        on:hide-person={() => handleHidePerson(person)}
+        onChangeName={() => handleChangeName(person)}
+        onSetBirthDate={() => handleSetBirthDate(person)}
+        onMergePeople={() => handleMergePeople(person)}
+        onHidePerson={() => handleHidePerson(person)}
       />
     </PeopleInfiniteScroll>
   {:else}
@@ -397,8 +397,8 @@
   {#if showSetBirthDateModal}
     <SetBirthDateModal
       birthDate={edittingPerson?.birthDate ?? ''}
-      on:close={() => (showSetBirthDateModal = false)}
-      on:updated={(event) => submitBirthDateChange(event.detail)}
+      onClose={() => (showSetBirthDateModal = false)}
+      onUpdate={submitBirthDateChange}
     />
   {/if}
 </UserPageLayout>
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 daa5821e85..bb648228b9 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
@@ -347,8 +347,8 @@
   <UnMergeFaceSelector
     assetIds={[...$selectedAssets].map((a) => a.id)}
     personAssets={person}
-    on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
-    on:confirm={handleUnmerge}
+    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onConfirm={handleUnmerge}
   />
 {/if}
 
@@ -357,22 +357,22 @@
     {personMerge1}
     {personMerge2}
     {potentialMergePeople}
-    on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
-    on:reject={() => changeName()}
-    on:confirm={(event) => handleMergeSamePerson(event.detail)}
+    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onReject={changeName}
+    onConfirm={handleMergeSamePerson}
   />
 {/if}
 
 {#if viewMode === ViewMode.BIRTH_DATE}
   <SetBirthDateModal
     birthDate={person.birthDate ?? ''}
-    on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
-    on:updated={(event) => handleSetBirthDate(event.detail)}
+    onClose={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    onUpdate={handleSetBirthDate}
   />
 {/if}
 
 {#if viewMode === ViewMode.MERGE_PEOPLE}
-  <MergeFaceSelector {person} on:back={handleGoBack} on:merge={({ detail }) => handleMerge(detail)} />
+  <MergeFaceSelector {person} onBack={handleGoBack} onMerge={handleMerge} />
 {/if}
 
 <header>
@@ -464,7 +464,7 @@
                 bind:suggestedPeople
                 name={person.name}
                 bind:isSearchingPeople
-                on:change={(event) => handleNameChange(event.detail)}
+                onChange={handleNameChange}
                 {thumbnailData}
               />
             {:else}
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte
index 74db5628ba..5ce3296a03 100644
--- a/web/src/routes/admin/library-management/+page.svelte
+++ b/web/src/routes/admin/library-management/+page.svelte
@@ -267,10 +267,7 @@
 </script>
 
 {#if toCreateLibrary}
-  <LibraryUserPickerForm
-    on:submit={({ detail }) => handleCreate(detail.ownerId)}
-    on:cancel={() => (toCreateLibrary = false)}
-  />
+  <LibraryUserPickerForm onSubmit={handleCreate} onCancel={() => (toCreateLibrary = false)} />
 {/if}
 
 <UserPageLayout title={data.meta.title} admin>
@@ -385,28 +382,20 @@
               </tr>
               {#if renameLibrary === index}
                 <div transition:slide={{ duration: 250 }}>
-                  <LibraryRenameForm
-                    {library}
-                    on:submit={({ detail }) => handleUpdate(detail)}
-                    on:cancel={() => (renameLibrary = null)}
-                  />
+                  <LibraryRenameForm {library} onSubmit={handleUpdate} onCancel={() => (renameLibrary = null)} />
                 </div>
               {/if}
               {#if editImportPaths === index}
                 <div transition:slide={{ duration: 250 }}>
-                  <LibraryImportPathsForm
-                    {library}
-                    on:submit={({ detail }) => handleUpdate(detail)}
-                    on:cancel={() => (editImportPaths = null)}
-                  />
+                  <LibraryImportPathsForm {library} onSubmit={handleUpdate} onCancel={() => (editImportPaths = null)} />
                 </div>
               {/if}
               {#if editScanSettings === index}
                 <div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
                   <LibraryScanSettingsForm
                     {library}
-                    on:submit={({ detail: library }) => handleUpdate(library)}
-                    on:cancel={() => (editScanSettings = null)}
+                    onSubmit={handleUpdate}
+                    onCancel={() => (editScanSettings = null)}
                   />
                 </div>
               {/if}
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index b040ce293c..2313b17cb1 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -110,8 +110,8 @@
     <section class="w-full pb-28 lg:w-[850px]">
       {#if shouldShowCreateUserForm}
         <CreateUserForm
-          on:submit={onUserCreated}
-          on:cancel={() => (shouldShowCreateUserForm = false)}
+          onSubmit={onUserCreated}
+          onCancel={() => (shouldShowCreateUserForm = false)}
           onClose={() => (shouldShowCreateUserForm = false)}
         />
       {/if}
@@ -121,8 +121,8 @@
           user={selectedUser}
           bind:newPassword
           canResetPassword={selectedUser?.id !== $user.id}
-          on:editSuccess={onEditUserSuccess}
-          on:resetPasswordSuccess={onEditPasswordSuccess}
+          onEditSuccess={onEditUserSuccess}
+          onResetPasswordSuccess={onEditPasswordSuccess}
           onClose={() => (shouldShowEditUserForm = false)}
         />
       {/if}
diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte
index aa23e4e7d2..eaf5a88fe2 100644
--- a/web/src/routes/auth/change-password/+page.svelte
+++ b/web/src/routes/auth/change-password/+page.svelte
@@ -25,5 +25,5 @@
     {$t('change_password_description')}
   </p>
 
-  <ChangePasswordForm on:success={onSuccess} />
+  <ChangePasswordForm {onSuccess} />
 </FullscreenContainer>