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>