From a6e5e4f62524f65f489cbe56e6f47398e2227b00 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jason@rasm.me>
Date: Wed, 7 May 2025 17:14:20 -0400
Subject: [PATCH 1/5] fix: schema ci checks (#18146)

---
 .github/workflows/test.yml                    |  6 +++---
 server/src/schema/enums.ts                    |  7 ++++++-
 server/src/schema/index.ts                    | 12 +++--------
 .../1746636476623-DropExtraIndexes.ts         | 21 +++++++++++++++++++
 server/src/schema/tables/asset.table.ts       |  3 +--
 .../tables/natural-earth-countries.table.ts   |  2 +-
 6 files changed, 35 insertions(+), 16 deletions(-)
 create mode 100644 server/src/schema/migrations/1746636476623-DropExtraIndexes.ts

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 64c084fa2e..d52ca4e6f7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -593,8 +593,8 @@ jobs:
           echo "Changed files: ${CHANGED_FILES}"
           exit 1
 
-  generated-typeorm-migrations-up-to-date:
-    name: TypeORM Checks
+  sql-schema-up-to-date:
+    name: SQL Schema Checks
     runs-on: ubuntu-latest
     permissions:
       contents: read
@@ -641,7 +641,7 @@ jobs:
 
       - name: Generate new migrations
         continue-on-error: true
-        run: npm run migrations:generate TestMigration
+        run: npm run migrations:generate src/TestMigration
 
       - name: Find file changes
         uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts
index 100b92aa63..a1134df6bc 100644
--- a/server/src/schema/enums.ts
+++ b/server/src/schema/enums.ts
@@ -1,4 +1,4 @@
-import { AssetStatus, SourceType } from 'src/enum';
+import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
 import { registerEnum } from 'src/sql-tools';
 
 export const assets_status_enum = registerEnum({
@@ -10,3 +10,8 @@ export const asset_face_source_type = registerEnum({
   name: 'sourcetype',
   values: Object.values(SourceType),
 });
+
+export const asset_visibility_enum = registerEnum({
+  name: 'asset_visibility_enum',
+  values: Object.values(AssetVisibility),
+});
diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts
index 1800f08c13..735dfd3ae9 100644
--- a/server/src/schema/index.ts
+++ b/server/src/schema/index.ts
@@ -1,5 +1,4 @@
-import { AssetVisibility } from 'src/enum';
-import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
+import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
 import {
   assets_delete_audit,
   f_concat_ws,
@@ -46,12 +45,7 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table';
 import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
 import { UserTable } from 'src/schema/tables/user.table';
 import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
-import { ConfigurationParameter, Database, Extensions, registerEnum } from 'src/sql-tools';
-
-export const asset_visibility_enum = registerEnum({
-  name: 'asset_visibility_enum',
-  values: Object.values(AssetVisibility),
-});
+import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
 
 @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
 @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
@@ -107,5 +101,5 @@ export class ImmichDatabase {
     assets_delete_audit,
   ];
 
-  enum = [assets_status_enum, asset_face_source_type];
+  enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
 }
diff --git a/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts b/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts
new file mode 100644
index 0000000000..aae52829a5
--- /dev/null
+++ b/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts
@@ -0,0 +1,21 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely<any>): Promise<void> {
+  const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db);
+  const databaseName = rows[0].db;
+  await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db);
+  await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "PK_21a6d86d1ab5d841648212e5353";`.execute(db);
+  await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "naturalearth_countries_pkey";`.execute(db);
+  await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db);
+  await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db);
+  await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db);
+  await sql`DROP INDEX IF EXISTS "IDX_7e077a8b70b3530138610ff5e0";`.execute(db);
+  await sql`DROP INDEX IF EXISTS "IDX_92e67dc508c705dd66c9461557";`.execute(db);
+  await sql`DROP INDEX IF EXISTS "IDX_6afb43681a21cf7815932bc38a";`.execute(db);
+}
+
+export async function down(db: Kysely<any>): Promise<void> {
+  const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db);
+  const databaseName = rows[0].db;
+  await sql.raw(`ALTER DATABASE "${databaseName}" RESET "search_path"`).execute(db);
+}
diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts
index 4552ac158d..d337984a46 100644
--- a/server/src/schema/tables/asset.table.ts
+++ b/server/src/schema/tables/asset.table.ts
@@ -1,7 +1,6 @@
 import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
 import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
-import { asset_visibility_enum } from 'src/schema';
-import { assets_status_enum } from 'src/schema/enums';
+import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
 import { assets_delete_audit } from 'src/schema/functions';
 import { LibraryTable } from 'src/schema/tables/library.table';
 import { StackTable } from 'src/schema/tables/stack.table';
diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts
index df1132d17d..e5e6ead772 100644
--- a/server/src/schema/tables/natural-earth-countries.table.ts
+++ b/server/src/schema/tables/natural-earth-countries.table.ts
@@ -1,6 +1,6 @@
 import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
 
-@Table({ name: 'naturalearth_countries' })
+@Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' })
 export class NaturalEarthCountriesTable {
   @PrimaryGeneratedColumn({ strategy: 'identity' })
   id!: number;

From 09ced9a1717a8da8104d0c26f7de167053f9a1e0 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Wed, 7 May 2025 23:31:38 +0200
Subject: [PATCH 2/5] refactor: help modal (#18145)

---
 .../navigation-bar/navigation-bar.svelte      | 16 ++++------------
 .../HelpAndFeedbackModal.svelte}              | 19 +++++++++----------
 2 files changed, 13 insertions(+), 22 deletions(-)
 rename web/src/lib/{components/shared-components/help-and-feedback-modal.svelte => modals/HelpAndFeedbackModal.svelte} (94%)

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 53b90798d9..915b041d4e 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
@@ -6,12 +6,13 @@
   import { page } from '$app/state';
   import { clickOutside } from '$lib/actions/click-outside';
   import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
-  import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte';
   import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
   import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte';
   import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
   import { AppRoute } from '$lib/constants';
   import { authManager } from '$lib/managers/auth-manager.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import HelpAndFeedbackModal from '$lib/modals/HelpAndFeedbackModal.svelte';
   import { mobileDevice } from '$lib/stores/mobile-device.svelte';
   import { notificationManager } from '$lib/stores/notification-manager.svelte';
   import { featureFlags } from '$lib/stores/server-config.store';
@@ -35,7 +36,6 @@
   let { showUploadButton = true, onUploadClick }: Props = $props();
 
   let shouldShowAccountInfoPanel = $state(false);
-  let shouldShowHelpPanel = $state(false);
   let shouldShowNotificationPanel = $state(false);
   let innerWidth: number = $state(0);
   const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
@@ -49,10 +49,6 @@
 
 <svelte:window bind:innerWidth />
 
-{#if shouldShowHelpPanel && info}
-  <HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} {info} />
-{/if}
-
 <nav id="dashboard-navbar" class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm">
   <SkipLink text={$t('skip_to_content')} />
   <div
@@ -129,18 +125,14 @@
 
         <ThemeButton padding="2" />
 
-        <div
-          use:clickOutside={{
-            onEscape: () => (shouldShowHelpPanel = false),
-          }}
-        >
+        <div>
           <IconButton
             shape="round"
             color="secondary"
             variant="ghost"
             size="medium"
             icon={mdiHelpCircleOutline}
-            onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
+            onclick={() => info && modalManager.open(HelpAndFeedbackModal, { info })}
             aria-label={$t('support_and_feedback')}
           />
         </div>
diff --git a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte b/web/src/lib/modals/HelpAndFeedbackModal.svelte
similarity index 94%
rename from web/src/lib/components/shared-components/help-and-feedback-modal.svelte
rename to web/src/lib/modals/HelpAndFeedbackModal.svelte
index 1dcb021d78..edc78b3bf4 100644
--- a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte
+++ b/web/src/lib/modals/HelpAndFeedbackModal.svelte
@@ -1,11 +1,10 @@
 <script lang="ts">
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import Portal from '$lib/components/shared-components/portal/portal.svelte';
-  import { type ServerAboutResponseDto } from '@immich/sdk';
-  import { t } from 'svelte-i18n';
-  import Icon from '$lib/components/elements/icon.svelte';
-  import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
   import { discordPath, discordViewBox } from '$lib/assets/svg-paths';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { type ServerAboutResponseDto } from '@immich/sdk';
+  import { Modal, ModalBody } from '@immich/ui';
+  import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
+  import { t } from 'svelte-i18n';
 
   interface Props {
     onClose: () => void;
@@ -15,8 +14,8 @@
   let { onClose, info }: Props = $props();
 </script>
 
-<Portal>
-  <FullScreenModal title={$t('support_and_feedback')} {onClose}>
+<Modal title={$t('support_and_feedback')} {onClose} size="small">
+  <ModalBody>
     <p>{$t('official_immich_resources')}</p>
     <div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5">
       <div>
@@ -130,5 +129,5 @@
         {/if}
       </div>
     {/if}
-  </FullScreenModal>
-</Portal>
+  </ModalBody>
+</Modal>

From a169fb6a79f33cc24f3a4d81abb68859b79826cc Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Wed, 7 May 2025 23:39:50 +0200
Subject: [PATCH 3/5] refactor: map (#18143)

---
 .../components/album-page/album-map.svelte    |   1 +
 .../asset-viewer/detail-panel.svelte          |   1 +
 .../map-page/map-settings-modal.svelte        | 133 -----------------
 .../shared-components/change-location.svelte  |   1 +
 .../shared-components/map/map.svelte          |  86 ++++++++++-
 web/src/lib/modals/MapSettingsModal.svelte    | 135 ++++++++++++++++++
 .../[[assetId=id]]/+page.svelte               |  85 +----------
 7 files changed, 220 insertions(+), 222 deletions(-)
 delete mode 100644 web/src/lib/components/map-page/map-settings-modal.svelte
 create mode 100644 web/src/lib/modals/MapSettingsModal.svelte

diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte
index 8230dea92e..871e26b4f9 100644
--- a/web/src/lib/components/album-page/album-map.svelte
+++ b/web/src/lib/components/album-page/album-map.svelte
@@ -130,6 +130,7 @@
               clickable={false}
               bind:mapMarkers
               onSelect={onViewAssets}
+              showSettings={false}
             />
           {/await}
         </div>
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 15bc42d001..d672b1a8b0 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -494,6 +494,7 @@
           },
         ]}
         center={latlng}
+        showSettings={false}
         zoom={12.5}
         simplified
         useLocationPin
diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte
deleted file mode 100644
index c1ee1e0b80..0000000000
--- a/web/src/lib/components/map-page/map-settings-modal.svelte
+++ /dev/null
@@ -1,133 +0,0 @@
-<script lang="ts">
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
-  import type { MapSettings } from '$lib/stores/preferences.store';
-  import { Button, Field, Stack, Switch } from '@immich/ui';
-  import { Duration } from 'luxon';
-  import { t } from 'svelte-i18n';
-  import { fly } from 'svelte/transition';
-  import DateInput from '../elements/date-input.svelte';
-
-  interface Props {
-    settings: MapSettings;
-    onClose: () => void;
-    onSave: (settings: MapSettings) => void;
-  }
-
-  let { settings: initialValues, onClose, onSave }: Props = $props();
-  let settings = $state(initialValues);
-
-  let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
-
-  const onsubmit = (event: Event) => {
-    event.preventDefault();
-    onSave(settings);
-  };
-</script>
-
-<form {onsubmit}>
-  <FullScreenModal title={$t('map_settings')} {onClose}>
-    <Stack gap={4}>
-      <Field label={$t('allow_dark_mode')}>
-        <Switch bind:checked={settings.allowDarkMode} />
-      </Field>
-      <Field label={$t('only_favorites')}>
-        <Switch bind:checked={settings.onlyFavorites} />
-      </Field>
-      <Field label={$t('include_archived')}>
-        <Switch bind:checked={settings.includeArchived} />
-      </Field>
-      <Field label={$t('include_shared_partner_assets')}>
-        <Switch bind:checked={settings.withPartners} />
-      </Field>
-      <Field label={$t('include_shared_albums')}>
-        <Switch bind:checked={settings.withSharedAlbums} />
-      </Field>
-
-      {#if customDateRange}
-        <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
-          <div class="flex items-center justify-between gap-8">
-            <label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
-            <DateInput
-              class="immich-form-input w-40"
-              type="date"
-              id="date-after"
-              max={settings.dateBefore}
-              bind:value={settings.dateAfter}
-            />
-          </div>
-          <div class="flex items-center justify-between gap-8">
-            <label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
-            <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
-          </div>
-          <div class="flex justify-center text-xs">
-            <Button
-              color="primary"
-              size="small"
-              variant="ghost"
-              onclick={() => {
-                customDateRange = false;
-                settings.dateAfter = '';
-                settings.dateBefore = '';
-              }}
-            >
-              {$t('remove_custom_date_range')}
-            </Button>
-          </div>
-        </div>
-      {:else}
-        <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
-          <SettingSelect
-            label={$t('date_range')}
-            name="date-range"
-            bind:value={settings.relativeDate}
-            options={[
-              {
-                value: '',
-                text: $t('all'),
-              },
-              {
-                value: Duration.fromObject({ hours: 24 }).toISO() || '',
-                text: $t('past_durations.hours', { values: { hours: 24 } }),
-              },
-              {
-                value: Duration.fromObject({ days: 7 }).toISO() || '',
-                text: $t('past_durations.days', { values: { days: 7 } }),
-              },
-              {
-                value: Duration.fromObject({ days: 30 }).toISO() || '',
-                text: $t('past_durations.days', { values: { days: 30 } }),
-              },
-              {
-                value: Duration.fromObject({ years: 1 }).toISO() || '',
-                text: $t('past_durations.years', { values: { years: 1 } }),
-              },
-              {
-                value: Duration.fromObject({ years: 3 }).toISO() || '',
-                text: $t('past_durations.years', { values: { years: 3 } }),
-              },
-            ]}
-          />
-          <div class="text-xs">
-            <Button
-              color="primary"
-              size="small"
-              variant="ghost"
-              onclick={() => {
-                customDateRange = true;
-                settings.relativeDate = '';
-              }}
-            >
-              {$t('use_custom_date_range')}
-            </Button>
-          </div>
-        </div>
-      {/if}
-    </Stack>
-
-    {#snippet stickyBottom()}
-      <Button color="secondary" shape="round" fullWidth onclick={onClose}>{$t('cancel')}</Button>
-      <Button type="submit" shape="round" fullWidth>{$t('save')}</Button>
-    {/snippet}
-  </FullScreenModal>
-</form>
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte
index 3539945911..e502d9aeda 100644
--- a/web/src/lib/components/shared-components/change-location.svelte
+++ b/web/src/lib/components/shared-components/change-location.svelte
@@ -190,6 +190,7 @@
             simplified={true}
             clickable={true}
             onClickPoint={(selected) => (point = selected)}
+            showSettings={false}
           />
         {/await}
       </div>
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index 5adeb2f00f..5742799409 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -9,15 +9,20 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
   import { Theme } from '$lib/constants';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
   import { themeManager } from '$lib/managers/theme-manager.svelte';
+  import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
   import { mapSettings } from '$lib/stores/preferences.store';
   import { serverConfig } from '$lib/stores/server-config.store';
   import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
-  import { type MapMarkerResponseDto } from '@immich/sdk';
+  import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
   import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
   import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
   import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
+  import { isEqual, omit } from 'lodash-es';
+  import { DateTime, Duration } from 'luxon';
   import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
+  import { onDestroy, onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import {
     AttributionControl,
@@ -36,8 +41,8 @@
   } from 'svelte-maplibre';
 
   interface Props {
-    mapMarkers: MapMarkerResponseDto[];
-    showSettingsModal?: boolean | undefined;
+    mapMarkers?: MapMarkerResponseDto[];
+    showSettings?: boolean;
     zoom?: number | undefined;
     center?: LngLatLike | undefined;
     hash?: boolean;
@@ -51,8 +56,8 @@
   }
 
   let {
-    mapMarkers = $bindable(),
-    showSettingsModal = $bindable(undefined),
+    mapMarkers = $bindable([]),
+    showSettings = true,
     zoom = undefined,
     center = $bindable(undefined),
     hash = false,
@@ -67,6 +72,7 @@
 
   let map: maplibregl.Map | undefined = $state();
   let marker: maplibregl.Marker | null = null;
+  let abortController: AbortController;
 
   const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
   const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
@@ -143,6 +149,72 @@
     };
   };
 
+  function getFileCreatedDates() {
+    const { relativeDate, dateAfter, dateBefore } = $mapSettings;
+
+    if (relativeDate) {
+      const duration = Duration.fromISO(relativeDate);
+      return {
+        fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
+      };
+    }
+
+    try {
+      return {
+        fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
+        fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
+      };
+    } catch {
+      $mapSettings.dateAfter = '';
+      $mapSettings.dateBefore = '';
+      return {};
+    }
+  }
+
+  async function loadMapMarkers() {
+    if (abortController) {
+      abortController.abort();
+    }
+    abortController = new AbortController();
+
+    const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
+    const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
+
+    return await getMapMarkers(
+      {
+        isArchived: includeArchived && undefined,
+        isFavorite: onlyFavorites || undefined,
+        fileCreatedAfter: fileCreatedAfter || undefined,
+        fileCreatedBefore,
+        withPartners: withPartners || undefined,
+        withSharedAlbums: withSharedAlbums || undefined,
+      },
+      {
+        signal: abortController.signal,
+      },
+    );
+  }
+
+  const handleSettingsClick = async () => {
+    const settings = await modalManager.open(MapSettingsModal, { settings: { ...$mapSettings } });
+    if (settings) {
+      const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
+      $mapSettings = settings;
+
+      if (shouldUpdate) {
+        mapMarkers = await loadMapMarkers();
+      }
+    }
+  };
+
+  onMount(async () => {
+    mapMarkers = await loadMapMarkers();
+  });
+
+  onDestroy(() => {
+    abortController.abort();
+  });
+
   $effect(() => {
     map?.setStyle(styleUrl, {
       transformStyle: (previousStyle, nextStyle) => {
@@ -199,10 +271,10 @@
       <AttributionControl compact={false} />
     {/if}
 
-    {#if showSettingsModal !== undefined}
+    {#if showSettings}
       <Control>
         <ControlGroup>
-          <ControlButton onclick={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton>
+          <ControlButton onclick={handleSettingsClick}><Icon path={mdiCog} size="100%" /></ControlButton>
         </ControlGroup>
       </Control>
     {/if}
diff --git a/web/src/lib/modals/MapSettingsModal.svelte b/web/src/lib/modals/MapSettingsModal.svelte
new file mode 100644
index 0000000000..e7bef2ecaf
--- /dev/null
+++ b/web/src/lib/modals/MapSettingsModal.svelte
@@ -0,0 +1,135 @@
+<script lang="ts">
+  import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
+  import type { MapSettings } from '$lib/stores/preferences.store';
+  import { Button, Field, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui';
+  import { Duration } from 'luxon';
+  import { t } from 'svelte-i18n';
+  import { fly } from 'svelte/transition';
+  import DateInput from '../components/elements/date-input.svelte';
+
+  interface Props {
+    settings: MapSettings;
+    onClose: (settings?: MapSettings) => void;
+  }
+
+  let { settings: initialValues, onClose }: Props = $props();
+  let settings = $state(initialValues);
+
+  let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore);
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    onClose(settings);
+  };
+</script>
+
+<Modal title={$t('map_settings')} {onClose} size="small">
+  <ModalBody>
+    <form {onsubmit} id="map-settings-form">
+      <Stack gap={4}>
+        <Field label={$t('allow_dark_mode')}>
+          <Switch bind:checked={settings.allowDarkMode} />
+        </Field>
+        <Field label={$t('only_favorites')}>
+          <Switch bind:checked={settings.onlyFavorites} />
+        </Field>
+        <Field label={$t('include_archived')}>
+          <Switch bind:checked={settings.includeArchived} />
+        </Field>
+        <Field label={$t('include_shared_partner_assets')}>
+          <Switch bind:checked={settings.withPartners} />
+        </Field>
+        <Field label={$t('include_shared_albums')}>
+          <Switch bind:checked={settings.withSharedAlbums} />
+        </Field>
+
+        {#if customDateRange}
+          <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
+            <div class="flex items-center justify-between gap-8">
+              <label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label>
+              <DateInput
+                class="immich-form-input w-40"
+                type="date"
+                id="date-after"
+                max={settings.dateBefore}
+                bind:value={settings.dateAfter}
+              />
+            </div>
+            <div class="flex items-center justify-between gap-8">
+              <label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label>
+              <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
+            </div>
+            <div class="flex justify-center text-xs">
+              <Button
+                color="primary"
+                size="small"
+                variant="ghost"
+                onclick={() => {
+                  customDateRange = false;
+                  settings.dateAfter = '';
+                  settings.dateBefore = '';
+                }}
+              >
+                {$t('remove_custom_date_range')}
+              </Button>
+            </div>
+          </div>
+        {:else}
+          <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
+            <SettingSelect
+              label={$t('date_range')}
+              name="date-range"
+              bind:value={settings.relativeDate}
+              options={[
+                {
+                  value: '',
+                  text: $t('all'),
+                },
+                {
+                  value: Duration.fromObject({ hours: 24 }).toISO() || '',
+                  text: $t('past_durations.hours', { values: { hours: 24 } }),
+                },
+                {
+                  value: Duration.fromObject({ days: 7 }).toISO() || '',
+                  text: $t('past_durations.days', { values: { days: 7 } }),
+                },
+                {
+                  value: Duration.fromObject({ days: 30 }).toISO() || '',
+                  text: $t('past_durations.days', { values: { days: 30 } }),
+                },
+                {
+                  value: Duration.fromObject({ years: 1 }).toISO() || '',
+                  text: $t('past_durations.years', { values: { years: 1 } }),
+                },
+                {
+                  value: Duration.fromObject({ years: 3 }).toISO() || '',
+                  text: $t('past_durations.years', { values: { years: 3 } }),
+                },
+              ]}
+            />
+            <div class="text-xs">
+              <Button
+                color="primary"
+                size="small"
+                variant="ghost"
+                onclick={() => {
+                  customDateRange = true;
+                  settings.relativeDate = '';
+                }}
+              >
+                {$t('use_custom_date_range')}
+              </Button>
+            </div>
+          </div>
+        {/if}
+      </Stack>
+    </form>
+  </ModalBody>
+
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      <Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
+      <Button type="submit" shape="round" fullWidth form="map-settings-form">{$t('save')}</Button>
+    </div>
+  </ModalFooter>
+</Modal>
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 0a71f35ff2..b1fff3a0cd 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
@@ -3,21 +3,15 @@
 
   import { goto } from '$app/navigation';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
-  import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
   import Map from '$lib/components/shared-components/map/map.svelte';
   import Portal from '$lib/components/shared-components/portal/portal.svelte';
   import { AppRoute } from '$lib/constants';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
-  import type { MapSettings } from '$lib/stores/preferences.store';
-  import { mapSettings } from '$lib/stores/preferences.store';
   import { featureFlags } from '$lib/stores/server-config.store';
-  import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
-  import { isEqual } from 'lodash-es';
-  import { DateTime, Duration } from 'luxon';
-  import { onDestroy, onMount } from 'svelte';
-  import type { PageData } from './$types';
   import { handlePromiseError } from '$lib/utils';
   import { navigate } from '$lib/utils/navigation';
+  import { onDestroy } from 'svelte';
+  import type { PageData } from './$types';
 
   interface Props {
     data: PageData;
@@ -27,18 +21,10 @@
 
   let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
 
-  let abortController: AbortController;
-  let mapMarkers: MapMarkerResponseDto[] = $state([]);
   let viewingAssets: string[] = $state([]);
   let viewingAssetCursor = 0;
-  let showSettingsModal = $state(false);
-
-  onMount(async () => {
-    mapMarkers = await loadMapMarkers();
-  });
 
   onDestroy(() => {
-    abortController?.abort();
     assetViewingStore.showAssetViewer(false);
   });
 
@@ -47,55 +33,6 @@
       handlePromiseError(goto(AppRoute.PHOTOS));
     }
   });
-  const omit = (obj: MapSettings, key: string) => {
-    return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key));
-  };
-
-  async function loadMapMarkers() {
-    if (abortController) {
-      abortController.abort();
-    }
-    abortController = new AbortController();
-
-    const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
-    const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
-
-    return await getMapMarkers(
-      {
-        isArchived: includeArchived && undefined,
-        isFavorite: onlyFavorites || undefined,
-        fileCreatedAfter: fileCreatedAfter || undefined,
-        fileCreatedBefore,
-        withPartners: withPartners || undefined,
-        withSharedAlbums: withSharedAlbums || undefined,
-      },
-      {
-        signal: abortController.signal,
-      },
-    );
-  }
-
-  function getFileCreatedDates() {
-    const { relativeDate, dateAfter, dateBefore } = $mapSettings;
-
-    if (relativeDate) {
-      const duration = Duration.fromISO(relativeDate);
-      return {
-        fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined,
-      };
-    }
-
-    try {
-      return {
-        fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
-        fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
-      };
-    } catch {
-      $mapSettings.dateAfter = '';
-      $mapSettings.dateBefore = '';
-      return {};
-    }
-  }
 
   async function onViewAssets(assetIds: string[]) {
     viewingAssets = assetIds;
@@ -135,7 +72,7 @@
 {#if $featureFlags.loaded && $featureFlags.map}
   <UserPageLayout title={data.meta.title}>
     <div class="isolate h-full w-full">
-      <Map hash bind:mapMarkers bind:showSettingsModal onSelect={onViewAssets} />
+      <Map hash onSelect={onViewAssets} />
     </div>
   </UserPageLayout>
   <Portal target="body">
@@ -156,20 +93,4 @@
       {/await}
     {/if}
   </Portal>
-
-  {#if showSettingsModal}
-    <MapSettingsModal
-      settings={{ ...$mapSettings }}
-      onClose={() => (showSettingsModal = false)}
-      onSave={async (settings) => {
-        const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
-        showSettingsModal = false;
-        $mapSettings = settings;
-
-        if (shouldUpdate) {
-          mapMarkers = await loadMapMarkers();
-        }
-      }}
-    />
-  {/if}
 {/if}

From 5250269fa4400f4981eb4c3a3dec11fb67d0f952 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Wed, 7 May 2025 23:58:46 +0200
Subject: [PATCH 4/5] refactor: user page modals (#18147)

---
 .../lib/components/forms/api-key-form.svelte  | 55 -----------
 .../components/forms/api-key-secret.svelte    | 32 -------
 .../partner-selection-modal.svelte            | 79 ----------------
 .../partner-settings.svelte                   | 20 ++--
 .../user-api-key-list.svelte                  | 92 ++++++++-----------
 web/src/lib/modals/ApiKeyModal.svelte         | 53 +++++++++++
 web/src/lib/modals/ApiKeySecretModal.svelte   | 35 +++++++
 .../lib/modals/PartnerSelectionModal.svelte   | 79 ++++++++++++++++
 8 files changed, 217 insertions(+), 228 deletions(-)
 delete mode 100644 web/src/lib/components/forms/api-key-form.svelte
 delete mode 100644 web/src/lib/components/forms/api-key-secret.svelte
 delete mode 100644 web/src/lib/components/user-settings-page/partner-selection-modal.svelte
 create mode 100644 web/src/lib/modals/ApiKeyModal.svelte
 create mode 100644 web/src/lib/modals/ApiKeySecretModal.svelte
 create mode 100644 web/src/lib/modals/PartnerSelectionModal.svelte

diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte
deleted file mode 100644
index bba7398655..0000000000
--- a/web/src/lib/components/forms/api-key-form.svelte
+++ /dev/null
@@ -1,55 +0,0 @@
-<script lang="ts">
-  import { mdiKeyVariant } from '@mdi/js';
-  import { t } from 'svelte-i18n';
-  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
-  import { NotificationType, notificationController } from '../shared-components/notification/notification';
-  import { Button } from '@immich/ui';
-
-  interface Props {
-    apiKey: { name: string };
-    title: string;
-    cancelText?: string;
-    submitText?: string;
-    onSubmit: (apiKey: { name: string }) => void;
-    onCancel: () => void;
-  }
-
-  let {
-    apiKey = $bindable(),
-    title,
-    cancelText = $t('cancel'),
-    submitText = $t('save'),
-    onSubmit,
-    onCancel,
-  }: Props = $props();
-
-  const handleSubmit = () => {
-    if (apiKey.name) {
-      onSubmit({ name: apiKey.name });
-    } else {
-      notificationController.show({
-        message: $t('api_key_empty'),
-        type: NotificationType.Warning,
-      });
-    }
-  };
-
-  const onsubmit = (event: Event) => {
-    event.preventDefault();
-    handleSubmit();
-  };
-</script>
-
-<FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}>
-  <form {onsubmit} autocomplete="off" id="api-key-form">
-    <div class="mb-4 flex flex-col gap-2">
-      <label class="immich-form-label" for="name">{$t('name')}</label>
-      <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
-    </div>
-  </form>
-
-  {#snippet stickyBottom()}
-    <Button shape="round" color="secondary" fullWidth onclick={() => onCancel()}>{cancelText}</Button>
-    <Button shape="round" type="submit" fullWidth form="api-key-form">{submitText}</Button>
-  {/snippet}
-</FullScreenModal>
diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte
deleted file mode 100644
index 0d3b88d85a..0000000000
--- a/web/src/lib/components/forms/api-key-secret.svelte
+++ /dev/null
@@ -1,32 +0,0 @@
-<script lang="ts">
-  import { copyToClipboard } from '$lib/utils';
-  import { Button } from '@immich/ui';
-  import { mdiKeyVariant } from '@mdi/js';
-  import { t } from 'svelte-i18n';
-  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
-
-  interface Props {
-    secret?: string;
-    onDone: () => void;
-  }
-
-  let { secret = '', onDone }: Props = $props();
-</script>
-
-<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')}
-    </p>
-  </div>
-
-  <div class="my-4 flex flex-col gap-2">
-    <!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> -->
-    <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea>
-  </div>
-
-  {#snippet stickyBottom()}
-    <Button shape="round" onclick={() => copyToClipboard(secret)} fullWidth>{$t('copy_to_clipboard')}</Button>
-    <Button shape="round" onclick={onDone} fullWidth>{$t('done')}</Button>
-  {/snippet}
-</FullScreenModal>
diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
deleted file mode 100644
index 37c6580429..0000000000
--- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte
+++ /dev/null
@@ -1,79 +0,0 @@
-<script lang="ts">
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
-  import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk';
-  import { onMount } from 'svelte';
-  import { t } from 'svelte-i18n';
-  import UserAvatar from '../shared-components/user-avatar.svelte';
-  import { Button } from '@immich/ui';
-
-  interface Props {
-    user: UserResponseDto;
-    onClose: () => void;
-    onAddUsers: (users: UserResponseDto[]) => void;
-  }
-
-  let { user, onClose, onAddUsers }: Props = $props();
-
-  let availableUsers: UserResponseDto[] = $state([]);
-  let selectedUsers: UserResponseDto[] = $state([]);
-
-  onMount(async () => {
-    let users = await searchUsers();
-
-    // remove current user
-    users = users.filter((_user) => _user.id !== user.id);
-
-    // exclude partners from the list of users available for selection
-    const partners = await getPartners({ direction: PartnerDirection.SharedBy });
-    const partnerIds = new Set(partners.map((partner) => partner.id));
-    availableUsers = users.filter((user) => !partnerIds.has(user.id));
-  });
-
-  const selectUser = (user: UserResponseDto) => {
-    selectedUsers = selectedUsers.includes(user)
-      ? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id)
-      : [...selectedUsers, user];
-  };
-</script>
-
-<FullScreenModal title={$t('add_partner')} showLogo {onClose}>
-  <div class="immich-scrollbar max-h-[300px] overflow-y-auto">
-    {#if availableUsers.length > 0}
-      {#each availableUsers as user (user.id)}
-        <button
-          type="button"
-          onclick={() => selectUser(user)}
-          class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
-        >
-          {#if selectedUsers.includes(user)}
-            <span
-              class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg"
-              >✓</span
-            >
-          {:else}
-            <UserAvatar {user} size="lg" />
-          {/if}
-
-          <div class="text-start">
-            <p class="text-immich-fg dark:text-immich-dark-fg">
-              {user.name}
-            </p>
-            <p class="text-xs">
-              {user.email}
-            </p>
-          </div>
-        </button>
-      {/each}
-    {:else}
-      <p class="py-5 text-sm">
-        {$t('photo_shared_all_users')}
-      </p>
-    {/if}
-
-    {#if selectedUsers.length > 0}
-      <div class="pt-5">
-        <Button shape="round" fullWidth onclick={() => onAddUsers(selectedUsers)}>{$t('add')}</Button>
-      </div>
-    {/if}
-  </div>
-</FullScreenModal>
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte
index b2238b84e2..9d187b6adf 100644
--- a/web/src/lib/components/user-settings-page/partner-settings.svelte
+++ b/web/src/lib/components/user-settings-page/partner-settings.svelte
@@ -2,6 +2,8 @@
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte';
   import {
     createPartner,
     getPartners,
@@ -18,7 +20,6 @@
   import { handleError } from '../../utils/handle-error';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import Icon from '../elements/icon.svelte';
-  import PartnerSelectionModal from './partner-selection-modal.svelte';
 
   interface PartnerSharing {
     user: UserResponseDto;
@@ -33,8 +34,6 @@
 
   let { user }: Props = $props();
 
-  let createPartnerFlag = $state(false);
-  // let removePartnerDto: PartnerResponseDto | null = null;
   let partners: Array<PartnerSharing> = $state([]);
 
   onMount(async () => {
@@ -99,14 +98,19 @@
     }
   };
 
-  const handleCreatePartners = async (users: UserResponseDto[]) => {
+  const handleCreatePartners = async () => {
+    const users = await modalManager.open(PartnerSelectionModal, { user });
+
+    if (!users) {
+      return;
+    }
+
     try {
       for (const user of users) {
         await createPartner({ id: user.id });
       }
 
       await refreshPartners();
-      createPartnerFlag = false;
     } catch (error) {
       handleError(error, $t('errors.unable_to_add_partners'));
     }
@@ -189,10 +193,6 @@
   {/if}
 
   <div class="flex justify-end mt-5">
-    <Button shape="round" size="small" onclick={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button>
+    <Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button>
   </div>
 </section>
-
-{#if createPartnerFlag}
-  <PartnerSelectionModal {user} onClose={() => (createPartnerFlag = false)} onAddUsers={handleCreatePartners} />
-{/if}
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 6fbc28a776..823f5a0a96 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
@@ -1,6 +1,9 @@
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import { modalManager } from '$lib/managers/modal-manager.svelte';
+  import ApiKeyModal from '$lib/modals/ApiKeyModal.svelte';
+  import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import {
     createApiKey,
@@ -15,8 +18,6 @@
   import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
   import { handleError } from '../../utils/handle-error';
-  import APIKeyForm from '../forms/api-key-form.svelte';
-  import APIKeySecret from '../forms/api-key-secret.svelte';
   import { notificationController, NotificationType } from '../shared-components/notification/notification';
 
   interface Props {
@@ -25,10 +26,6 @@
 
   let { keys = $bindable() }: Props = $props();
 
-  let newKey: { name: string } | null = $state(null);
-  let editKey: ApiKeyResponseDto | null = $state(null);
-  let secret = $state('');
-
   const format: Intl.DateTimeFormatOptions = {
     month: 'short',
     day: 'numeric',
@@ -39,30 +36,46 @@
     keys = await getApiKeys();
   }
 
-  const handleCreate = async ({ name }: { name: string }) => {
-    try {
-      const data = await createApiKey({
-        apiKeyCreateDto: {
-          name,
-          permissions: [Permission.All],
-        },
-      });
-      secret = data.secret;
-    } catch (error) {
-      handleError(error, $t('errors.unable_to_create_api_key'));
-    } finally {
-      await refreshKeys();
-      newKey = null;
-    }
-  };
+  const handleCreate = async () => {
+    const result = await modalManager.open(ApiKeyModal, {
+      title: $t('new_api_key'),
+      apiKey: { name: 'API Key' },
+      submitText: $t('create'),
+    });
 
-  const handleUpdate = async (detail: Partial<ApiKeyResponseDto>) => {
-    if (!editKey || !detail.name) {
+    if (!result) {
       return;
     }
 
     try {
-      await updateApiKey({ id: editKey.id, apiKeyUpdateDto: { name: detail.name } });
+      const { secret } = await createApiKey({
+        apiKeyCreateDto: {
+          name: result.name,
+          permissions: [Permission.All],
+        },
+      });
+
+      await modalManager.open(ApiKeySecretModal, { secret });
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_create_api_key'));
+    } finally {
+      await refreshKeys();
+    }
+  };
+
+  const handleUpdate = async (key: ApiKeyResponseDto) => {
+    const result = await modalManager.open(ApiKeyModal, {
+      title: $t('api_key'),
+      submitText: $t('save'),
+      apiKey: key,
+    });
+
+    if (!result) {
+      return;
+    }
+
+    try {
+      await updateApiKey({ id: key.id, apiKeyUpdateDto: { name: result.name } });
       notificationController.show({
         message: $t('saved_api_key'),
         type: NotificationType.Info,
@@ -71,7 +84,6 @@
       handleError(error, $t('errors.unable_to_save_api_key'));
     } finally {
       await refreshKeys();
-      editKey = null;
     }
   };
 
@@ -95,34 +107,10 @@
   };
 </script>
 
-{#if newKey}
-  <APIKeyForm
-    title={$t('new_api_key')}
-    submitText={$t('create')}
-    apiKey={newKey}
-    onSubmit={(key) => handleCreate(key)}
-    onCancel={() => (newKey = null)}
-  />
-{/if}
-
-{#if secret}
-  <APIKeySecret {secret} onDone={() => (secret = '')} />
-{/if}
-
-{#if editKey}
-  <APIKeyForm
-    title={$t('api_key')}
-    submitText={$t('save')}
-    apiKey={editKey}
-    onSubmit={(key) => handleUpdate(key)}
-    onCancel={() => (editKey = null)}
-  />
-{/if}
-
 <section class="my-4">
   <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
     <div class="mb-2 flex justify-end">
-      <Button shape="round" size="small" onclick={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button>
+      <Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
     </div>
 
     {#if keys.length > 0}
@@ -153,7 +141,7 @@
                   icon={mdiPencilOutline}
                   title={$t('edit_key')}
                   size="16"
-                  onclick={() => (editKey = key)}
+                  onclick={() => handleUpdate(key)}
                 />
                 <CircleIconButton
                   color="primary"
diff --git a/web/src/lib/modals/ApiKeyModal.svelte b/web/src/lib/modals/ApiKeyModal.svelte
new file mode 100644
index 0000000000..f5e1fb2a7e
--- /dev/null
+++ b/web/src/lib/modals/ApiKeyModal.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
+  import { mdiKeyVariant } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  interface Props {
+    apiKey: { name: string };
+    title: string;
+    cancelText?: string;
+    submitText?: string;
+    onClose: (apiKey?: { name: string }) => void;
+  }
+
+  let { apiKey = $bindable(), title, cancelText = $t('cancel'), submitText = $t('save'), onClose }: Props = $props();
+
+  const handleSubmit = () => {
+    if (apiKey.name) {
+      onClose({ name: apiKey.name });
+    } else {
+      notificationController.show({
+        message: $t('api_key_empty'),
+        type: NotificationType.Warning,
+      });
+    }
+  };
+
+  const onsubmit = (event: Event) => {
+    event.preventDefault();
+    handleSubmit();
+  };
+</script>
+
+<Modal {title} icon={mdiKeyVariant} {onClose} size="small">
+  <ModalBody>
+    <form {onsubmit} autocomplete="off" id="api-key-form">
+      <div class="mb-4 flex flex-col gap-2">
+        <label class="immich-form-label" for="name">{$t('name')}</label>
+        <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
+      </div>
+    </form>
+  </ModalBody>
+
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      <Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button>
+      <Button shape="round" type="submit" fullWidth form="api-key-form">{submitText}</Button>
+    </div>
+  </ModalFooter>
+</Modal>
diff --git a/web/src/lib/modals/ApiKeySecretModal.svelte b/web/src/lib/modals/ApiKeySecretModal.svelte
new file mode 100644
index 0000000000..88d34341d9
--- /dev/null
+++ b/web/src/lib/modals/ApiKeySecretModal.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+  import { copyToClipboard } from '$lib/utils';
+  import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
+  import { mdiKeyVariant } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  interface Props {
+    secret?: string;
+    onClose: () => void;
+  }
+
+  let { secret = '', onClose }: Props = $props();
+</script>
+
+<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
+  <ModalBody>
+    <div class="text-immich-primary dark:text-immich-dark-primary">
+      <p class="text-sm dark:text-immich-dark-fg">
+        {$t('api_key_description')}
+      </p>
+    </div>
+
+    <div class="my-4 flex flex-col gap-2">
+      <!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> -->
+      <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea>
+    </div>
+  </ModalBody>
+
+  <ModalFooter>
+    <div class="flex gap-3 w-full">
+      <Button shape="round" onclick={() => copyToClipboard(secret)} fullWidth>{$t('copy_to_clipboard')}</Button>
+      <Button shape="round" onclick={onClose} fullWidth>{$t('done')}</Button>
+    </div>
+  </ModalFooter>
+</Modal>
diff --git a/web/src/lib/modals/PartnerSelectionModal.svelte b/web/src/lib/modals/PartnerSelectionModal.svelte
new file mode 100644
index 0000000000..729a035ef1
--- /dev/null
+++ b/web/src/lib/modals/PartnerSelectionModal.svelte
@@ -0,0 +1,79 @@
+<script lang="ts">
+  import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
+  import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk';
+  import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
+  import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
+
+  interface Props {
+    user: UserResponseDto;
+    onClose: (users?: UserResponseDto[]) => void;
+  }
+
+  let { user, onClose }: Props = $props();
+
+  let availableUsers: UserResponseDto[] = $state([]);
+  let selectedUsers: UserResponseDto[] = $state([]);
+
+  onMount(async () => {
+    let users = await searchUsers();
+
+    // remove current user
+    users = users.filter((_user) => _user.id !== user.id);
+
+    // exclude partners from the list of users available for selection
+    const partners = await getPartners({ direction: PartnerDirection.SharedBy });
+    const partnerIds = new Set(partners.map((partner) => partner.id));
+    availableUsers = users.filter((user) => !partnerIds.has(user.id));
+  });
+
+  const selectUser = (user: UserResponseDto) => {
+    selectedUsers = selectedUsers.includes(user)
+      ? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id)
+      : [...selectedUsers, user];
+  };
+</script>
+
+<Modal title={$t('add_partner')} {onClose} size="small">
+  <ModalBody>
+    <div class="immich-scrollbar max-h-[300px] overflow-y-auto">
+      {#if availableUsers.length > 0}
+        {#each availableUsers as user (user.id)}
+          <button
+            type="button"
+            onclick={() => selectUser(user)}
+            class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
+          >
+            {#if selectedUsers.includes(user)}
+              <span
+                class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg"
+                >✓</span
+              >
+            {:else}
+              <UserAvatar {user} size="lg" />
+            {/if}
+
+            <div class="text-start">
+              <p class="text-immich-fg dark:text-immich-dark-fg">
+                {user.name}
+              </p>
+              <p class="text-xs">
+                {user.email}
+              </p>
+            </div>
+          </button>
+        {/each}
+      {:else}
+        <p class="py-5 text-sm">
+          {$t('photo_shared_all_users')}
+        </p>
+      {/if}
+
+      <ModalFooter>
+        {#if selectedUsers.length > 0}
+          <Button shape="round" fullWidth onclick={() => onClose(selectedUsers)}>{$t('add')}</Button>
+        {/if}
+      </ModalFooter>
+    </div>
+  </ModalBody>
+</Modal>

From 894545aeed204914b1db47d2bd4ddb8fdd49c9b4 Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Thu, 8 May 2025 00:08:19 +0200
Subject: [PATCH 5/5] refactor: modal manager types (#18150)

---
 .../shared-components/map/map.svelte          |  2 +-
 .../navigation-bar/navigation-bar.svelte      |  2 +-
 .../side-bar/purchase-info.svelte             |  2 +-
 .../side-bar/server-status.svelte             |  2 +-
 .../partner-settings.svelte                   |  2 +-
 .../user-api-key-list.svelte                  |  6 ++--
 web/src/lib/managers/modal-manager.svelte.ts  | 30 +++++++++++--------
 web/src/routes/admin/jobs-status/+page.svelte |  2 +-
 .../routes/admin/user-management/+page.svelte | 10 +++----
 9 files changed, 32 insertions(+), 26 deletions(-)

diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index 5742799409..bf66018eb6 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -196,7 +196,7 @@
   }
 
   const handleSettingsClick = async () => {
-    const settings = await modalManager.open(MapSettingsModal, { settings: { ...$mapSettings } });
+    const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } });
     if (settings) {
       const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
       $mapSettings = settings;
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 915b041d4e..8cf2fb9dfc 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
@@ -132,7 +132,7 @@
             variant="ghost"
             size="medium"
             icon={mdiHelpCircleOutline}
-            onclick={() => info && modalManager.open(HelpAndFeedbackModal, { info })}
+            onclick={() => info && modalManager.show(HelpAndFeedbackModal, { info })}
             aria-label={$t('support_and_feedback')}
           />
         </div>
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
index fe48a68009..aafb430046 100644
--- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
+++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte
@@ -28,7 +28,7 @@
   const { isPurchased } = purchaseStore;
 
   const openPurchaseModal = async () => {
-    await modalManager.open(PurchaseModal);
+    await modalManager.show(PurchaseModal, {});
     showMessage = false;
   };
 
diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte
index 665decc44f..0a9f3d9d8c 100644
--- a/web/src/lib/components/shared-components/side-bar/server-status.svelte
+++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte
@@ -56,7 +56,7 @@
     {#if $connected && version}
       <button
         type="button"
-        onclick={() => info && modalManager.open(ServerAboutModal, { versions, info })}
+        onclick={() => info && modalManager.show(ServerAboutModal, { versions, info })}
         class="dark:text-immich-gray flex gap-1"
       >
         {#if isMain}
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte
index 9d187b6adf..b18390e28f 100644
--- a/web/src/lib/components/user-settings-page/partner-settings.svelte
+++ b/web/src/lib/components/user-settings-page/partner-settings.svelte
@@ -99,7 +99,7 @@
   };
 
   const handleCreatePartners = async () => {
-    const users = await modalManager.open(PartnerSelectionModal, { user });
+    const users = await modalManager.show(PartnerSelectionModal, { user });
 
     if (!users) {
       return;
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 823f5a0a96..ab9ffb294c 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
@@ -37,7 +37,7 @@
   }
 
   const handleCreate = async () => {
-    const result = await modalManager.open(ApiKeyModal, {
+    const result = await modalManager.show(ApiKeyModal, {
       title: $t('new_api_key'),
       apiKey: { name: 'API Key' },
       submitText: $t('create'),
@@ -55,7 +55,7 @@
         },
       });
 
-      await modalManager.open(ApiKeySecretModal, { secret });
+      await modalManager.show(ApiKeySecretModal, { secret });
     } catch (error) {
       handleError(error, $t('errors.unable_to_create_api_key'));
     } finally {
@@ -64,7 +64,7 @@
   };
 
   const handleUpdate = async (key: ApiKeyResponseDto) => {
-    const result = await modalManager.open(ApiKeyModal, {
+    const result = await modalManager.show(ApiKeyModal, {
       title: $t('api_key'),
       submitText: $t('save'),
       apiKey: key,
diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts
index 055df14502..12f9224018 100644
--- a/web/src/lib/managers/modal-manager.svelte.ts
+++ b/web/src/lib/managers/modal-manager.svelte.ts
@@ -1,19 +1,20 @@
 import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
 import { mount, unmount, type Component, type ComponentProps } from 'svelte';
 
-type OnCloseData<T> = T extends { onClose: (data: infer R) => void | Promise<void> } ? R : never;
+type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never;
+type ExtendsEmptyObject<T> = keyof T extends never ? Record<string, never> : T;
 
 class ModalManager {
-  open<T = { onClose: (data: unknown) => void }, K = OnCloseData<T>>(
-    Component: Component<{ onClose: T }>,
-    props?: Record<string, never>,
-  ): Promise<K>;
-  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>): Promise<K>;
-  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props?: Omit<T, 'onClose'>) {
-    return new Promise<K>((resolve) => {
-      let modal: object = {};
+  show<T extends object>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
+    return this.open(Component, props).onClose;
+  }
 
-      const onClose = async (data: K) => {
+  open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) {
+    let modal: object = {};
+    let onClose: () => Promise<void>;
+
+    const deferred = new Promise<K | undefined>((resolve) => {
+      onClose = async (data?: K) => {
         await unmount(modal);
         resolve(data);
       };
@@ -21,15 +22,20 @@ class ModalManager {
       modal = mount(Component, {
         target: document.body,
         props: {
-          ...((props ?? {}) as T),
+          ...(props as T),
           onClose,
         },
       });
     });
+
+    return {
+      onClose: deferred,
+      close: () => onClose(),
+    };
   }
 
   openDialog(options: Omit<ComponentProps<typeof ConfirmDialog>, 'onClose'>) {
-    return this.open(ConfirmDialog, options);
+    return this.show(ConfirmDialog, options);
   }
 }
 
diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte
index c5327a4271..6636d748cf 100644
--- a/web/src/routes/admin/jobs-status/+page.svelte
+++ b/web/src/routes/admin/jobs-status/+page.svelte
@@ -39,7 +39,7 @@
     <HStack gap={0}>
       <Button
         leadingIcon={mdiPlus}
-        onclick={() => modalManager.open(JobCreateModal)}
+        onclick={() => modalManager.show(JobCreateModal, {})}
         size="small"
         variant="ghost"
         color="secondary"
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index 8ac3793046..dcf82bd10d 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -59,15 +59,15 @@
   };
 
   const handleCreate = async () => {
-    await modalManager.open(UserCreateModal);
+    await modalManager.show(UserCreateModal, {});
     await refresh();
   };
 
   const handleEdit = async (dto: UserAdminResponseDto) => {
-    const result = await modalManager.open(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id });
+    const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id });
     switch (result?.action) {
       case 'resetPassword': {
-        await modalManager.open(PasswordResetSuccess, { newPassword: result.data });
+        await modalManager.show(PasswordResetSuccess, { newPassword: result.data });
         break;
       }
       case 'update': {
@@ -78,14 +78,14 @@
   };
 
   const handleDelete = async (user: UserAdminResponseDto) => {
-    const result = await modalManager.open(UserDeleteConfirmModal, { user });
+    const result = await modalManager.show(UserDeleteConfirmModal, { user });
     if (result) {
       await refresh();
     }
   };
 
   const handleRestore = async (user: UserAdminResponseDto) => {
-    const result = await modalManager.open(UserRestoreConfirmModal, { user });
+    const result = await modalManager.show(UserRestoreConfirmModal, { user });
     if (result) {
       await refresh();
     }