diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 7b850f6166..69070dc0cf 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -67,7 +67,7 @@ const runQuery = async (query: string) => { const runMigrations = async () => { const configRepository = new ConfigRepository(); - const logger = new LoggingRepository(undefined, configRepository); + const logger = LoggingRepository.create(); const db = getDatabaseClient(); const databaseRepository = new DatabaseRepository(db, logger, configRepository); await databaseRepository.runMigrations(); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 388c4df96b..89b1921819 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -142,18 +142,15 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys } case 'database': { - const configRepo = new ConfigRepository(); - return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo); + return new DatabaseRepository(db, LoggingRepository.create(), new ConfigRepository()); } case 'email': { - const logger = new LoggingRepository(undefined, new ConfigRepository()); - return new EmailRepository(logger); + return new EmailRepository(LoggingRepository.create()); } case 'logger': { - const configMock = { getEnv: () => ({ noColor: false }) }; - return new LoggingRepository(undefined, configMock as ConfigRepository); + return LoggingRepository.create(); } case 'memory': { diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts index e63c9f5224..4398da5c0a 100644 --- a/server/test/medium/globalSetup.ts +++ b/server/test/medium/globalSetup.ts @@ -42,7 +42,7 @@ const globalSetup = async () => { const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl })); const configRepository = new ConfigRepository(); - const logger = new LoggingRepository(undefined, configRepository); + const logger = LoggingRepository.create(); await new DatabaseRepository(db, logger, configRepository).runMigrations(); await db.destroy(); diff --git a/web/package-lock.json b/web/package-lock.json index c76dd64840..75c55aa779 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.18.1", + "@immich/ui": "^0.19.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -1320,9 +1320,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.18.1.tgz", - "integrity": "sha512-XWWO6OTfH3MektyxCn0hWefZyOGyWwwx/2zHinuShpxTHSyfveJ4mOkFP8DkyMz0dnvJ1EfdkPBMkld3y5R/Hw==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.19.0.tgz", + "integrity": "sha512-XVjSUoQVIoe83pxM4q8kmlejb2xep/TZEfoGbasI7takEGKNiWEyXr5eZaXZCSVgq78fcNRr4jyWz290ZAXh7A==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index 9aa9bee6bc..7c5a0147bb 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.18.1", + "@immich/ui": "^0.19.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/app.css b/web/src/app.css index 2c8d150b4f..61759eb1b0 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -8,7 +8,6 @@ --immich-primary: 66 80 175; --immich-bg: 255 255 255; --immich-fg: 0 0 0; - --immich-gray: 246 246 244; --immich-error: 229 115 115; --immich-success: 129 199 132; --immich-warning: 255 183 77; @@ -33,6 +32,7 @@ --immich-ui-warning: 255 170 0; --immich-ui-info: 14 165 233; --immich-ui-default-border: 209 213 219; + --immich-ui-gray: 246 246 246; } .dark { @@ -45,6 +45,7 @@ --immich-ui-warning: 255 170 0; --immich-ui-info: 14 165 233; --immich-ui-default-border: 55 65 81; + --immich-ui-gray: 33 33 33; } } diff --git a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte index 6eb603263e..e2d3c86bf3 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialogue.svelte @@ -47,8 +47,7 @@ <ConfirmDialog title={$t('delete_user')} confirmText={forceDelete ? $t('permanently_delete') : $t('delete')} - onConfirm={handleDeleteUser} - {onCancel} + onClose={(confirmed) => (confirmed ? handleDeleteUser() : onCancel())} disabled={deleteButtonDisabled} > {#snippet promptSnippet()} diff --git a/web/src/lib/components/admin-page/restore-dialogue.svelte b/web/src/lib/components/admin-page/restore-dialogue.svelte index 1386ae9fc4..7fd51aaf06 100644 --- a/web/src/lib/components/admin-page/restore-dialogue.svelte +++ b/web/src/lib/components/admin-page/restore-dialogue.svelte @@ -33,8 +33,7 @@ title={$t('restore_user')} confirmText={$t('continue')} confirmColor="success" - onConfirm={handleRestoreUser} - {onCancel} + onClose={(confirmed) => (confirmed ? handleRestoreUser() : onCancel())} > {#snippet promptSnippet()} <p> diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index b2454b06c3..2a270f7438 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -49,8 +49,7 @@ {#if isConfirmOpen} <ConfirmDialog title={$t('admin.disable_login')} - onCancel={() => (isConfirmOpen = false)} - onConfirm={() => handleSave(true)} + onClose={(confirmed) => (confirmed ? handleSave(true) : (isConfirmOpen = false))} > {#snippet promptSnippet()} <div class="flex flex-col gap-4"> diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 884de8c2a2..4a8c018fbd 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -1,27 +1,27 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; + import { handleError } from '$lib/utils/handle-error'; import { - updateAlbumInfo, + AlbumUserRole, + AssetOrder, removeUserFromAlbum, + updateAlbumInfo, + updateAlbumUser, type AlbumResponseDto, type UserResponseDto, - AssetOrder, - AlbumUserRole, - updateAlbumUser, } from '@immich/sdk'; - import { mdiArrowDownThin, mdiArrowUpThin, mdiPlus, mdiDotsVertical } from '@mdi/js'; - 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'; - import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte'; - import type { RenderedOption } from '../elements/dropdown.svelte'; - import { handleError } from '$lib/utils/handle-error'; + import { mdiArrowDownThin, mdiArrowUpThin, mdiDotsVertical, mdiPlus } from '@mdi/js'; import { findKey } from 'lodash-es'; import { t } from 'svelte-i18n'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import type { RenderedOption } from '../elements/dropdown.svelte'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; - import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import SettingDropdown from '../shared-components/settings/setting-dropdown.svelte'; interface Props { album: AlbumResponseDto; @@ -195,7 +195,6 @@ title={$t('album_remove_user')} prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })} confirmText={$t('remove_user')} - onConfirm={handleRemoveUser} - onCancel={() => (selectedRemoveUser = null)} + onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))} /> {/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 35d8a84412..97bbb81ea5 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -1,22 +1,22 @@ <script lang="ts"> + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { + AlbumUserRole, getMyUser, removeUserFromAlbum, + updateAlbumUser, type AlbumResponseDto, type UserResponseDto, - updateAlbumUser, - AlbumUserRole, } from '@immich/sdk'; import { mdiDotsVertical } from '@mdi/js'; import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; 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'; + import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import UserAvatar from '../shared-components/user-avatar.svelte'; - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import { t } from 'svelte-i18n'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; interface Props { album: AlbumResponseDto; @@ -144,8 +144,7 @@ title={$t('album_leave')} prompt={$t('album_leave_confirmation', { values: { album: album.albumName } })} confirmText={$t('leave')} - onConfirm={handleRemoveUser} - onCancel={() => (selectedRemoveUser = null)} + onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))} /> {/if} @@ -154,7 +153,6 @@ title={$t('album_remove_user')} prompt={$t('album_remove_user_confirmation', { values: { user: selectedRemoveUser.name } })} confirmText={$t('remove_user')} - onConfirm={handleRemoveUser} - onCancel={() => (selectedRemoveUser = null)} + onClose={(confirmed) => (confirmed ? handleRemoveUser() : (selectedRemoveUser = null))} /> {/if} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 39ba7ac200..7efe4a1a73 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -1,13 +1,13 @@ <script lang="ts"> + import { shortcut } from '$lib/actions/shortcut'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; import { websocketEvents } from '$lib/stores/websocket'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiClose } from '@mdi/js'; import { onMount } from 'svelte'; - import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; - import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; - import { shortcut } from '$lib/actions/shortcut'; + import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte'; onMount(() => { return websocketEvents.on('on_asset_update', (assetUpdate) => { @@ -31,10 +31,13 @@ setTimeout(() => { onUpdateSelectedType(selectedType); }, 1); + function selectType(name: string) { selectedType = name; onUpdateSelectedType(selectedType); } + + const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog()); </script> <svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} /> @@ -71,9 +74,6 @@ cancelColor="secondary" confirmColor="danger" confirmText={$t('close')} - onCancel={() => { - $showCancelConfirmDialog = false; - }} - onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())} + onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))} /> {/if} diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 83b3154d4b..34e498ce1c 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -1,21 +1,29 @@ <script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; import { userInteraction } from '$lib/stores/user.svelte'; import { ByteUnit, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; - import { createUserAdmin } from '@immich/sdk'; - import { Alert, Button, Field, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui'; + import { createUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; + import { + Alert, + Button, + Field, + HelperText, + Input, + Modal, + ModalBody, + ModalFooter, + PasswordInput, + Stack, + Switch, + } from '@immich/ui'; import { t } from 'svelte-i18n'; interface Props { - onClose: () => void; - onSubmit: () => void; - onCancel: () => void; - oauthEnabled?: boolean; + onClose: (user?: UserAdminResponseDto) => void; } - let { onClose, onSubmit: onDone, onCancel, oauthEnabled = false }: Props = $props(); + let { onClose }: Props = $props(); let error = $state(''); let success = $state(false); @@ -50,7 +58,7 @@ error = ''; try { - await createUserAdmin({ + const user = await createUserAdmin({ userAdminCreateDto: { email, password, @@ -63,8 +71,7 @@ success = true; - onDone(); - + onClose(user); return; } catch (error) { handleError(error, $t('errors.unable_to_create_user')); @@ -74,55 +81,60 @@ }; </script> -<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form"> - <FullScreenModal title={$t('create_new_user')} showLogo {onClose}> - {#if error} - <Alert color="danger" size="small" title={error} closable /> - {/if} - - {#if success} - <p class="text-sm text-immich-primary">{$t('new_user_created')}</p> - {/if} - - <Stack gap={4}> - <Field label={$t('email')} required> - <Input bind:value={email} type="email" /> - </Field> - - {#if $featureFlags.email} - <Field label={$t('admin.send_welcome_email')}> - <Switch id="send-welcome-email" bind:checked={notify} class="text-sm" /> - </Field> +<Modal title={$t('create_new_user')} {onClose} size="small" class="text-dark bg-light"> + <ModalBody> + <form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form"> + {#if error} + <Alert color="danger" size="small" title={error} closable /> {/if} - <Field label={$t('password')} required={!oauthEnabled}> - <PasswordInput id="password" bind:value={password} autocomplete="new-password" /> - </Field> + {#if success} + <p class="text-sm text-immich-primary">{$t('new_user_created')}</p> + {/if} - <Field label={$t('confirm_password')} required={!oauthEnabled}> - <PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" /> - <HelperText color="danger">{passwordMismatchMessage}</HelperText> - </Field> + <Stack gap={4}> + <Field label={$t('email')} required> + <Input bind:value={email} type="email" /> + </Field> - <Field label={$t('admin.require_password_change_on_login')}> - <Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm" /> - </Field> - - <Field label={$t('name')} required> - <Input bind:value={name} /> - </Field> - - <Field label={$t('admin.quota_size_gib')}> - <Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" /> - {#if quotaSizeWarning} - <HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText> + {#if $featureFlags.email} + <Field label={$t('admin.send_welcome_email')}> + <Switch id="send-welcome-email" bind:checked={notify} class="text-sm" /> + </Field> {/if} - </Field> - </Stack> - {#snippet stickyBottom()} - <Button color="secondary" fullWidth onclick={onCancel} shape="round">{$t('cancel')}</Button> - <Button type="submit" disabled={!valid} fullWidth shape="round">{$t('create')}</Button> - {/snippet} - </FullScreenModal> -</form> + <Field label={$t('password')} required={!$featureFlags.oauth}> + <PasswordInput id="password" bind:value={password} autocomplete="new-password" /> + </Field> + + <Field label={$t('confirm_password')} required={!$featureFlags.oauth}> + <PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" /> + <HelperText color="danger">{passwordMismatchMessage}</HelperText> + </Field> + + <Field label={$t('admin.require_password_change_on_login')}> + <Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm text-start" /> + </Field> + + <Field label={$t('name')} required> + <Input bind:value={name} /> + </Field> + + <Field label={$t('admin.quota_size_gib')}> + <Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" /> + {#if quotaSizeWarning} + <HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText> + {/if} + </Field> + </Stack> + </form> + </ModalBody> + + <ModalFooter> + <div class="flex gap-3 w-full"> + <Button color="secondary" fullWidth onclick={() => onClose()} shape="round">{$t('cancel')}</Button> + <Button type="submit" disabled={!valid} fullWidth shape="round" form="create-new-user-form">{$t('create')}</Button + > + </div> + </ModalFooter> +</Modal> diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index ab914e6430..d2f56a974a 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -1,34 +1,26 @@ <script lang="ts"> - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { AppRoute } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { userInteraction } from '$lib/stores/user.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; - import { Button } from '@immich/ui'; + import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { mdiAccountEditOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { user: UserAdminResponseDto; canResetPassword?: boolean; - newPassword: string; - onClose: () => void; - onResetPasswordSuccess: () => void; - onEditSuccess: () => void; + onClose: ( + data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string }, + ) => void; } - let { - user, - canResetPassword = true, - newPassword = $bindable(), - onClose, - onResetPasswordSuccess, - onEditSuccess, - }: Props = $props(); + let { user, canResetPassword = true, onClose }: Props = $props(); let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB)); + let newPassword = $state<string>(''); const previousQutoa = user.quotaSizeInBytes; @@ -42,7 +34,7 @@ const editUser = async () => { try { const { id, email, name, storageLabel } = user; - await updateUserAdmin({ + const newUser = await updateUserAdmin({ id, userAdminUpdateDto: { email, @@ -52,14 +44,14 @@ }, }); - onEditSuccess(); + onClose({ action: 'update', data: newUser }); } catch (error) { handleError(error, $t('errors.unable_to_update_user')); } }; const resetPassword = async () => { - const isConfirmed = await dialogController.show({ + const isConfirmed = await modalManager.openDialog({ prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }), }); @@ -78,7 +70,7 @@ }, }); - onResetPasswordSuccess(); + onClose({ action: 'resetPassword', data: newPassword }); } catch (error) { handleError(error, $t('errors.unable_to_reset_password')); } @@ -107,61 +99,65 @@ }; </script> -<FullScreenModal title={$t('edit_user')} icon={mdiAccountEditOutline} {onClose}> - <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form"> - <div class="my-4 flex flex-col gap-2"> - <label class="immich-form-label" for="email">{$t('email')}</label> - <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} /> +<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose}> + <ModalBody> + <form onsubmit={onSubmit} autocomplete="off" id="edit-user-form"> + <div class="my-4 flex flex-col gap-2"> + <label class="immich-form-label" for="email">{$t('email')}</label> + <input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} /> + </div> + + <div class="my-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" required bind:value={user.name} /> + </div> + + <div class="my-4 flex flex-col gap-2"> + <label class="flex items-center gap-2 immich-form-label" for="quotaSize"> + {$t('admin.quota_size_gib')} + {#if quotaSizeWarning} + <p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p> + {/if}</label + > + <input + class="immich-form-input" + id="quotaSize" + name="quotaSize" + placeholder={$t('unlimited')} + type="number" + min="0" + bind:value={quotaSize} + /> + </div> + + <div class="my-4 flex flex-col gap-2"> + <label class="immich-form-label" for="storage-label">{$t('storage_label')}</label> + <input + class="immich-form-input" + id="storage-label" + name="storage-label" + type="text" + bind:value={user.storageLabel} + /> + + <p> + {$t('admin.note_apply_storage_label_previous_assets')} + <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> + {$t('admin.storage_template_migration_job')} + </a> + </p> + </div> + </form> + </ModalBody> + + <ModalFooter> + <div class="flex gap-3 w-full"> + {#if canResetPassword} + <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} + >{$t('reset_password')}</Button + > + {/if} + <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> </div> - - <div class="my-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" required bind:value={user.name} /> - </div> - - <div class="my-4 flex flex-col gap-2"> - <label class="flex items-center gap-2 immich-form-label" for="quotaSize"> - {$t('admin.quota_size_gib')} - {#if quotaSizeWarning} - <p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p> - {/if}</label - > - <input - class="immich-form-input" - id="quotaSize" - name="quotaSize" - placeholder={$t('unlimited')} - type="number" - min="0" - bind:value={quotaSize} - /> - </div> - - <div class="my-4 flex flex-col gap-2"> - <label class="immich-form-label" for="storage-label">{$t('storage_label')}</label> - <input - class="immich-form-input" - id="storage-label" - name="storage-label" - type="text" - bind:value={user.storageLabel} - /> - - <p> - {$t('admin.note_apply_storage_label_previous_assets')} - <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> - {$t('admin.storage_template_migration_job')} - </a> - </p> - </div> - </form> - - {#snippet stickyBottom()} - {#if canResetPassword} - <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} - >{$t('reset_password')}</Button - > - {/if} - <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> - {/snippet} -</FullScreenModal> + </ModalFooter> +</Modal> 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 3053600a47..fbdec86244 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,9 +1,9 @@ <script lang="ts"> - import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte'; - import { showDeleteModal } from '$lib/stores/preferences.store'; import Checkbox from '$lib/components/elements/checkbox.svelte'; - import { t } from 'svelte-i18n'; import FormatMessage from '$lib/components/i18n/format-message.svelte'; + import { showDeleteModal } from '$lib/stores/preferences.store'; + import { t } from 'svelte-i18n'; + import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte'; interface Props { size: number; @@ -26,8 +26,7 @@ <ConfirmDialog title={$t('permanently_delete_assets_count', { values: { count: size } })} confirmText={$t('delete')} - onConfirm={handleConfirm} - {onCancel} + onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())} > {#snippet promptSnippet()} <p> diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index ef682d9048..d6b575f772 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -1,9 +1,9 @@ <script lang="ts"> import { DateTime } from 'luxon'; - import ConfirmDialog from './dialog/confirm-dialog.svelte'; - import Combobox, { type ComboBoxOption } from './combobox.svelte'; - import DateInput from '../elements/date-input.svelte'; import { t } from 'svelte-i18n'; + import DateInput from '../elements/date-input.svelte'; + import Combobox, { type ComboBoxOption } from './combobox.svelte'; + import ConfirmDialog from './dialog/confirm-dialog.svelte'; interface Props { initialDate?: DateTime; @@ -138,8 +138,7 @@ title={$t('edit_date_and_time')} prompt="Please select a new date:" disabled={!date.isValid} - onConfirm={handleConfirm} - {onCancel} + onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())} > <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component --> <!-- @migration-task: migrate this slot by hand, `prompt` would shadow a prop on the parent component --> diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index f981e85029..3539945911 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -1,20 +1,20 @@ <script lang="ts"> - import ConfirmDialog from './dialog/confirm-dialog.svelte'; import { timeDebounceOnSearch } from '$lib/constants'; - import { handleError } from '$lib/utils/handle-error'; import { lastChosenLocation } from '$lib/stores/asset-editor.store'; + import { handleError } from '$lib/utils/handle-error'; + import ConfirmDialog from './dialog/confirm-dialog.svelte'; import { clickOutside } from '$lib/actions/click-outside'; - import LoadingSpinner from './loading-spinner.svelte'; - import { delay } from '$lib/utils/asset-utils'; - import { timeToLoadTheMap } from '$lib/constants'; - import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk'; - import SearchBar from '../elements/search-bar.svelte'; import { listNavigation } from '$lib/actions/list-navigation'; - import { t } from 'svelte-i18n'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; import type Map from '$lib/components/shared-components/map/map.svelte'; + import { timeToLoadTheMap } from '$lib/constants'; + import { delay } from '$lib/utils/asset-utils'; + import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk'; + import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; + import SearchBar from '../elements/search-bar.svelte'; + import LoadingSpinner from './loading-spinner.svelte'; interface Point { lng: number; lat: number; @@ -112,7 +112,12 @@ }; </script> -<ConfirmDialog confirmColor="primary" title={$t('change_location')} width="wide" onConfirm={handleConfirm} {onCancel}> +<ConfirmDialog + confirmColor="primary" + title={$t('change_location')} + size="medium" + onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())} +> {#snippet promptSnippet()} <div class="flex flex-col w-full h-full gap-2"> <div class="relative w-64 sm:w-96"> diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte index dad16d52ca..75c07aebc6 100644 --- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte +++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte @@ -1,8 +1,7 @@ <script lang="ts"> - import FullScreenModal from '../full-screen-modal.svelte'; - import { t } from 'svelte-i18n'; + import { Button, Modal, ModalBody, ModalFooter, type Color } from '@immich/ui'; import type { Snippet } from 'svelte'; - import { Button, type Color } from '@immich/ui'; + import { t } from 'svelte-i18n'; interface Props { title?: string; @@ -13,9 +12,8 @@ cancelColor?: Color; hideCancelButton?: boolean; disabled?: boolean; - width?: 'wide' | 'narrow'; - onCancel: () => void; - onConfirm: () => void; + size?: 'small' | 'medium'; + onClose: (confirmed: boolean) => void; promptSnippet?: Snippet; } @@ -28,32 +26,33 @@ cancelColor = 'secondary', hideCancelButton = false, disabled = false, - width = 'narrow', - onCancel, - onConfirm, + size = 'small', + onClose, promptSnippet, }: Props = $props(); const handleConfirm = () => { - onConfirm(); + onClose(true); }; </script> -<FullScreenModal {title} onClose={onCancel} {width}> - <div class="text-md py-5 text-center"> +<Modal {title} onClose={() => onClose(false)} {size} class="bg-light text-dark"> + <ModalBody> {#if promptSnippet}{@render promptSnippet()}{:else} <p>{prompt}</p> {/if} - </div> + </ModalBody> - {#snippet stickyBottom()} - {#if !hideCancelButton} - <Button shape="round" color={cancelColor} fullWidth onclick={onCancel}> - {cancelText} + <ModalFooter> + <div class="flex gap-3 w-full"> + {#if !hideCancelButton} + <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> + {cancelText} + </Button> + {/if} + <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}> + {confirmText} </Button> - {/if} - <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}> - {confirmText} - </Button> - {/snippet} -</FullScreenModal> + </div> + </ModalFooter> +</Modal> diff --git a/web/src/lib/components/shared-components/dialog/dialog.ts b/web/src/lib/components/shared-components/dialog/dialog.ts index 8efff58da0..69a64aad21 100644 --- a/web/src/lib/components/shared-components/dialog/dialog.ts +++ b/web/src/lib/components/shared-components/dialog/dialog.ts @@ -1,8 +1,7 @@ import { writable } from 'svelte/store'; type DialogActions = { - onConfirm: () => void; - onCancel: () => void; + onClose: (confirmed: boolean) => void; }; type DialogOptions = { @@ -24,13 +23,9 @@ function createDialogWrapper() { return new Promise<boolean>((resolve) => { const newDialog: Dialog = { ...options, - onConfirm: () => { + onClose: (confirmed) => { dialog.set(undefined); - resolve(true); - }, - onCancel: () => { - dialog.set(undefined); - resolve(false); + resolve(confirmed); }, }; diff --git a/web/src/lib/forms/password-reset-success.svelte b/web/src/lib/forms/password-reset-success.svelte new file mode 100644 index 0000000000..7091047eb8 --- /dev/null +++ b/web/src/lib/forms/password-reset-success.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { copyToClipboard } from '$lib/utils'; + import { Code, IconButton, Text } from '@immich/ui'; + import { mdiContentCopy } from '@mdi/js'; + import { t } from 'svelte-i18n'; + + type Props = { + onClose: () => void; + newPassword: string; + }; + + const { onClose, newPassword }: Props = $props(); +</script> + +<ConfirmDialog + title={$t('password_reset_success')} + confirmText={$t('done')} + {onClose} + hideCancelButton={true} + confirmColor="success" +> + {#snippet promptSnippet()} + <div class="flex flex-col gap-4"> + <Text>{$t('admin.user_password_has_been_reset')}</Text> + + <div class="flex justify-center gap-2 items-center"> + <Code color="primary">{newPassword}</Code> + <IconButton + icon={mdiContentCopy} + shape="round" + color="secondary" + variant="ghost" + onclick={() => copyToClipboard(newPassword)} + title={$t('copy_password')} + aria-label={$t('copy_password')} + /> + </div> + + <Text>{$t('admin.user_password_reset_description')}</Text> + </div> + {/snippet} +</ConfirmDialog> diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts new file mode 100644 index 0000000000..c8cefe8a58 --- /dev/null +++ b/web/src/lib/managers/modal-manager.svelte.ts @@ -0,0 +1,33 @@ +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 } ? R : never; +// TODO make `props` optional if component only has `onClose` +// type OptionalIfEmpty<T extends object> = keyof T extends never ? undefined : T; + +class ModalManager { + open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>) { + return new Promise<K>((resolve) => { + let modal: object = {}; + + const onClose = async (data: K) => { + await unmount(modal); + resolve(data); + }; + + modal = mount(Component, { + target: document.body, + props: { + ...(props as T), + onClose, + }, + }); + }); + } + + openDialog(options: Omit<ComponentProps<typeof ConfirmDialog>, 'onClose'>) { + return this.open(ConfirmDialog, options); + } +} + +export const modalManager = new ModalManager(); diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 07757614e5..af7f3d11af 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -102,8 +102,7 @@ confirmColor="primary" title={$t('admin.create_job')} disabled={!selectedJob} - onConfirm={handleCreate} - onCancel={handleCancel} + onClose={(confirmed) => (confirmed ? handleCreate() : handleCancel())} > {#snippet promptSnippet()} <form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full"> diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index a25799588a..42d1404177 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -6,20 +6,20 @@ import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; import EditUserForm from '$lib/components/forms/edit-user-form.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; - import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; + import PasswordResetSuccess from '$lib/forms/password-reset-success.svelte'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; - import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; + import { serverConfig } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; - import { copyToClipboard } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; - import { Button, Code, IconButton, Text } from '@immich/ui'; - import { mdiContentCopy, mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; + import { Button, IconButton } from '@immich/ui'; + import { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -32,13 +32,9 @@ let { data }: Props = $props(); let allUsers: UserAdminResponseDto[] = $state([]); - let shouldShowEditUserForm = $state(false); - let shouldShowCreateUserForm = $state(false); - let shouldShowPasswordResetSuccess = $state(false); let shouldShowDeleteConfirmDialog = $state(false); let shouldShowRestoreDialog = $state(false); let selectedUser = $state<UserAdminResponseDto>(); - let newPassword = $state(''); const refresh = async () => { allUsers = await searchUsersAdmin({ withDeleted: true }); @@ -65,25 +61,23 @@ return DateTime.fromISO(deletedAt).plus({ days: $serverConfig.userDeleteDelay }).toJSDate(); }; - const onUserCreated = async () => { + const handleCreate = async () => { + await modalManager.open(CreateUserForm, {}); await refresh(); - shouldShowCreateUserForm = false; }; - const editUserHandler = (user: UserAdminResponseDto) => { - selectedUser = user; - shouldShowEditUserForm = true; - }; - - const onEditUserSuccess = async () => { - await refresh(); - shouldShowEditUserForm = false; - }; - - const onEditPasswordSuccess = async () => { - await refresh(); - shouldShowEditUserForm = false; - shouldShowPasswordResetSuccess = true; + const handleEdit = async (dto: UserAdminResponseDto) => { + const result = await modalManager.open(EditUserForm, { user: dto, canResetPassword: dto.id !== $user.id }); + switch (result?.action) { + case 'resetPassword': { + await modalManager.open(PasswordResetSuccess, { newPassword: result.data }); + break; + } + case 'update': { + await refresh(); + break; + } + } }; const deleteUserHandler = (user: UserAdminResponseDto) => { @@ -110,26 +104,6 @@ <UserPageLayout title={data.meta.title} admin> <section id="setting-content" class="flex place-content-center sm:mx-4"> <section class="w-full pb-28 lg:w-[850px]"> - {#if shouldShowCreateUserForm} - <CreateUserForm - onSubmit={onUserCreated} - onCancel={() => (shouldShowCreateUserForm = false)} - onClose={() => (shouldShowCreateUserForm = false)} - oauthEnabled={$featureFlags.oauth} - /> - {/if} - - {#if shouldShowEditUserForm && selectedUser} - <EditUserForm - user={selectedUser} - bind:newPassword - canResetPassword={selectedUser?.id !== $user.id} - onEditSuccess={onEditUserSuccess} - onResetPasswordSuccess={onEditPasswordSuccess} - onClose={() => (shouldShowEditUserForm = false)} - /> - {/if} - {#if shouldShowDeleteConfirmDialog && selectedUser} <DeleteConfirmDialog user={selectedUser} @@ -148,38 +122,6 @@ /> {/if} - {#if shouldShowPasswordResetSuccess} - <ConfirmDialog - title={$t('password_reset_success')} - confirmText={$t('done')} - onConfirm={() => (shouldShowPasswordResetSuccess = false)} - onCancel={() => (shouldShowPasswordResetSuccess = false)} - hideCancelButton={true} - confirmColor="success" - > - {#snippet promptSnippet()} - <div class="flex flex-col gap-4"> - <Text>{$t('admin.user_password_has_been_reset')}</Text> - - <div class="flex justify-center gap-2 items-center"> - <Code color="primary">{newPassword}</Code> - <IconButton - icon={mdiContentCopy} - shape="round" - color="secondary" - variant="ghost" - onclick={() => copyToClipboard(newPassword)} - title={$t('copy_password')} - aria-label={$t('copy_password')} - /> - </div> - - <Text>{$t('admin.user_password_reset_description')}</Text> - </div> - {/snippet} - </ConfirmDialog> - {/if} - <table class="my-5 w-full text-start"> <thead class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary" @@ -225,7 +167,7 @@ size="small" icon={mdiPencilOutline} title={$t('edit_user')} - onclick={() => editUserHandler(immichUser)} + onclick={() => handleEdit(immichUser)} aria-label={$t('edit_user')} /> {#if immichUser.id !== $user.id} @@ -257,7 +199,7 @@ {/if} </tbody> </table> - <Button shape="round" size="small" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button> + <Button shape="round" size="small" onclick={handleCreate}>{$t('create_user')}</Button> </section> </section> </UserPageLayout> diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 95611d486d..2e13e5997d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -36,7 +36,7 @@ export default { danger: 'rgb(var(--immich-ui-danger) / <alpha-value>)', warning: 'rgb(var(--immich-ui-warning) / <alpha-value>)', info: 'rgb(var(--immich-ui-info) / <alpha-value>)', - subtle: 'rgb(var(--immich-gray) / <alpha-value>)', + subtle: 'rgb(var(--immich-ui-gray) / <alpha-value>)', }, borderColor: ({ theme }) => ({ ...theme('colors'),