refactor: admin settings (#22109)

This commit is contained in:
Jason Rasmussen
2025-09-16 17:15:57 -04:00
committed by GitHub
parent a88a9a7d5e
commit 2012b07645
27 changed files with 72 additions and 72 deletions

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
import { retrieveServerConfig } from '$lib/stores/server-config.store';
import { cloneDeep, isEqual } from 'lodash-es';
import { onMount } from 'svelte';
import type { SettingsResetOptions } from './admin-settings';
import { t } from 'svelte-i18n';
interface Props {
config: SystemConfigDto;
children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
}
let { config = $bindable(), children }: Props = $props();
let savedConfig: SystemConfigDto | undefined = $state();
let defaultConfig: SystemConfigDto | undefined = $state();
export const handleReset = async (options: SettingsResetOptions) => {
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
};
export const handleSave = async (update: Partial<SystemConfigDto>) => {
let systemConfigDto = {
...savedConfig,
...update,
} as SystemConfigDto;
if (isEqual(systemConfigDto, savedConfig)) {
return;
}
try {
const newConfig = await updateConfig({
systemConfigDto,
});
config = cloneDeep(newConfig);
savedConfig = cloneDeep(newConfig);
notificationController.show({ message: $t('settings_saved'), type: NotificationType.Info });
await retrieveServerConfig();
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}
};
const reset = async (configKeys: Array<keyof SystemConfigDto>) => {
const resetConfig = await getConfig();
for (const key of configKeys) {
config = { ...config, [key]: resetConfig[key] };
}
notificationController.show({
message: $t('admin.reset_settings_to_recent_saved'),
type: NotificationType.Info,
});
};
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
if (!defaultConfig) {
return;
}
for (const key of configKeys) {
config = { ...config, [key]: defaultConfig[key] };
}
notificationController.show({
message: $t('admin.reset_settings_to_default'),
type: NotificationType.Info,
});
};
onMount(async () => {
[savedConfig, defaultConfig] = await Promise.all([getConfig(), getConfigDefaults()]);
});
</script>
{#if savedConfig && defaultConfig}
{@render children({ savedConfig, defaultConfig })}
{/if}

View File

@@ -0,0 +1,307 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = globalThis.location.origin + '/api/oauth/mobile-redirect';
}
};
const handleSave = async (skipConfirm: boolean) => {
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
if (allMethodsDisabled && !skipConfirm) {
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal);
if (!isConfirmed) {
return;
}
}
onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth });
};
const handleUnlinkAllOAuthAccounts = async () => {
const confirmed = await modalManager.showDialog({
icon: mdiRestart,
title: $t('admin.unlink_all_oauth_accounts'),
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
try {
await unlinkAllOAuthAccountsAdmin({});
notificationController.show({ message: $t('success'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col">
<SettingAccordion
key="oauth"
title={$t('admin.oauth_settings')}
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<Text size="small">
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</Text>
<SettingSwitch
{disabled}
title={$t('admin.oauth_enable_description')}
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
>{$t('admin.unlink_all_oauth_accounts')}</Button
>
</div>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_SECRET"
description={$t('admin.oauth_client_secret_description')}
bind:value={config.oauth.clientSecret}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
{#if config.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
bind:value={config.oauth.tokenEndpointAuthMethod}
disabled={disabled || !config.oauth.enabled || !config.oauth.clientSecret}
isEdited={!(config.oauth.tokenEndpointAuthMethod == savedConfig.oauth.tokenEndpointAuthMethod)}
options={[
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
]}
name="tokenEndpointAuthMethod"
/>
{/if}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="USERINFO_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_timeout')}
description={$t('admin.oauth_timeout_description')}
required={true}
bind:value={config.oauth.timeout}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.timeout == savedConfig.oauth.timeout)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim')}
description={$t('admin.oauth_storage_label_claim_description')}
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_role_claim')}
description={$t('admin.oauth_role_claim_description')}
bind:value={config.oauth.roleClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.roleClaim == savedConfig.oauth.roleClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_quota_claim')}
description={$t('admin.oauth_storage_quota_claim_description')}
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_storage_quota_default')}
description={$t('admin.oauth_storage_quota_default_description')}
bind:value={config.oauth.defaultStorageQuota}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_button_text')}
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
title={$t('admin.oauth_auto_register')}
subtitle={$t('admin.oauth_auto_register_description')}
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
title={$t('admin.oauth_auto_launch')}
subtitle={$t('admin.oauth_auto_launch_description')}
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
title={$t('admin.oauth_mobile_redirect_uri_override')}
subtitle={$t('admin.oauth_mobile_redirect_uri_override_description', {
values: { callback: 'app.immich:///oauth-callback' },
})}
disabled={disabled || !config.oauth.enabled}
onToggle={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_mobile_redirect_uri')}
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
</div>
</SettingAccordion>
<SettingAccordion
key="password"
title={$t('admin.password_settings')}
subtitle={$t('admin.password_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="ms-4 mt-4 flex flex-col">
<SettingSwitch
title={$t('admin.password_enable_description')}
{disabled}
bind:checked={config.passwordLogin.enabled}
/>
</div>
</div>
</SettingAccordion>
<SettingButtonsRow
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin) ||
!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
onReset={(options) => onReset({ ...options, configKeys: ['passwordLogin', 'oauth'] })}
onSave={() => handleSave(false)}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.backup_database_enable_description')}
{disabled}
bind:checked={config.backup.database.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !config.backup.database.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={config.backup.database.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !config.backup.database.enabled}
label={$t('admin.cron_expression')}
bind:value={config.backup.database.cronExpression}
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
>
{#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description">
{#snippet children({ message })}
<a
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
<br />
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required={true}
label={$t('admin.backup_keep_last_amount')}
disabled={disabled || !config.backup.database.enabled}
bind:value={config.backup.database.keepLastAmount}
isEdited={config.backup.database.keepLastAmount !== savedConfig.backup.database.keepLastAmount}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['backup'] })}
onSave={() => onSave({ backup: config.backup })}
showResetToDefault={!isEqual(savedConfig.backup, defaultConfig.backup)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,420 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import {
AudioCodec,
CQMode,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
type SystemConfigDto,
} from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHelpCircleOutline } from '@mdi/js';
import { isEqual, sortBy } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Icon icon={mdiHelpCircleOutline} class="inline" size="15" />
<FormatMessage key="admin.transcoding_codecs_learn_more">
{#snippet children({ tag, message })}
{#if tag === 'h264-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{:else if tag === 'hevc-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{:else if tag === 'vp9-link'}
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{/if}
{/snippet}
</FormatMessage>
</p>
<SettingAccordion
key="transcoding-policy"
title={$t('admin.transcoding_policy')}
subtitle={$t('admin.transcoding_policy_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_transcode_policy')}
{disabled}
desc={$t('admin.transcoding_transcode_policy_description')}
bind:value={config.ffmpeg.transcode}
name="transcode"
options={[
{ value: TranscodePolicy.All, text: $t('all_videos') },
{
value: TranscodePolicy.Optimal,
text: $t('admin.transcoding_optimal_description'),
},
{
value: TranscodePolicy.Bitrate,
text: $t('admin.transcoding_bitrate_description'),
},
{
value: TranscodePolicy.Required,
text: $t('admin.transcoding_required_description'),
},
{
value: TranscodePolicy.Disabled,
text: $t('admin.transcoding_disabled_description'),
},
]}
isEdited={config.ffmpeg.transcode !== savedConfig.ffmpeg.transcode}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_video_codecs')}
{disabled}
desc={$t('admin.transcoding_accepted_video_codecs_description')}
bind:value={config.ffmpeg.acceptedVideoCodecs}
name="videoCodecs"
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
{ value: VideoCodec.Vp9, text: 'VP9' },
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(
sortBy(config.ffmpeg.acceptedVideoCodecs),
sortBy(savedConfig.ffmpeg.acceptedVideoCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_audio_codecs')}
{disabled}
desc={$t('admin.transcoding_accepted_audio_codecs_description')}
bind:value={config.ffmpeg.acceptedAudioCodecs}
name="audioCodecs"
options={[
{ value: AudioCodec.Aac, text: 'AAC' },
{ value: AudioCodec.Mp3, text: 'MP3' },
{ value: AudioCodec.Libopus, text: 'Opus' },
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
]}
isEdited={!isEqual(
sortBy(config.ffmpeg.acceptedAudioCodecs),
sortBy(savedConfig.ffmpeg.acceptedAudioCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_accepted_containers')}
{disabled}
desc={$t('admin.transcoding_accepted_containers_description')}
bind:value={config.ffmpeg.acceptedContainers}
name="videoContainers"
options={[
{ value: VideoContainer.Mov, text: 'MOV' },
{ value: VideoContainer.Ogg, text: 'Ogg' },
{ value: VideoContainer.Webm, text: 'WebM' },
]}
isEdited={!isEqual(
sortBy(config.ffmpeg.acceptedContainers),
sortBy(savedConfig.ffmpeg.acceptedContainers),
)}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="encoding-options"
title={$t('admin.transcoding_encoding_options')}
subtitle={$t('admin.transcoding_encoding_options_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_video_codec')}
{disabled}
desc={$t('admin.transcoding_video_codec_description')}
bind:value={config.ffmpeg.targetVideoCodec}
options={[
{ value: VideoCodec.H264, text: 'h264' },
{ value: VideoCodec.Hevc, text: 'hevc' },
{ value: VideoCodec.Vp9, text: 'vp9' },
{ value: VideoCodec.Av1, text: 'av1' },
]}
name="vcodec"
isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec}
onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])}
/>
<!-- PCM is excluded here since it's a bad choice for users storage-wise -->
<SettingSelect
label={$t('admin.transcoding_audio_codec')}
{disabled}
desc={$t('admin.transcoding_audio_codec_description')}
bind:value={config.ffmpeg.targetAudioCodec}
options={[
{ value: AudioCodec.Aac, text: 'aac' },
{ value: AudioCodec.Mp3, text: 'mp3' },
{ value: AudioCodec.Libopus, text: 'opus' },
]}
name="acodec"
isEdited={config.ffmpeg.targetAudioCodec !== savedConfig.ffmpeg.targetAudioCodec}
onSelect={() =>
config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)
? null
: config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)}
/>
<SettingSelect
label={$t('admin.transcoding_target_resolution')}
{disabled}
desc={$t('admin.transcoding_target_resolution_description')}
bind:value={config.ffmpeg.targetResolution}
options={[
{ value: '2160', text: '4k' },
{ value: '1440', text: '1440p' },
{ value: '1080', text: '1080p' },
{ value: '720', text: '720p' },
{ value: '480', text: '480p' },
{ value: 'original', text: $t('original') },
]}
name="resolution"
isEdited={config.ffmpeg.targetResolution !== savedConfig.ffmpeg.targetResolution}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.transcoding_constant_rate_factor')}
description={$t('admin.transcoding_constant_rate_factor_description')}
bind:value={config.ffmpeg.crf}
required={true}
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
/>
<SettingSelect
label={$t('admin.transcoding_preset_preset')}
{disabled}
desc={$t('admin.transcoding_preset_preset_description')}
bind:value={config.ffmpeg.preset}
name="preset"
options={[
{ value: 'ultrafast', text: 'ultrafast' },
{ value: 'superfast', text: 'superfast' },
{ value: 'veryfast', text: 'veryfast' },
{ value: 'faster', text: 'faster' },
{ value: 'fast', text: 'fast' },
{ value: 'medium', text: 'medium' },
{ value: 'slow', text: 'slow' },
{ value: 'slower', text: 'slower' },
{ value: 'veryslow', text: 'veryslow' },
]}
isEdited={config.ffmpeg.preset !== savedConfig.ffmpeg.preset}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
{disabled}
label={$t('admin.transcoding_max_bitrate')}
description={$t('admin.transcoding_max_bitrate_description')}
bind:value={config.ffmpeg.maxBitrate}
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.transcoding_threads')}
description={$t('admin.transcoding_threads_description')}
bind:value={config.ffmpeg.threads}
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
/>
<SettingSelect
label={$t('admin.transcoding_tone_mapping')}
{disabled}
desc={$t('admin.transcoding_tone_mapping_description')}
bind:value={config.ffmpeg.tonemap}
name="tonemap"
options={[
{
value: ToneMapping.Hable,
text: 'Hable',
},
{
value: ToneMapping.Mobius,
text: 'Mobius',
},
{
value: ToneMapping.Reinhard,
text: 'Reinhard',
},
{
value: ToneMapping.Disabled,
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.tonemap !== savedConfig.ffmpeg.tonemap}
/>
<SettingSwitch
title={$t('admin.transcoding_two_pass_encoding')}
{disabled}
subtitle={$t('admin.transcoding_two_pass_encoding_setting_description')}
bind:checked={config.ffmpeg.twoPass}
isEdited={config.ffmpeg.twoPass !== savedConfig.ffmpeg.twoPass}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="hardware-acceleration"
title={$t('admin.transcoding_hardware_acceleration')}
subtitle={$t('admin.transcoding_hardware_acceleration_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSelect
label={$t('admin.transcoding_acceleration_api')}
{disabled}
desc={$t('admin.transcoding_acceleration_api_description')}
bind:value={config.ffmpeg.accel}
name="accel"
options={[
{ value: TranscodeHWAccel.Nvenc, text: $t('admin.transcoding_acceleration_nvenc') },
{
value: TranscodeHWAccel.Qsv,
text: $t('admin.transcoding_acceleration_qsv'),
},
{
value: TranscodeHWAccel.Vaapi,
text: $t('admin.transcoding_acceleration_vaapi'),
},
{
value: TranscodeHWAccel.Rkmpp,
text: $t('admin.transcoding_acceleration_rkmpp'),
},
{
value: TranscodeHWAccel.Disabled,
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
/>
<SettingSwitch
title={$t('admin.transcoding_hardware_decoding')}
{disabled}
subtitle={$t('admin.transcoding_hardware_decoding_setting_description')}
bind:checked={config.ffmpeg.accelDecode}
isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
/>
<SettingSelect
label={$t('admin.transcoding_constant_quality_mode')}
desc={$t('admin.transcoding_constant_quality_mode_description')}
bind:value={config.ffmpeg.cqMode}
options={[
{ value: CQMode.Auto, text: 'Auto' },
{ value: CQMode.Icq, text: 'ICQ' },
{ value: CQMode.Cqp, text: 'CQP' },
]}
isEdited={config.ffmpeg.cqMode !== savedConfig.ffmpeg.cqMode}
{disabled}
/>
<SettingSwitch
title={$t('admin.transcoding_temporal_aq')}
{disabled}
subtitle={$t('admin.transcoding_temporal_aq_description')}
bind:checked={config.ffmpeg.temporalAQ}
isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.transcoding_preferred_hardware_device')}
description={$t('admin.transcoding_preferred_hardware_device_description')}
bind:value={config.ffmpeg.preferredHwDevice}
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
{disabled}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="advanced-options"
title={$t('advanced')}
subtitle={$t('admin.transcoding_advanced_options_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')}
description={$t('admin.transcoding_max_b_frames_description')}
bind:value={config.ffmpeg.bframes}
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_reference_frames')}
description={$t('admin.transcoding_reference_frames_description')}
bind:value={config.ffmpeg.refs}
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_keyframe_interval')}
description={$t('admin.transcoding_max_keyframe_interval_description')}
bind:value={config.ffmpeg.gopSize}
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
{disabled}
/>
</div>
</SettingAccordion>
</div>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['ffmpeg'] })}
onSave={() => onSave({ ffmpeg: config.ffmpeg })}
showResetToDefault={!isEqual(savedConfig.ffmpeg, defaultConfig.ffmpeg)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4">
<SettingAccordion
key="thumbnail-settings"
title={$t('admin.image_thumbnail_title')}
subtitle={$t('admin.image_thumbnail_description')}
isOpen={openByDefault}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.thumbnail.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.thumbnail.format !== savedConfig.image.thumbnail.format}
{disabled}
/>
<SettingSelect
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={config.image.thumbnail.size}
options={[
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
{ value: 480, text: '480p' },
{ value: 250, text: '250p' },
{ value: 200, text: '200p' },
]}
name="resolution"
isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_thumbnail_quality_description')}
bind:value={config.image.thumbnail.quality}
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
{disabled}
/>
</SettingAccordion>
<SettingAccordion
key="preview-settings"
title={$t('admin.image_preview_title')}
subtitle={$t('admin.image_preview_description')}
isOpen={openByDefault}
>
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.preview.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.preview.format !== savedConfig.image.preview.format}
{disabled}
/>
<SettingSelect
label={$t('admin.image_resolution')}
desc={$t('admin.image_resolution_description')}
number
bind:value={config.image.preview.size}
options={[
{ value: 2160, text: '4K' },
{ value: 1440, text: '1440p' },
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
]}
name="resolution"
isEdited={config.image.preview.size !== savedConfig.image.preview.size}
{disabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_preview_quality_description')}
bind:value={config.image.preview.quality}
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
{disabled}
/>
</SettingAccordion>
<SettingAccordion
key="fullsize-settings"
title={$t('admin.image_fullsize_title')}
subtitle={$t('admin.image_fullsize_description')}
isOpen={openByDefault}
>
<SettingSwitch
title={$t('admin.image_fullsize_enabled')}
subtitle={$t('admin.image_fullsize_enabled_description')}
checked={config.image.fullsize.enabled}
onToggle={(isChecked) => (config.image.fullsize.enabled = isChecked)}
isEdited={config.image.fullsize.enabled !== savedConfig.image.fullsize.enabled}
{disabled}
/>
<hr class="my-4" />
<SettingSelect
label={$t('admin.image_format')}
desc={$t('admin.image_format_description')}
bind:value={config.image.fullsize.format}
options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' },
]}
name="format"
isEdited={config.image.fullsize.format !== savedConfig.image.fullsize.format}
disabled={disabled || !config.image.fullsize.enabled}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')}
description={$t('admin.image_fullsize_quality_description')}
bind:value={config.image.fullsize.quality}
isEdited={config.image.fullsize.quality !== savedConfig.image.fullsize.quality}
disabled={disabled || !config.image.fullsize.enabled}
/>
</SettingAccordion>
<div class="mt-4">
<SettingSwitch
title={$t('admin.image_prefer_wide_gamut')}
subtitle={$t('admin.image_prefer_wide_gamut_setting_description')}
checked={config.image.colorspace === Colorspace.P3}
onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)}
isEdited={config.image.colorspace !== savedConfig.image.colorspace}
{disabled}
/>
</div>
<div class="mt-4">
<SettingSwitch
title={$t('admin.image_prefer_embedded_preview')}
subtitle={$t('admin.image_prefer_embedded_preview_setting_description')}
checked={config.image.extractEmbedded}
onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)}
isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded}
{disabled}
/>
</div>
</div>
<div class="ms-4 mt-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['image'] })}
onSave={() => onSave({ image: config.image })}
showResetToDefault={!isEqual(savedConfig.image, defaultConfig.image)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { getJobName } from '$lib/utils';
import { JobName, type SystemConfigDto, type SystemConfigJobDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const jobNames = [
JobName.ThumbnailGeneration,
JobName.MetadataExtraction,
JobName.Library,
JobName.Sidecar,
JobName.SmartSearch,
JobName.FaceDetection,
JobName.FacialRecognition,
JobName.VideoConversion,
JobName.StorageTemplateMigration,
JobName.Migration,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
return jobName in config.job;
}
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
{#each jobNames as jobName (jobName)}
<div class="ms-4 mt-4 flex flex-col gap-4">
{#if isSystemConfigJobDto(jobName)}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
description=""
bind:value={config.job[jobName].concurrency}
required={true}
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
/>
{:else}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
description=""
value={1}
disabled={true}
title={$t('admin.job_not_concurrency_safe')}
/>
{/if}
</div>
{/each}
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['job'] })}
onSave={() => onSave({ job: config.job })}
showResetToDefault={!isEqual(savedConfig.job, defaultConfig.job)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
openByDefault?: boolean;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
onReset,
onSave,
openByDefault = false,
}: Props = $props();
let cronExpressionOptions = $derived([
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
]);
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingAccordion
key="library-watching"
title={$t('admin.library_watching_settings')}
subtitle={$t('admin.library_watching_settings_description')}
isOpen={openByDefault}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_watching_enable_description')}
{disabled}
bind:checked={config.library.watch.enabled}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="library-scanning"
title={$t('admin.library_scanning')}
subtitle={$t('admin.library_scanning_description')}
isOpen={openByDefault}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.library_scanning_enable_description')}
{disabled}
bind:checked={config.library.scan.enabled}
/>
<SettingSelect
options={cronExpressionOptions}
disabled={disabled || !config.library.scan.enabled}
name="expression"
label={$t('admin.cron_expression_presets')}
bind:value={config.library.scan.cronExpression}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required={true}
disabled={disabled || !config.library.scan.enabled}
label={$t('admin.cron_expression')}
bind:value={config.library.scan.cronExpression}
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
>
{#snippet descriptionSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.cron_expression_description">
{#snippet children({ message })}
<a
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
</div>
</SettingAccordion>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['library'] })}
onSave={() => onSave({ library: config.library })}
showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { LogLevel, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.logging_enable_description')}
{disabled}
bind:checked={config.logging.enabled}
/>
<SettingSelect
label={$t('level')}
desc={$t('admin.logging_level_description')}
bind:value={config.logging.level}
options={[
{ value: LogLevel.Fatal, text: 'Fatal' },
{ value: LogLevel.Error, text: 'Error' },
{ value: LogLevel.Warn, text: 'Warn' },
{ value: LogLevel.Log, text: 'Log' },
{ value: LogLevel.Debug, text: 'Debug' },
{ value: LogLevel.Verbose, text: 'Verbose' },
]}
name="level"
isEdited={config.logging.level !== savedConfig.logging.level}
disabled={disabled || !config.logging.enabled}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['logging'] })}
onSave={() => onSave({ logging: config.logging })}
showResetToDefault={!isEqual(savedConfig.logging, defaultConfig.logging)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import type { SystemConfigDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { mdiMinusCircle } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_enabled')}
subtitle={$t('admin.machine_learning_enabled_description')}
{disabled}
bind:checked={config.machineLearning.enabled}
/>
<hr />
<div>
{#each config.machineLearning.urls as _, i (i)}
{#snippet removeButton()}
{#if config.machineLearning.urls.length > 1}
<IconButton
size="large"
shape="round"
color="danger"
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiMinusCircle}
/>
{/if}
{/snippet}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('url') : undefined}
description={i === 0 ? $t('admin.machine_learning_url_description') : undefined}
bind:value={config.machineLearning.urls[i]}
required={i === 0}
disabled={disabled || !config.machineLearning.enabled}
isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)}
trailingSnippet={removeButton}
/>
{/each}
</div>
<Button
class="mb-2"
size="small"
shape="round"
onclick={() => config.machineLearning.urls.splice(0, 0, '')}
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
>
</div>
<SettingAccordion
key="smart-search"
title={$t('admin.machine_learning_smart_search')}
subtitle={$t('admin.machine_learning_smart_search_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_smart_search_enabled')}
subtitle={$t('admin.machine_learning_smart_search_enabled_description')}
bind:checked={config.machineLearning.clip.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.machine_learning_clip_model')}
bind:value={config.machineLearning.clip.modelName}
required={true}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
>
{#snippet descriptionSnippet()}
<p class="immich-form-label pb-2 text-sm">
<FormatMessage key="admin.machine_learning_clip_model_description">
{#snippet children({ message })}
<a target="_blank" href="https://huggingface.co/immich-app"><u>{message}</u></a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
</SettingInputField>
</div>
</SettingAccordion>
<SettingAccordion
key="duplicate-detection"
title={$t('admin.machine_learning_duplicate_detection')}
subtitle={$t('admin.machine_learning_duplicate_detection_setting_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_duplicate_detection_enabled')}
subtitle={$t('admin.machine_learning_duplicate_detection_enabled_description')}
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_detection_distance')}
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.0005"
min={0.001}
max={0.1}
description={$t('admin.machine_learning_max_detection_distance_description')}
disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="facial-recognition"
title={$t('admin.machine_learning_facial_recognition')}
subtitle={$t('admin.machine_learning_facial_recognition_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_facial_recognition_setting')}
subtitle={$t('admin.machine_learning_facial_recognition_setting_description')}
bind:checked={config.machineLearning.facialRecognition.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<hr />
<SettingSelect
label={$t('admin.machine_learning_facial_recognition_model')}
desc={$t('admin.machine_learning_facial_recognition_model_description')}
name="facial-recognition-model"
bind:value={config.machineLearning.facialRecognition.modelName}
options={[
{ value: 'antelopev2', text: 'antelopev2' },
{ value: 'buffalo_l', text: 'buffalo_l' },
{ value: 'buffalo_m', text: 'buffalo_m' },
{ value: 'buffalo_s', text: 'buffalo_s' },
]}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.modelName !==
savedConfig.machineLearning.facialRecognition.modelName}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_detection_score')}
description={$t('admin.machine_learning_min_detection_score_description')}
bind:value={config.machineLearning.facialRecognition.minScore}
step="0.01"
min={0.1}
max={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minScore !==
savedConfig.machineLearning.facialRecognition.minScore}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_max_recognition_distance')}
description={$t('admin.machine_learning_max_recognition_distance_description')}
bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.01"
min={0.1}
max={2}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.maxDistance !==
savedConfig.machineLearning.facialRecognition.maxDistance}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_min_recognized_faces')}
description={$t('admin.machine_learning_min_recognized_faces_description')}
bind:value={config.machineLearning.facialRecognition.minFaces}
step="1"
min={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minFaces !==
savedConfig.machineLearning.facialRecognition.minFaces}
/>
</div>
</SettingAccordion>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['machineLearning'] })}
onSave={() => onSave({ machineLearning: config.machineLearning })}
showResetToDefault={!isEqual(savedConfig.machineLearning, defaultConfig.machineLearning)}
{disabled}
/>
</form>
</div>
</div>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="flex flex-col gap-4">
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.map_enable_description')}
subtitle={$t('admin.map_implications')}
{disabled}
bind:checked={config.map.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_light_style')}
description={$t('admin.map_style_description')}
bind:value={config.map.lightStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.map_dark_style')}
description={$t('admin.map_style_description')}
bind:value={config.map.darkStyle}
disabled={disabled || !config.map.enabled}
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
/>
</div></SettingAccordion
>
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
{#snippet subtitleSnippet()}
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
{#snippet children({ message })}
<a
href="https://immich.app/docs/features/reverse-geocoding"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
{/snippet}
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.map_reverse_geocoding_enable_description')}
{disabled}
bind:checked={config.reverseGeocoding.enabled}
/>
</div></SettingAccordion
>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['map', 'reverseGeocoding'] })}
onSave={() => onSave({ map: config.map, reverseGeocoding: config.reverseGeocoding })}
showResetToDefault={!isEqual(
{ map: savedConfig.map, reverseGeocoding: savedConfig.reverseGeocoding },
{ map: defaultConfig.map, reverseGeocoding: defaultConfig.reverseGeocoding },
)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.metadata_faces_import_setting')}
subtitle={$t('admin.metadata_faces_import_setting_description')}
bind:checked={config.metadata.faces.import}
{disabled}
/>
</div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['metadata'] })}
onSave={() => onSave({ metadata: config.metadata })}
showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)}
{disabled}
/>
</form>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4">
<SettingSwitch
title={$t('admin.version_check_enabled_description')}
subtitle={$t('admin.version_check_implications')}
bind:checked={config.newVersionCheck.enabled}
{disabled}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['newVersionCheck'] })}
onSave={() => onSave({ newVersionCheck: config.newVersionCheck })}
showResetToDefault={!isEqual(savedConfig.newVersionCheck, defaultConfig.newVersionCheck)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.nightly_tasks_start_time_setting')}
description={$t('admin.nightly_tasks_start_time_setting_description')}
bind:value={config.nightlyTasks.startTime}
required={true}
{disabled}
isEdited={!(config.nightlyTasks.startTime === savedConfig.nightlyTasks.startTime)}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_database_cleanup_setting')}
subtitle={$t('admin.nightly_tasks_database_cleanup_setting_description')}
bind:checked={config.nightlyTasks.databaseCleanup}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_missing_thumbnails_setting')}
subtitle={$t('admin.nightly_tasks_missing_thumbnails_setting_description')}
bind:checked={config.nightlyTasks.missingThumbnails}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_cluster_new_faces_setting')}
subtitle={$t('admin.nightly_tasks_cluster_faces_setting_description')}
bind:checked={config.nightlyTasks.clusterNewFaces}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_generate_memories_setting')}
subtitle={$t('admin.nightly_tasks_generate_memories_setting_description')}
bind:checked={config.nightlyTasks.generateMemories}
{disabled}
/>
<SettingSwitch
title={$t('admin.nightly_tasks_sync_quota_usage_setting')}
subtitle={$t('admin.nightly_tasks_sync_quota_usage_setting_description')}
bind:checked={config.nightlyTasks.syncQuotaUsage}
{disabled}
/>
</div>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['nightlyTasks'] })}
onSave={() => onSave({ nightlyTasks: config.nightlyTasks })}
showResetToDefault={!isEqual(savedConfig.nightlyTasks, defaultConfig.nightlyTasks)}
{disabled}
/>
</form>
</div>
</div>

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import TemplateSettings from '$lib/components/admin-settings/TemplateSettings.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button, LoadingSpinner } from '@immich/ui';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
let isSending = $state(false);
const handleSendTestEmail = async () => {
if (isSending) {
return;
}
isSending = true;
try {
await sendTestEmailAdmin({
systemConfigSmtpDto: {
enabled: config.notifications.smtp.enabled,
transport: {
host: config.notifications.smtp.transport.host,
port: config.notifications.smtp.transport.port,
username: config.notifications.smtp.transport.username,
password: config.notifications.smtp.transport.password,
ignoreCert: config.notifications.smtp.transport.ignoreCert,
},
from: config.notifications.smtp.from,
replyTo: config.notifications.smtp.from,
},
});
notificationController.show({
type: NotificationType.Info,
message: $t('admin.notification_email_test_email_sent', { values: { email: $user.email } }),
});
if (!disabled) {
onSave({ notifications: config.notifications });
}
} catch (error) {
handleError(error, $t('admin.notification_email_test_email_failed'));
} finally {
isSending = false;
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.notification_enable_email_notifications')}
{disabled}
bind:checked={config.notifications.smtp.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label={$t('host')}
description={$t('admin.notification_email_host_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.host}
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required
label={$t('port')}
description={$t('admin.notification_email_port_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.port}
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('username')}
description={$t('admin.notification_email_username_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.username}
isEdited={config.notifications.smtp.transport.username !==
savedConfig.notifications.smtp.transport.username}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label={$t('password')}
description={$t('admin.notification_email_password_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.transport.password}
isEdited={config.notifications.smtp.transport.password !==
savedConfig.notifications.smtp.transport.password}
/>
<SettingSwitch
title={$t('admin.notification_email_ignore_certificate_errors')}
subtitle={$t('admin.notification_email_ignore_certificate_errors_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:checked={config.notifications.smtp.transport.ignoreCert}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
required
label={$t('admin.notification_email_from_address')}
description={$t('admin.notification_email_from_address_description')}
disabled={disabled || !config.notifications.smtp.enabled}
bind:value={config.notifications.smtp.from}
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
/>
<div class="flex gap-2 place-items-center">
<Button
size="small"
shape="round"
disabled={!config.notifications.smtp.enabled}
onclick={handleSendTestEmail}
>
{#if disabled}
{$t('admin.notification_email_test_email')}
{:else}
{$t('admin.notification_email_sent_test_email_button')}
{/if}
</Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div>
</div>
</SettingAccordion>
</div>
</form>
</div>
<TemplateSettings {config} {savedConfig} />
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['notifications', 'templates'] })}
onSave={() => onSave({ notifications: config.notifications, templates: config.templates })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="mt-4 ms-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_external_domain_settings')}
description={$t('admin.server_external_domain_settings_description')}
bind:value={config.server.externalDomain}
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_welcome_message')}
description={$t('admin.server_welcome_message_description')}
bind:value={config.server.loginPageMessage}
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
/>
<SettingSwitch
title={$t('admin.server_public_users')}
subtitle={$t('admin.server_public_users_description')}
{disabled}
bind:checked={config.server.publicUsers}
/>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}
onSave={() => onSave({ server: config.server })}
showResetToDefault={!isEqual(savedConfig.server, defaultConfig.server)}
{disabled}
/>
</div>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,293 @@
<script lang="ts">
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { AppRoute, SettingInputFieldType } from '$lib/constants';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { user } from '$lib/stores/user.store';
import {
getStorageTemplateOptions,
type SystemConfigDto,
type SystemConfigTemplateStorageOptionDto,
} from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import handlebar from 'handlebars';
import { isEqual } from 'lodash-es';
import * as luxon from 'luxon';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { createBubbler, preventDefault } from 'svelte/legacy';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
minified?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
duration?: number;
children?: Snippet;
}
let {
savedConfig,
defaultConfig,
config = $bindable(),
disabled = false,
minified = false,
onReset,
onSave,
duration = 500,
children,
}: Props = $props();
const bubble = createBubbler();
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
let selectedPreset = $state('');
const getTemplateOptions = async () => {
templateOptions = await getStorageTemplateOptions();
selectedPreset = savedConfig.storageTemplate.template;
};
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
const renderTemplate = (templateString: string) => {
if (!templateOptions) {
return '';
}
const template = handlebar.compile(templateString, {
knownHelpers: undefined,
});
const substitutions: Record<string, string> = {
filename: 'IMAGE_56437',
ext: 'jpg',
filetype: 'IMG',
filetypefull: 'IMAGE',
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
assetIdShort: '56717ccba856',
album: $t('album_name'),
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
const albumStartTime = luxon.DateTime.fromISO(new Date('2021-12-31T05:32:41.750').toISOString());
const albumEndTime = luxon.DateTime.fromISO(new Date('2023-05-06T09:15:17.100').toISOString());
const dateTokens = [
...templateOptions.yearOptions,
...templateOptions.monthOptions,
...templateOptions.weekOptions,
...templateOptions.dayOptions,
...templateOptions.hourOptions,
...templateOptions.minuteOptions,
...templateOptions.secondOptions,
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
}
return template(substitutions);
};
const handlePresetSelection = () => {
config.storageTemplate.template = selectedPreset;
};
let parsedTemplate = $derived(() => {
try {
return renderTemplate(config.storageTemplate.template);
} catch {
return 'error';
}
});
</script>
<section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.storage_template_more_details">
{#snippet children({ tag, message })}
{#if tag === 'template-link'}
<a
href="https://immich.app/docs/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{:else if tag === 'implications-link'}
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/if}
{/snippet}
</FormatMessage>
</p>
</div>
{#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ms-4 mt-4'}">
<SettingSwitch
title={$t('admin.storage_template_enable_description')}
{disabled}
bind:checked={config.storageTemplate.enabled}
isEdited={!(config.storageTemplate.enabled === savedConfig.storageTemplate.enabled)}
/>
{#if !minified}
<SettingSwitch
title={$t('admin.storage_template_hash_verification_enabled')}
{disabled}
subtitle={$t('admin.storage_template_hash_verification_enabled_description')}
bind:checked={config.storageTemplate.hashVerificationEnabled}
isEdited={!(
config.storageTemplate.hashVerificationEnabled === savedConfig.storageTemplate.hashVerificationEnabled
)}
/>
{/if}
{#if config.storageTemplate.enabled}
<hr />
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('variables')}</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="flex flex-col mt-4">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('template')}</h3>
<div class="my-2 text-sm">
<h4 class="uppercase">{$t('preview')}</h4>
</div>
<p class="text-sm">
<FormatMessage
key="admin.storage_template_path_length"
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
>
{#snippet children({ message })}
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
{/snippet}
</FormatMessage>
</p>
<p class="text-sm">
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
{#snippet children({ message })}
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
{/snippet}
</FormatMessage>
</p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/library/{$user.storageLabel || $user.id}</span
>/{parsedTemplate()}.jpg
</p>
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
<div class="flex flex-col my-2">
{#if templateOptions}
<label
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
for="preset-select"
>
{$t('preset')}
</label>
<select
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
disabled={disabled || !config.storageTemplate.enabled}
name="presets"
id="preset-select"
bind:value={selectedPreset}
onchange={handlePresetSelection}
>
{#each templateOptions.presetOptions as preset (preset)}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
{/if}
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label={$t('template')}
disabled={disabled || !config.storageTemplate.enabled}
required
inputType={SettingInputFieldType.TEXT}
bind:value={config.storageTemplate.template}
isEdited={!(config.storageTemplate.template === savedConfig.storageTemplate.template)}
/>
<div class="flex-0">
<SettingInputField
label={$t('extension')}
inputType={SettingInputFieldType.TEXT}
value=".jpg"
disabled
/>
</div>
</div>
{#if !minified}
<div id="migration-info" class="mt-2 text-sm">
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
<section class="flex flex-col gap-2">
<p>
<FormatMessage
key="admin.storage_template_migration_info"
values={{ job: $t('admin.storage_template_migration_job') }}
>
{#snippet children({ message })}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
{message}
</a>
{/snippet}
</FormatMessage>
</p>
</section>
</div>
{/if}
</form>
</div>
{/if}
{#if minified}
{@render children?.()}
{:else}
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}
onSave={() => onSave({ storageTemplate: config.storageTemplate })}
showResetToDefault={!isEqual(savedConfig.storageTemplate, defaultConfig.storageTemplate) && !minified}
{disabled}
/>
{/if}
</div>
{/await}
</section>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
options: SystemConfigTemplateStorageOptionDto;
}
let { options }: Props = $props();
const getLuxonExample = (format: string) => {
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
};
</script>
<div class="mt-2 text-sm">
<h4 class="uppercase">{$t('date_and_time')}</h4>
</div>
<!-- eslint-disable svelte/no-useless-mustaches -->
<div class="mt-2 rounded-lg bg-gray-200 p-4 text-xs dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
<p>{$t('admin.storage_template_date_time_description')}</p>
<p>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-09-04T20:03:05.250' } })}</p>
</div>
<div class="flex gap-[40px]">
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('year')}</p>
<ul>
{#each options.yearOptions as yearFormat, index (index)}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('month')}</p>
<ul>
{#each options.monthOptions as monthFormat, index (index)}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('week')}</p>
<ul>
{#each options.weekOptions as weekFormat, index (index)}
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('day')}</p>
<ul>
{#each options.dayOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('hour')}</p>
<ul>
{#each options.hourOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('minute')}</p>
<ul>
{#each options.minuteOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('second')}</p>
<ul>
{#each options.secondOptions as dayFormat, index (index)}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { t } from 'svelte-i18n';
</script>
<div class="mt-4 text-sm">
<h4 class="uppercase">{$t('other_variables')}</h4>
</div>
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
<div class="flex gap-[50px]">
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filename')}</p>
<ul>
<li>{`{{filename}}`} - IMG_123</li>
<li>{`{{ext}}`} - jpg</li>
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('filetype')}</p>
<ul>
<li>{`{{filetype}}`} - VID or IMG</li>
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
</ul>
</div>
<div>
<p class="uppercase font-medium text-immich-primary dark:text-immich-dark-primary">{$t('other')}</p>
<ul>
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
<li>{`{{album}}`} - Album Name</li>
<li>
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
</li>
<li>
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
</li>
</ul>
</div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import EmailTemplatePreviewModal from '$lib/modals/EmailTemplatePreviewModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type SystemConfigDto, type SystemConfigTemplateEmailsDto, getNotificationTemplateAdmin } from '@immich/sdk';
import { Button, Icon, LoadingSpinner, modalManager } from '@immich/ui';
import { mdiEyeOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
savedConfig: SystemConfigDto;
config: SystemConfigDto;
}
let { savedConfig, config = $bindable() }: Props = $props();
let loadingPreview = $state(false);
const getTemplate = async (name: string, template: string) => {
try {
loadingPreview = true;
const { html } = await getNotificationTemplateAdmin({ name, templateDto: { template } });
await modalManager.show(EmailTemplatePreviewModal, { html });
} catch (error) {
handleError(error, 'Could not load template.');
} finally {
loadingPreview = false;
}
};
const templateConfigs = [
{
label: $t('admin.template_email_welcome'),
templateKey: 'welcomeTemplate' as const,
descriptionTags: '{username}, {password}, {displayName}, {baseUrl}',
templateName: 'welcome',
},
{
label: $t('admin.template_email_invite_album'),
templateKey: 'albumInviteTemplate' as const,
descriptionTags: '{senderName}, {recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-invite',
},
{
label: $t('admin.template_email_update_album'),
templateKey: 'albumUpdateTemplate' as const,
descriptionTags: '{recipientName}, {albumId}, {albumName}, {baseUrl}',
templateName: 'album-update',
},
];
const isEdited = (templateKey: keyof SystemConfigTemplateEmailsDto) =>
config.templates.email[templateKey] !== savedConfig.templates.email[templateKey];
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit} class="mt-4">
<div class="flex flex-col gap-4">
<SettingAccordion
key="templates"
title={$t('admin.template_email_settings')}
subtitle={$t('admin.template_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<FormatMessage key="admin.template_email_if_empty">
{$t('admin.template_email_if_empty')}
</FormatMessage>
</p>
<hr />
{#if loadingPreview}
<LoadingSpinner />
{/if}
{#each templateConfigs as { label, templateKey, descriptionTags, templateName } (templateKey)}
<SettingTextarea
{label}
description={$t('admin.template_email_available_tags', { values: { tags: descriptionTags } })}
bind:value={config.templates.email[templateKey]}
isEdited={isEdited(templateKey)}
disabled={!config.notifications.smtp.enabled}
/>
<div class="flex justify-between">
<Button
size="small"
shape="round"
onclick={() => getTemplate(templateName, config.templates.email[templateKey])}
title={$t('admin.template_email_preview')}
>
<Icon icon={mdiEyeOutline} class="me-1" />
{$t('admin.template_email_preview')}
</Button>
</div>
{/each}
</div>
</SettingAccordion>
</div>
</form>
</div>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingTextarea
{disabled}
label={$t('admin.theme_custom_css_settings')}
description={$t('admin.theme_custom_css_settings_description')}
bind:value={config.theme.customCss}
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['theme'] })}
onSave={() => onSave({ theme: config.theme })}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
const onsubmit = (event: Event) => {
event.preventDefault();
};
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.trash_number_of_days')}
description={$t('admin.trash_number_of_days_description')}
bind:value={config.trash.days}
required={true}
disabled={disabled || !config.trash.enabled}
isEdited={config.trash.days !== savedConfig.trash.days}
/>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['trash'] })}
onSave={() => onSave({ trash: config.trash })}
showResetToDefault={!isEqual(savedConfig.trash, defaultConfig.trash)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from './admin-settings';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { SettingInputFieldType } from '$lib/constants';
import { t } from 'svelte-i18n';
interface Props {
savedConfig: SystemConfigDto;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
disabled?: boolean;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
}
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
</script>
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
min={1}
label={$t('admin.user_delete_delay_settings')}
description={$t('admin.user_delete_delay_settings_description')}
bind:value={config.user.deleteDelay}
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
/>
</div>
<div class="ms-4">
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['user'] })}
onSave={() => onSave({ user: config.user })}
showResetToDefault={!isEqual(savedConfig.user, defaultConfig.user)}
{disabled}
/>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,15 @@
import type { ResetOptions } from '$lib/utils/dipatch';
import type { SystemConfigDto } from '@immich/sdk';
export type SettingsResetOptions = ResetOptions & { configKeys: Array<keyof SystemConfigDto> };
export type SettingsResetEvent = (options: SettingsResetOptions) => void;
export type SettingsSaveEvent = (config: Partial<SystemConfigDto>) => void;
export type SettingsComponentProps = {
disabled?: boolean;
defaultConfig: SystemConfigDto;
config: SystemConfigDto;
savedConfig: SystemConfigDto;
onReset: SettingsResetEvent;
onSave: SettingsSaveEvent;
};