refactor: modals (#25162)

This commit is contained in:
Jason Rasmussen
2026-01-09 13:03:57 -05:00
committed by GitHub
parent da248414af
commit 702499b97d
11 changed files with 144 additions and 189 deletions

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { changePinCode } from '@immich/sdk';
import { Button, Heading, Text, toastManager } from '@immich/ui';
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -12,12 +13,6 @@
let isLoading = $state(false);
let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
type Props = {
onForgot: () => void;
};
let { onForgot }: Props = $props();
const handleSubmit = async (event: Event) => {
event.preventDefault();
await handleChangePinCode();
@@ -51,7 +46,7 @@
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={onForgot}>
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
import { getAuthStatus } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -14,18 +13,17 @@
hasPinCode = pinCode;
});
const handleResetPINCode = async () => {
const success = await modalManager.show(PinCodeResetModal, {});
if (success) {
hasPinCode = false;
}
const onUserPinCodeReset = () => {
hasPinCode = false;
};
</script>
<OnEvents {onUserPinCodeReset} />
<section>
{#if hasPinCode}
<div in:fade={{ duration: 200 }}>
<PinCodeChangeForm onForgot={handleResetPINCode} />
<PinCodeChangeForm />
</div>
{:else}
<div in:fade={{ duration: 200 }}>

View File

@@ -52,6 +52,8 @@ export type Events = {
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
UserPinCodeReset: [];
UserAdminCreate: [UserAdminResponseDto];
UserAdminUpdate: [UserAdminResponseDto];
UserAdminRestore: [UserAdminResponseDto];

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { Field, FormModal, Input, modalManager } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -15,11 +16,11 @@
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onSubmit = async () => {
const success = await handleCreateApiKey({
name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
});
if (success) {
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
const response = await handleCreateApiKey({ name, permissions });
if (response) {
// no nested modal
void modalManager.show(ApiKeySecretModal, { secret: response.secret });
onClose();
}
};

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { handleError } from '$lib/utils/handle-error';
import { createJob, ManualJobName } from '@immich/sdk';
import { ConfirmModal, toastManager } from '@immich/ui';
import { handleCreateJob } from '$lib/services/job.service';
import { ManualJobName } from '@immich/sdk';
import { FormModal } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = { onClose: (confirmed: boolean) => void };
type Props = { onClose: () => void };
let { onClose }: Props = $props();
@@ -20,42 +20,18 @@
let selectedJob: ComboBoxOption | undefined = $state(undefined);
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleCreate();
};
const handleCreate = async () => {
const onSubmit = async () => {
if (!selectedJob) {
return;
}
try {
await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } });
toastManager.success($t('admin.job_created'));
onClose(true);
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
const success = await handleCreateJob({ name: selectedJob.value as ManualJobName });
if (success) {
onClose();
}
};
</script>
<ConfirmModal
confirmColor="primary"
title={$t('admin.create_job')}
disabled={!selectedJob}
onClose={(confirmed) => (confirmed ? handleCreate() : onClose(false))}
>
{#snippet promptSnippet()}
<form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
<div class="flex flex-col gap-1 text-start">
<Combobox
bind:selectedOption={selectedJob}
label={$t('jobs')}
{options}
placeholder={$t('admin.search_jobs')}
/>
</div>
</form>
{/snippet}
</ConfirmModal>
<FormModal title={$t('admin.create_job')} submitText={$t('create')} disabled={!selectedJob} {onClose} {onSubmit}>
<Combobox bind:selectedOption={selectedJob} label={$t('jobs')} {options} placeholder={$t('admin.search_jobs')} />
</FormModal>

View File

@@ -1,10 +1,8 @@
<script lang="ts">
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { createApiKey, Permission } from '@immich/sdk';
import { Button, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { Permission } from '@immich/sdk';
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
let inputUrl = $state(location.origin);
let inputApiKey = $state('');
@@ -12,72 +10,63 @@
let obtainiumLink = $derived(
`https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.alextran.immich%22%2C%22url%22%3A%22${inputUrl}%2Fapi%2Fserver%2Fapk-links%22%2C%22author%22%3A%22Immich%22%2C%22name%22%3A%22Immich%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%2C%7B%5C%22requestHeader%5C%22%3A%5C%22x-api-key%3A%20${inputApiKey}%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%2Fv(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B)%2F%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241.%242.%243%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-${archVariant}.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D`,
);
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
const handleCreate = async () => {
try {
const { secret } = await createApiKey({
apiKeyCreateDto: {
name: 'Obtainium',
permissions: [Permission.ServerApkLinks],
},
});
inputApiKey = secret;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
const response = await handleCreateApiKey({ name: 'Obtainium', permissions: [Permission.ServerApkLinks] });
if (response) {
inputApiKey = response.secret;
}
};
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
</script>
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
<ModalBody>
<div>
<Text color="muted" size="small">
{$t('obtainium_configurator_instructions')}
</Text>
<form class="mt-4">
<div class="mt-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
</div>
<Text color="muted" size="small">{$t('obtainium_configurator_instructions')}</Text>
<div class="mt-2 flex gap-2 place-items-center place-content-center">
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('api_key')} bind:value={inputApiKey} />
<Field label={$t('url')} class="mt-4">
<Input bind:value={inputUrl} />
</Field>
<div class="translate-y-[3px]">
<Button size="small" onclick={() => handleCreate()}>{$t('create_api_key')}</Button>
</div>
</div>
<Field label={$t('api_key')} class="mt-4">
<Input bind:value={inputApiKey} />
</Field>
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
</form>
{#if inputUrl && inputApiKey && archVariant}
<div class="content-center">
<hr />
<div class="flex place-items-center place-content-center">
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 pr-5 h-20" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
</div>
</div>
{/if}
<div class="flex justify-end mt-2">
<Button size="small" onclick={handleCreate}>{$t('create_api_key')}</Button>
</div>
<SettingSelect
label={$t('app_architecture_variant')}
bind:value={archVariant}
options={[
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
{ value: 'release', text: 'universal' },
{ value: 'x86_64-release', text: 'x86_64' },
]}
/>
{#if inputUrl && inputApiKey && archVariant}
<div class="content-center">
<hr />
<div class="flex place-items-center place-content-center">
<a
href={obtainiumLink}
class="underline text-sm immich-form-label"
target="_blank"
rel="noreferrer"
id="obtainium-link"
>
<img class="pt-2 h-20" alt="Get it on Obtainium" src={obtainiumBadge} />
</a>
</div>
</div>
{/if}
</ModalBody>
</Modal>

View File

@@ -1,20 +1,7 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { resetPinCode } from '@immich/sdk';
import {
Button,
Field,
HelperText,
HStack,
Modal,
ModalBody,
ModalFooter,
PasswordInput,
Stack,
Text,
toastManager,
} from '@immich/ui';
import { handleResetPinCode } from '$lib/services/user.service';
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -24,55 +11,35 @@
let { onClose }: Props = $props();
let passwordLoginEnabled = $derived(featureFlagsManager.value.passwordLogin);
let password = $state('');
const handleReset = async () => {
try {
await resetPinCode({ pinCodeResetDto: { password } });
toastManager.success($t('pin_code_reset_successfully'));
onClose(true);
} catch (error) {
handleError(error, $t('errors.failed_to_reset_pin_code'));
const onSubmit = async () => {
const success = await handleResetPinCode({ password });
if (success) {
onClose();
}
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleReset();
};
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script>
<Modal title={$t('reset_pin_code')} icon={mdiLockReset} size="small" {onClose}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="reset-pin-form">
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
{#if passwordLoginEnabled}
<hr class="my-2 h-px w-full border-0 bg-gray-200 dark:bg-gray-600" />
<section>
<Field label={$t('confirm_password')} required>
<PasswordInput bind:value={password} autocomplete="current-password" />
<HelperText>
<Text color="muted">{$t('reset_pin_code_with_password')}</Text>
</HelperText>
</Field>
</section>
{/if}
</Stack>
</form>
</ModalBody>
<ModalFooter>
{#if passwordLoginEnabled}
<HStack fullWidth>
<Button fullWidth shape="round" color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button type="submit" form="reset-pin-form" fullWidth shape="round" color="danger" disabled={!password}>
{$t('reset')}
</Button>
</HStack>
{:else}
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('close')}</Button>
{/if}
</ModalFooter>
</Modal>
{#if featureFlagsManager.value.passwordLogin === false}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
<hr class="my-2 h-px w-full border-0 bg-gray-200 dark:bg-gray-600" />
<section>
<Field label={$t('confirm_password')} required>
<PasswordInput bind:value={password} autocomplete="current-password" />
<HelperText color="muted">{$t('reset_pin_code_with_password')}</HelperText>
</Field>
</section>
</Stack>
</FormModal>
{:else}
<Modal {...common} closeOnBackdropClick>
<ModalBody>
<div>{$t('reset_pin_code_description')}</div>
</ModalBody>
</Modal>
{/if}

View File

@@ -1,6 +1,5 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -56,14 +55,10 @@ export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => {
return;
}
const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto });
const response = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', response.apiKey);
eventManager.emit('ApiKeyCreate', apiKey);
// no nested modal
void modalManager.show(ApiKeySecretModal, { secret });
return true;
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}

View File

@@ -0,0 +1,16 @@
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { createJob, type JobCreateDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
export const handleCreateJob = async (dto: JobCreateDto) => {
const $t = await getFormatter();
try {
await createJob({ jobCreateDto: dto });
toastManager.success($t('admin.job_created'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};

View File

@@ -64,9 +64,7 @@ export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[
title: $t('admin.create_job'),
type: $t('command'),
shortcuts: { shift: true, key: 'n' },
onAction: async () => {
await modalManager.show(JobCreateModal, {});
},
onAction: () => modalManager.show(JobCreateModal, {}),
};
const ManageConcurrency: ActionItem = {

View File

@@ -0,0 +1,18 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { resetPinCode, type PinCodeResetDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
export const handleResetPinCode = async (dto: PinCodeResetDto) => {
const $t = await getFormatter();
try {
await resetPinCode({ pinCodeResetDto: dto });
toastManager.success($t('pin_code_reset_successfully'));
eventManager.emit('UserPinCodeReset');
return true;
} catch (error) {
handleError(error, $t('errors.failed_to_reset_pin_code'));
}
};