From f21fe8716c1033f22a2fa82e4c4a1db1e6d9982d Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Fri, 9 May 2025 19:24:36 +0200
Subject: [PATCH] refactor: shortcuts modal (#18175)

---
 .../components/photos-page/asset-grid.svelte  |  37 ++++---
 .../gallery-viewer/gallery-viewer.svelte      |  38 ++++---
 .../shared-components/show-shortcuts.svelte   |  94 ----------------
 web/src/lib/modals/ShortcutsModal.svelte      | 100 ++++++++++++++++++
 .../routes/(user)/user-settings/+page.svelte  |  15 +--
 .../[[assetId=id]]/+page.svelte               |   9 +-
 6 files changed, 153 insertions(+), 140 deletions(-)
 delete mode 100644 web/src/lib/components/shared-components/show-shortcuts.svelte
 create mode 100644 web/src/lib/modals/ShortcutsModal.svelte

diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index 5b48f0494f..ba021b8ad1 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -1,33 +1,34 @@
 <script lang="ts">
   import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
+  import { page } from '$app/stores';
+  import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
   import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
   import type { Action } from '$lib/components/asset-viewer/actions/action';
+  import Skeleton from '$lib/components/photos-page/skeleton.svelte';
   import { AppRoute, AssetAction } from '$lib/constants';
+  import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
+  import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
+  import { mobileDevice } from '$lib/stores/mobile-device.svelte';
   import { showDeleteModal } from '$lib/stores/preferences.store';
   import { searchStore } from '$lib/stores/search.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { handlePromiseError } from '$lib/utils';
   import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
   import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
+  import { focusNext } from '$lib/utils/focus-util';
   import { navigate } from '$lib/utils/navigation';
   import { type ScrubberListener } from '$lib/utils/timeline-util';
   import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
   import { onMount, type Snippet } from 'svelte';
+  import type { UpdatePayload } from 'vite';
   import Portal from '../shared-components/portal/portal.svelte';
   import Scrubber from '../shared-components/scrubber/scrubber.svelte';
-  import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
   import AssetDateGroup from './asset-date-group.svelte';
   import DeleteAssetDialog from './delete-asset-dialog.svelte';
-  import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
-  import Skeleton from '$lib/components/photos-page/skeleton.svelte';
-  import { page } from '$app/stores';
-  import type { UpdatePayload } from 'vite';
-  import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
-  import { mobileDevice } from '$lib/stores/mobile-device.svelte';
-  import { focusNext } from '$lib/utils/focus-util';
-  import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
 
   interface Props {
     isSelectionMode?: boolean;
@@ -75,7 +76,6 @@
   let element: HTMLElement | undefined = $state();
 
   let timelineElement: HTMLElement | undefined = $state();
-  let showShortcuts = $state(false);
   let showSkeleton = $state(true);
   let scrubBucketPercent = $state(0);
   let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
@@ -623,6 +623,17 @@
   let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
   let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
   let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
+  let isShortcutModalOpen = false;
+
+  const handleOpenShortcutModal = async () => {
+    if (isShortcutModalOpen) {
+      return;
+    }
+
+    isShortcutModalOpen = true;
+    await modalManager.show(ShortcutsModal, {});
+    isShortcutModalOpen = false;
+  };
 
   $effect(() => {
     if (isEmpty) {
@@ -638,7 +649,7 @@
 
       const shortcuts: ShortcutOptions[] = [
         { shortcut: { key: 'Escape' }, onShortcut: onEscape },
-        { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
+        { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
         { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
         { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
         { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
@@ -690,10 +701,6 @@
   />
 {/if}
 
-{#if showShortcuts}
-  <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
-{/if}
-
 {#if assetStore.buckets.length > 0}
   <Scrubber
     {assetStore}
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 09c126e0df..b820868b59 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
@@ -1,28 +1,29 @@
 <script lang="ts">
-  import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
   import { goto } from '$app/navigation';
+  import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
   import type { Action } from '$lib/components/asset-viewer/actions/action';
   import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
   import { AppRoute, AssetAction } from '$lib/constants';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
+  import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import type { Viewport } from '$lib/stores/assets-store.svelte';
   import { showDeleteModal } from '$lib/stores/preferences.store';
+  import { featureFlags } from '$lib/stores/server-config.store';
+  import { handlePromiseError } from '$lib/utils';
   import { deleteAssets } from '$lib/utils/actions';
   import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
-  import { featureFlags } from '$lib/stores/server-config.store';
+  import { focusNext } from '$lib/utils/focus-util';
   import { handleError } from '$lib/utils/handle-error';
+  import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
   import { navigate } from '$lib/utils/navigation';
   import { type AssetResponseDto } from '@immich/sdk';
+  import { debounce } from 'lodash-es';
   import { t } from 'svelte-i18n';
   import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
-  import ShowShortcuts from '../show-shortcuts.svelte';
-  import Portal from '../portal/portal.svelte';
-  import { handlePromiseError } from '$lib/utils';
   import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
-  import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
-  import { debounce } from 'lodash-es';
-  import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
-  import { focusNext } from '$lib/utils/focus-util';
+  import Portal from '../portal/portal.svelte';
 
   interface Props {
     assets: AssetResponseDto[];
@@ -106,7 +107,6 @@
     };
   });
 
-  let showShortcuts = $state(false);
   let currentViewAssetIndex = 0;
   let shiftKeyIsDown = $state(false);
   let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
@@ -263,6 +263,18 @@
   const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
   const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
 
+  let isShortcutModalOpen = false;
+
+  const handleOpenShortcutModal = async () => {
+    if (isShortcutModalOpen) {
+      return;
+    }
+
+    isShortcutModalOpen = true;
+    await modalManager.show(ShortcutsModal, {});
+    isShortcutModalOpen = false;
+  };
+
   let shortcutList = $derived(
     (() => {
       if ($isViewerOpen) {
@@ -270,7 +282,7 @@
       }
 
       const shortcuts: ShortcutOptions[] = [
-        { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
+        { shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
         { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
         { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
         { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
@@ -439,10 +451,6 @@
   />
 {/if}
 
-{#if showShortcuts}
-  <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
-{/if}
-
 {#if assets.length > 0}
   <div
     style:position="relative"
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte
deleted file mode 100644
index e1454b49df..0000000000
--- a/web/src/lib/components/shared-components/show-shortcuts.svelte
+++ /dev/null
@@ -1,94 +0,0 @@
-<script lang="ts">
-  import { mdiInformationOutline } from '@mdi/js';
-  import { t } from 'svelte-i18n';
-  import Icon from '../elements/icon.svelte';
-  import FullScreenModal from './full-screen-modal.svelte';
-
-  interface Shortcuts {
-    general: ExplainedShortcut[];
-    actions: ExplainedShortcut[];
-  }
-
-  interface ExplainedShortcut {
-    key: string[];
-    action: string;
-    info?: string;
-  }
-
-  interface Props {
-    onClose: () => void;
-    shortcuts?: Shortcuts;
-  }
-
-  let {
-    onClose,
-    shortcuts = {
-      general: [
-        { key: ['←', '→'], action: $t('previous_or_next_photo') },
-        { key: ['x'], action: $t('select') },
-        { key: ['Esc'], action: $t('back_close_deselect') },
-        { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
-        { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
-      ],
-      actions: [
-        { key: ['f'], action: $t('favorite_or_unfavorite_photo') },
-        { key: ['i'], action: $t('show_or_hide_info') },
-        { key: ['s'], action: $t('stack_selected_photos') },
-        { key: ['l'], action: $t('add_to_album') },
-        { key: ['⇧', 'l'], action: $t('add_to_shared_album') },
-        { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
-        { key: ['⇧', 'd'], action: $t('download') },
-        { key: ['Space'], action: $t('play_or_pause_video') },
-        { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
-      ],
-    },
-  }: Props = $props();
-</script>
-
-<FullScreenModal title={$t('keyboard_shortcuts')} width="auto" {onClose}>
-  <div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
-    {#if shortcuts.general.length > 0}
-      <div class="p-4">
-        <h2>{$t('general')}</h2>
-        <div class="text-sm">
-          {#each shortcuts.general as shortcut (shortcut.key.join('-'))}
-            <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
-              <div class="flex justify-self-end">
-                {#each shortcut.key as key (key)}
-                  <p class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
-                    {key}
-                  </p>
-                {/each}
-              </div>
-              <p class="mb-1 mt-1 flex">{shortcut.action}</p>
-            </div>
-          {/each}
-        </div>
-      </div>
-    {/if}
-    {#if shortcuts.actions.length > 0}
-      <div class="p-4">
-        <h2>{$t('actions')}</h2>
-        <div class="text-sm">
-          {#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
-            <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
-              <div class="flex justify-self-end">
-                {#each shortcut.key as key (key)}
-                  <p class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
-                    {key}
-                  </p>
-                {/each}
-              </div>
-              <div class="flex items-center gap-2">
-                <p class="mb-1 mt-1 flex">{shortcut.action}</p>
-                {#if shortcut.info}
-                  <Icon path={mdiInformationOutline} title={shortcut.info} />
-                {/if}
-              </div>
-            </div>
-          {/each}
-        </div>
-      </div>
-    {/if}
-  </div>
-</FullScreenModal>
diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte
new file mode 100644
index 0000000000..2f16eaa817
--- /dev/null
+++ b/web/src/lib/modals/ShortcutsModal.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+  import { Modal, ModalBody } from '@immich/ui';
+  import { mdiInformationOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+  import Icon from '../components/elements/icon.svelte';
+
+  interface Shortcuts {
+    general: ExplainedShortcut[];
+    actions: ExplainedShortcut[];
+  }
+
+  interface ExplainedShortcut {
+    key: string[];
+    action: string;
+    info?: string;
+  }
+
+  interface Props {
+    onClose: () => void;
+    shortcuts?: Shortcuts;
+  }
+
+  let {
+    onClose,
+    shortcuts = {
+      general: [
+        { key: ['←', '→'], action: $t('previous_or_next_photo') },
+        { key: ['x'], action: $t('select') },
+        { key: ['Esc'], action: $t('back_close_deselect') },
+        { key: ['Ctrl', 'k'], action: $t('search_your_photos') },
+        { key: ['Ctrl', '⇧', 'k'], action: $t('open_the_search_filters') },
+      ],
+      actions: [
+        { key: ['f'], action: $t('favorite_or_unfavorite_photo') },
+        { key: ['i'], action: $t('show_or_hide_info') },
+        { key: ['s'], action: $t('stack_selected_photos') },
+        { key: ['l'], action: $t('add_to_album') },
+        { key: ['⇧', 'l'], action: $t('add_to_shared_album') },
+        { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
+        { key: ['⇧', 'd'], action: $t('download') },
+        { key: ['Space'], action: $t('play_or_pause_video') },
+        { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
+      ],
+    },
+  }: Props = $props();
+</script>
+
+<Modal title={$t('keyboard_shortcuts')} size="medium" {onClose}>
+  <ModalBody>
+    <div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
+      {#if shortcuts.general.length > 0}
+        <div class="p-4">
+          <h2>{$t('general')}</h2>
+          <div class="text-sm">
+            {#each shortcuts.general as shortcut (shortcut.key.join('-'))}
+              <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
+                <div class="flex justify-self-end">
+                  {#each shortcut.key as key (key)}
+                    <p
+                      class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
+                    >
+                      {key}
+                    </p>
+                  {/each}
+                </div>
+                <p class="mb-1 mt-1 flex">{shortcut.action}</p>
+              </div>
+            {/each}
+          </div>
+        </div>
+      {/if}
+      {#if shortcuts.actions.length > 0}
+        <div class="p-4">
+          <h2>{$t('actions')}</h2>
+          <div class="text-sm">
+            {#each shortcuts.actions as shortcut (shortcut.key.join('-'))}
+              <div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
+                <div class="flex justify-self-end">
+                  {#each shortcut.key as key (key)}
+                    <p
+                      class="me-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
+                    >
+                      {key}
+                    </p>
+                  {/each}
+                </div>
+                <div class="flex items-center gap-2">
+                  <p class="mb-1 mt-1 flex">{shortcut.action}</p>
+                  {#if shortcut.info}
+                    <Icon path={mdiInformationOutline} title={shortcut.info} />
+                  {/if}
+                </div>
+              </div>
+            {/each}
+          </div>
+        </div>
+      {/if}
+    </div>
+  </ModalBody>
+</Modal>
diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte
index 54d54f5b40..028941cdd6 100644
--- a/web/src/routes/(user)/user-settings/+page.svelte
+++ b/web/src/routes/(user)/user-settings/+page.svelte
@@ -1,19 +1,18 @@
 <script lang="ts">
+  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
   import { mdiKeyboard } from '@mdi/js';
-  import type { PageData } from './$types';
-  import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
-  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { t } from 'svelte-i18n';
+  import type { PageData } from './$types';
 
   interface Props {
     data: PageData;
   }
 
   let { data }: Props = $props();
-
-  let isShowKeyboardShortcut = $state(false);
 </script>
 
 <UserPageLayout title={data.meta.title}>
@@ -21,7 +20,7 @@
     <CircleIconButton
       icon={mdiKeyboard}
       title={$t('show_keyboard_shortcuts')}
-      onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
+      onclick={() => modalManager.show(ShortcutsModal, {})}
     />
   {/snippet}
   <section class="mx-4 flex place-content-center">
@@ -30,7 +29,3 @@
     </div>
   </section>
 </UserPageLayout>
-
-{#if isShowKeyboardShortcut}
-  <ShowShortcuts onClose={() => (isShowKeyboardShortcut = false)} />
-{/if}
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 6c21260f99..14b1420110 100644
--- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -7,8 +7,9 @@
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
-  import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
   import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { stackAssets } from '$lib/utils/asset-utils';
@@ -27,7 +28,6 @@
 
   let { data = $bindable() }: Props = $props();
 
-  let isShowKeyboardShortcut = $state(false);
   let isShowDuplicateInfo = $state(false);
 
   interface Shortcuts {
@@ -185,7 +185,7 @@
         color="secondary"
         icon={mdiKeyboard}
         title={$t('show_keyboard_shortcuts')}
-        onclick={() => (isShowKeyboardShortcut = !isShowKeyboardShortcut)}
+        onclick={() => modalManager.show(ShortcutsModal, { shortcuts: duplicateShortcuts })}
         aria-label={$t('show_keyboard_shortcuts')}
       />
     </HStack>
@@ -222,9 +222,6 @@
   </div>
 </UserPageLayout>
 
-{#if isShowKeyboardShortcut}
-  <ShowShortcuts shortcuts={duplicateShortcuts} onClose={() => (isShowKeyboardShortcut = false)} />
-{/if}
 {#if isShowDuplicateInfo}
   <DuplicatesModal onClose={() => (isShowDuplicateInfo = false)} />
 {/if}