refactor: modals (#25163)

This commit is contained in:
Jason Rasmussen 2026-01-09 15:05:20 -05:00 committed by GitHub
parent 88327fb872
commit 1e4af9731d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 258 additions and 357 deletions

View file

@ -56,7 +56,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByLabel('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect await expect
.poll(async () => { .poll(async () => {
@ -85,7 +85,7 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked(); await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByLabel('Admin User').click(); await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked(); await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect await expect
.poll(async () => { .poll(async () => {

View file

@ -735,8 +735,8 @@ importers:
specifier: file:../open-api/typescript-sdk specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk version: link:../open-api/typescript-sdk
'@immich/ui': '@immich/ui':
specifier: ^0.54.0 specifier: ^0.56.1
version: 0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) version: 0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3 specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3) version: 0.2.3(mapbox-gl@1.13.3)
@ -3084,8 +3084,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
'@immich/ui@0.54.0': '@immich/ui@0.56.1':
resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==} resolution: {integrity: sha512-W4uEQn9pxVKRvIV7sl9p6dU2r7xlVsMFxBeClxtXzSsiJEoE10uZwBIm0L9q17c4TQ/+lk9e/w1e4jNSvFqFwQ==}
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
@ -15098,7 +15098,7 @@ snapshots:
dependencies: dependencies:
svelte: 5.46.1 svelte: 5.46.1
'@immich/ui@0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)': '@immich/ui@0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies: dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0

View file

@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.54.0", "@immich/ui": "^0.56.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/core": "^5.14.0",

View file

@ -32,8 +32,8 @@
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled; const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
if (allMethodsDisabled) { if (allMethodsDisabled) {
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal); const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
if (!isConfirmed) { if (!confirmed) {
return false; return false;
} }
} }

View file

@ -146,7 +146,7 @@
size="medium" size="medium"
onClose={handleConfirm} onClose={handleConfirm}
> >
{#snippet promptSnippet()} {#snippet prompt()}
<div class="flex flex-col w-full h-full gap-2"> <div class="flex flex-col w-full h-full gap-2">
<div class="relative w-64 sm:w-96 z-1"> <div class="relative w-64 sm:w-96 z-1">
{#if suggestionContainer} {#if suggestionContainer}

View file

@ -4,10 +4,10 @@
import { mdiKeyVariant } from '@mdi/js'; import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
secret?: string; secret?: string;
onClose: () => void; onClose: () => void;
} };
let { secret = '', onClose }: Props = $props(); let { secret = '', onClose }: Props = $props();
</script> </script>

View file

@ -29,7 +29,7 @@
icon={mdiDeleteForeverOutline} icon={mdiDeleteForeverOutline}
{onClose} {onClose}
> >
{#snippet promptSnippet()} {#snippet prompt()}
<p> <p>
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}> <FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}>
{#snippet children({ message })} {#snippet children({ message })}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ConfirmModal, Field, Textarea } from '@immich/ui'; import { Field, FormModal, Textarea } from '@immich/ui';
import { mdiText } from '@mdi/js'; import { mdiText } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -11,16 +11,8 @@
let description = $state(''); let description = $state('');
</script> </script>
<ConfirmModal <FormModal title={$t('edit_description')} icon={mdiText} {onClose} onSubmit={() => onClose(description)}>
confirmColor="primary" <Field label={$t('description')}>
title={$t('edit_description')} <Textarea bind:value={description} grow />
icon={mdiText} </Field>
prompt={$t('edit_description_prompt')} </FormModal>
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
>
{#snippet promptSnippet()}
<Field label={$t('description')}>
<Textarea bind:value={description} grow />
</Field>
{/snippet}
</ConfirmModal>

View file

@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import FormatMessage from '$lib/elements/FormatMessage.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { ConfirmModal } from '@immich/ui';
import { mdiCancel } from '@mdi/js'; import { mdiCancel } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
onClose: (confirmed?: boolean) => void; onClose: (confirmed?: boolean) => void;
} };
let { onClose }: Props = $props(); let { onClose }: Props = $props();
</script> </script>
<Modal title={$t('admin.disable_login')} icon={mdiCancel} size="small" {onClose}> <ConfirmModal title={$t('admin.disable_login')} icon={mdiCancel} size="small" {onClose}>
<ModalBody> {#snippet prompt()}
<div class="flex flex-col gap-4 text-center"> <div class="flex flex-col gap-4 text-center">
<p>{$t('admin.authentication_settings_disable_all')}</p> <p>{$t('admin.authentication_settings_disable_all')}</p>
<p> <p>
@ -30,15 +30,5 @@
</FormatMessage> </FormatMessage>
</p> </p>
</div> </div>
</ModalBody> {/snippet}
<ModalFooter> </ConfirmModal>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{$t('cancel')}
</Button>
<Button shape="round" color="danger" fullWidth onclick={() => onClose(true)}>
{$t('confirm')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -1,33 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
location: { latitude: number | undefined; longitude: number | undefined }; location: { latitude: number | undefined; longitude: number | undefined };
assetCount: number; assetCount: number;
onClose: (confirm?: true) => void; onClose: (confirm: boolean) => void;
} }
let { location, assetCount, onClose }: Props = $props(); let { location, assetCount, onClose }: Props = $props();
</script> </script>
<Modal title={$t('confirm')} size="small" {onClose}> <ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
<ModalBody> {#snippet prompt()}
<p> <p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
{$t('update_location_action_prompt', {
values: {
count: assetCount,
},
})}
</p>
<p>- {$t('latitude')}: {location.latitude}</p> <p>- {$t('latitude')}: {location.latitude}</p>
<p>- {$t('longitude')}: {location.longitude}</p> <p>- {$t('longitude')}: {location.longitude}</p>
</ModalBody> {/snippet}
<ModalFooter> </ConfirmModal>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth onclick={() => onClose(true)}>{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { Button, Code, HStack, IconButton, Modal, ModalBody, ModalFooter, Text } from '@immich/ui'; import { BasicModal, Code, IconButton, Text } from '@immich/ui';
import { mdiCheck, mdiContentCopy } from '@mdi/js'; import { mdiCheck, mdiContentCopy } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -12,33 +12,23 @@
const { onClose, newPassword }: Props = $props(); const { onClose, newPassword }: Props = $props();
</script> </script>
<Modal title={$t('password_reset_success')} icon={mdiCheck} onClose={() => onClose()} size="small"> <BasicModal title={$t('password_reset_success')} icon={mdiCheck} {onClose} size="small" closeText={$t('done')}>
<ModalBody> <div class="flex flex-col gap-4">
<div class="flex flex-col gap-4"> <Text>{$t('admin.user_password_has_been_reset')}</Text>
<Text>{$t('admin.user_password_has_been_reset')}</Text>
<div class="flex justify-center gap-2 items-center"> <div class="flex justify-center gap-2 items-center">
<Code color="primary">{newPassword}</Code> <Code color="primary">{newPassword}</Code>
<IconButton <IconButton
icon={mdiContentCopy} icon={mdiContentCopy}
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => copyToClipboard(newPassword)} onclick={() => copyToClipboard(newPassword)}
title={$t('copy_password')} title={$t('copy_password')}
aria-label={$t('copy_password')} aria-label={$t('copy_password')}
/> />
</div>
<Text>{$t('admin.user_password_reset_description')}</Text>
</div> </div>
</ModalBody>
<ModalFooter> <Text>{$t('admin.user_password_reset_description')}</Text>
<HStack fullWidth> </div>
<Button shape="round" color="primary" fullWidth onclick={() => onClose()}> </BasicModal>
{$t('done')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -2,18 +2,18 @@
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { mergePerson, type PersonResponseDto } from '@immich/sdk'; import { mergePerson, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, Icon, IconButton, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui'; import { FormModal, Icon, IconButton, toastManager } from '@immich/ui';
import { mdiArrowLeft, mdiCallMerge, mdiSwapHorizontal } from '@mdi/js'; import { mdiArrowLeft, mdiCallMerge, mdiSwapHorizontal } from '@mdi/js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ImageThumbnail from '../components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../components/assets/thumbnail/image-thumbnail.svelte';
interface Props { type Props = {
personToMerge: PersonResponseDto; personToMerge: PersonResponseDto;
personToBeMergedInto: PersonResponseDto; personToBeMergedInto: PersonResponseDto;
potentialMergePeople: PersonResponseDto[]; potentialMergePeople: PersonResponseDto[];
onClose: (people?: [PersonResponseDto, PersonResponseDto]) => void; onClose: (people?: [PersonResponseDto, PersonResponseDto]) => void;
} };
let { let {
personToMerge = $bindable(), personToMerge = $bindable(),
@ -32,7 +32,7 @@
choosePersonToMerge = false; choosePersonToMerge = false;
}; };
const handleMergePerson = async () => { const onSubmit = async () => {
try { try {
await mergePerson({ await mergePerson({
id: personToBeMergedInto.id, id: personToBeMergedInto.id,
@ -51,99 +51,95 @@
}); });
</script> </script>
<Modal title="{$t('merge_people')} - {title}" {onClose}> <FormModal
<ModalBody> title="{$t('merge_people')} - {title}"
<div class="flex items-center justify-center gap-2 py-4 md:h-36"> submitColor="primary"
{#if !choosePersonToMerge} submitText={$t('yes')}
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2"> cancelText={$t('no')}
<ImageThumbnail {onClose}
circle {onSubmit}
shadow >
url={getPeopleThumbnailUrl(personToMerge)} <div class="flex items-center justify-center gap-2 py-4 md:h-36">
altText={personToMerge.name} {#if !choosePersonToMerge}
widthStyle="100%" <div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(personToMerge)}
altText={personToMerge.name}
widthStyle="100%"
/>
</div>
<div class="grid grid-rows-3">
<div></div>
<div class="flex flex-col h-full items-center justify-center">
<div class="flex h-full items-center justify-center">
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" />
</div>
</div>
<div>
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('swap_merge_direction')}
icon={mdiSwapHorizontal}
onclick={() => ([personToMerge, personToBeMergedInto] = [personToBeMergedInto, personToMerge])}
/> />
</div> </div>
</div>
<div class="grid grid-rows-3"> <button
<div></div> type="button"
<div class="flex flex-col h-full items-center justify-center"> disabled={potentialMergePeople.length === 0}
<div class="flex h-full items-center justify-center"> class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" /> onclick={() => {
</div> if (potentialMergePeople.length > 0) {
</div> choosePersonToMerge = !choosePersonToMerge;
<div> }
<IconButton }}
shape="round" >
color="secondary" <ImageThumbnail
variant="ghost" border={potentialMergePeople.length > 0}
aria-label={$t('swap_merge_direction')} circle
icon={mdiSwapHorizontal} shadow
onclick={() => ([personToMerge, personToBeMergedInto] = [personToBeMergedInto, personToMerge])} url={getPeopleThumbnailUrl(personToBeMergedInto)}
/> altText={personToBeMergedInto.name}
widthStyle="100%"
/>
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon icon={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
</button>
</div>
{/each}
</div> </div>
</div> </div>
</div>
{/if}
</div>
<button <div class="flex px-4 md:pt-4">
type="button" <h1 class="text-xl text-gray-500 dark:text-gray-300">{$t('are_these_the_same_person')}</h1>
disabled={potentialMergePeople.length === 0} </div>
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2" <div class="flex px-4 pt-2">
onclick={() => { <p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p>
if (potentialMergePeople.length > 0) { </div>
choosePersonToMerge = !choosePersonToMerge; </FormModal>
}
}}
>
<ImageThumbnail
border={potentialMergePeople.length > 0}
circle
shadow
url={getPeopleThumbnailUrl(personToBeMergedInto)}
altText={personToBeMergedInto.name}
widthStyle="100%"
/>
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon icon={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="flex px-4 md:pt-4">
<h1 class="text-xl text-gray-500 dark:text-gray-300">{$t('are_these_the_same_person')}</h1>
</div>
<div class="flex px-4 pt-2">
<p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p>
</div>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button fullWidth shape="round" color="secondary" onclick={() => onClose()}>{$t('no')}</Button>
<Button id="merge-confirm-button" fullWidth shape="round" onclick={handleMergePerson}>
{$t('yes')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View file

@ -2,7 +2,7 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createProfileImage, type AssetResponseDto } from '@immich/sdk'; import { createProfileImage, type AssetResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui'; import { FormModal, toastManager } from '@immich/ui';
import domtoimage from 'dom-to-image'; import domtoimage from 'dom-to-image';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -50,7 +50,7 @@
return false; return false;
}; };
const handleSetProfilePicture = async () => { const onSubmit = async () => {
if (!imgElement) { if (!imgElement) {
return; return;
} }
@ -72,24 +72,20 @@
toastManager.success($t('profile_picture_set')); toastManager.success($t('profile_picture_set'));
$user.profileImagePath = profileImagePath; $user.profileImagePath = profileImagePath;
$user.profileChangedAt = profileChangedAt; $user.profileChangedAt = profileChangedAt;
onClose();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_set_profile_picture')); handleError(error, $t('errors.unable_to_set_profile_picture'));
} }
onClose();
}; };
</script> </script>
<Modal size="small" title={$t('set_profile_picture')} {onClose}> <FormModal size="small" title={$t('set_profile_picture')} {onClose} {onSubmit}>
<ModalBody> <div class="flex place-items-center items-center justify-center">
<div class="flex place-items-center items-center justify-center"> <div
<div class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary" >
> <PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
</div>
</div> </div>
</ModalBody> </div>
<ModalFooter> </FormModal>
<Button fullWidth shape="round" onclick={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
</ModalFooter>
</Modal>

View file

@ -38,7 +38,7 @@
onClose={handleClose} onClose={handleClose}
{disabled} {disabled}
> >
{#snippet promptSnippet()} {#snippet prompt()}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Text> <Text>
{#if force} {#if force}

View file

@ -33,7 +33,7 @@
size="small" size="small"
onClose={handleClose} onClose={handleClose}
> >
{#snippet promptSnippet()} {#snippet prompt()}
<p> <p>
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }}> <FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
{#snippet children({ message })} {#snippet children({ message })}

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import FormatMessage from '$lib/elements/FormatMessage.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; import { BasicModal } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@ -12,33 +12,33 @@
const { serverVersion, releaseVersion, onClose }: Props = $props(); const { serverVersion, releaseVersion, onClose }: Props = $props();
</script> </script>
<Modal size="small" title="🎉 {$t('new_version_available')}" {onClose} icon={false}> <BasicModal
<ModalBody> size="small"
<div> title="🎉 {$t('new_version_available')}"
<FormatMessage key="version_announcement_message"> closeText={$t('acknowledge')}
{#snippet children({ tag, message })} closeColor="primary"
{#if tag === 'link'} {onClose}
<span class="font-medium underline"> icon={false}
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"> >
{message} <FormatMessage key="version_announcement_message">
</a> {#snippet children({ tag, message })}
</span> {#if tag === 'link'}
{:else if tag === 'code'} <span class="font-medium underline">
<code>{message}</code> <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
{/if} {message}
{/snippet} </a>
</FormatMessage> </span>
</div> {:else if tag === 'code'}
<code>{message}</code>
{/if}
{/snippet}
</FormatMessage>
<div class="mt-4 font-medium">{$t('version_announcement_closing')}</div> <div class="mt-4 font-medium">{$t('version_announcement_closing')}</div>
<div class="font-sm mt-8"> <div class="font-sm mt-8">
<code>{$t('server_version')}: {serverVersion}</code> <code>{$t('server_version')}: {serverVersion}</code>
<br /> <br />
<code>{$t('latest_version')}: {releaseVersion}</code> <code>{$t('latest_version')}: {releaseVersion}</code>
</div> </div>
</ModalBody> </BasicModal>
<ModalFooter>
<Button fullWidth shape="round" onclick={onClose}>{$t('acknowledge')}</Button>
</ModalFooter>
</Modal>

View file

@ -5,19 +5,7 @@
import { handleCreateUserAdmin } from '$lib/services/user-admin.service'; import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertToBytes } from '$lib/utils/byte-units'; import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
import { import { Field, FormModal, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui';
Button,
Field,
HelperText,
HStack,
Input,
Modal,
ModalBody,
ModalFooter,
PasswordInput,
Stack,
Switch,
} from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let success = $state(false); let success = $state(false);
@ -73,61 +61,48 @@
}; };
</script> </script>
<Modal title={$t('create_new_user')} {onClose} size="small"> <FormModal title={$t('create_new_user')} size="small" disabled={!valid} submitText={$t('create')} {onClose} {onSubmit}>
<ModalBody> {#if success}
<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form"> <p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
{#if success} {/if}
<p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
<Stack gap={4}>
<Field label={$t('email')} required>
<Input bind:value={email} type="email" />
</Field>
{#if featureFlagsManager.value.email}
<Field label={$t('admin.send_welcome_email')}>
<Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
</Field>
{/if}
<Field label={$t('password')} required={!featureFlagsManager.value.oauth}>
<PasswordInput id="password" bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('confirm_password')} required={!featureFlagsManager.value.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" step="1" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if} {/if}
</Field>
<Stack gap={4}> <Field label={$t('admin.admin_user')}>
<Field label={$t('email')} required> <Switch bind:checked={isAdmin} />
<Input bind:value={email} type="email" /> </Field>
</Field> </Stack>
</FormModal>
{#if featureFlagsManager.value.email}
<Field label={$t('admin.send_welcome_email')}>
<Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
</Field>
{/if}
<Field label={$t('password')} required={!featureFlagsManager.value.oauth}>
<PasswordInput id="password" bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('confirm_password')} required={!featureFlagsManager.value.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" step="1" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if}
</Field>
<Field label={$t('admin.admin_user')}>
<Switch bind:checked={isAdmin} />
</Field>
</Stack>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<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>
</HStack>
</ModalFooter>
</Modal>

View file

@ -5,19 +5,7 @@
import { user as authUser } from '$lib/stores/user.store'; import { user as authUser } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
import { import { Field, FormModal, Input, Link, NumberInput, Switch, Text } from '@immich/ui';
Button,
Field,
HStack,
Input,
Link,
Modal,
ModalBody,
ModalFooter,
NumberInput,
Switch,
Text,
} from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js'; import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@ -69,49 +57,36 @@
}; };
</script> </script>
<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose}> <FormModal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose} {onSubmit}>
<ModalBody> <Field label={$t('email')} required>
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form"> <Input type="email" bind:value={email} />
<Field label={$t('email')} required> </Field>
<Input type="email" bind:value={email} />
</Field>
<Field label={$t('name')} required class="mt-4"> <Field label={$t('name')} required class="mt-4">
<Input bind:value={name} /> <Input bind:value={name} />
</Field> </Field>
<Field label={$t('admin.quota_size_gib')} class="mt-4"> <Field label={$t('admin.quota_size_gib')} class="mt-4">
<NumberInput bind:value={quotaSize} min="0" step="1" placeholder={$t('unlimited')} /> <NumberInput bind:value={quotaSize} min="0" step="1" placeholder={$t('unlimited')} />
{#if quotaSizeWarning} {#if quotaSizeWarning}
<Text size="small" color="danger">{$t('errors.quota_higher_than_disk_size')}</Text> <Text size="small" color="danger">{$t('errors.quota_higher_than_disk_size')}</Text>
{/if} {/if}
</Field> </Field>
<Field label={$t('storage_label')} class="mt-4"> <Field label={$t('storage_label')} class="mt-4">
<Input bind:value={storageLabel} /> <Input bind:value={storageLabel} />
</Field> </Field>
<Text size="small" class="mt-2" color="muted"> <Text size="small" class="mt-2" color="muted">
{$t('admin.note_apply_storage_label_previous_assets')} {$t('admin.note_apply_storage_label_previous_assets')}
<Link href={AppRoute.ADMIN_QUEUES}> <Link href={AppRoute.ADMIN_QUEUES}>
{$t('admin.storage_template_migration_job')} {$t('admin.storage_template_migration_job')}
</Link> </Link>
</Text> </Text>
{#if user.id !== $authUser.id} {#if user.id !== $authUser.id}
<Field label={$t('admin.admin_user')}> <Field label={$t('admin.admin_user')}>
<Switch bind:checked={isAdmin} class="mt-4" /> <Switch bind:checked={isAdmin} class="mt-4" />
</Field> </Field>
{/if} {/if}
</form> </FormModal>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()}
>{$t('cancel')}</Button
>
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
</HStack>
</ModalFooter>
</Modal>