feat: improved storage template onboarding (#31531)
* feat: improved storage template onboarding * chore: minor cleanup
This commit is contained in:
+7
-1
@@ -369,11 +369,17 @@
|
||||
"storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.",
|
||||
"storage_template_migration_job": "Storage Template Migration Job",
|
||||
"storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>",
|
||||
"storage_template_onboarding_description_v2": "When enabled, this feature will auto-organize files based on a user-defined template. For more information, please see the <link>documentation</link>.",
|
||||
"storage_template_onboarding_description_v3": "Storage templates allow you to define a custom folder structure on disk for your uploaded assets. This can always be changed later.",
|
||||
"storage_template_option_custom": "Custom",
|
||||
"storage_template_option_custom_description": "Build your own folder structure with template variables",
|
||||
"storage_template_option_date_description": "Sort files into year, month, and day folders",
|
||||
"storage_template_option_none": "No template",
|
||||
"storage_template_option_none_description": "Keep the folder structure Immich uses by default",
|
||||
"storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}",
|
||||
"storage_template_settings": "Storage Template",
|
||||
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
|
||||
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
|
||||
"storage_template_variables": "Template variables",
|
||||
"system_settings": "System Settings",
|
||||
"tag_cleanup_job": "Tag cleanup",
|
||||
"template_email_available_tags": "You can use the following variables in your template: {tags}",
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<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/SystemConfigButtonRow.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import StorageTemplateVariablesModal from '$lib/modals/StorageTemplateVariablesModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleSystemConfigSave } from '$lib/services/system-config.service';
|
||||
import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Heading, Link, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { Code, Heading, IconButton, Link, modalManager, Text } from '@immich/ui';
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
minified?: boolean;
|
||||
@@ -28,21 +27,29 @@
|
||||
|
||||
const { minified = false, duration = 500, saveOnClose = false }: Props = $props();
|
||||
|
||||
const DATE_TEMPLATE = '{{y}}/{{MM}}/{{dd}}/{{filename}}';
|
||||
|
||||
const StorageTemplateOption = {
|
||||
None: 'none',
|
||||
Date: 'date',
|
||||
Custom: 'custom',
|
||||
} as const;
|
||||
|
||||
type StorageTemplateOption = (typeof StorageTemplateOption)[keyof typeof StorageTemplateOption];
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
|
||||
const bubble = createBubbler();
|
||||
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
|
||||
let selectedPreset = $state('');
|
||||
let customTemplate = $state(systemConfigManager.value.storageTemplate.template);
|
||||
|
||||
const getTemplateOptions = async () => {
|
||||
templateOptions = await getStorageTemplateOptions();
|
||||
selectedPreset = config.storageTemplate.template;
|
||||
};
|
||||
|
||||
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
|
||||
|
||||
const renderTemplate = (templateString: string) => {
|
||||
if (!templateOptions) {
|
||||
return '';
|
||||
@@ -91,9 +98,47 @@
|
||||
const handlePresetSelection = () => {
|
||||
configToEdit.storageTemplate.template = selectedPreset;
|
||||
};
|
||||
let parsedTemplate = $derived(() => {
|
||||
|
||||
let selectedOption = $state(
|
||||
// initially load the selection state from saved config
|
||||
(() => {
|
||||
if (!configToEdit.storageTemplate.enabled) {
|
||||
return StorageTemplateOption.None;
|
||||
}
|
||||
|
||||
return configToEdit.storageTemplate.template === DATE_TEMPLATE
|
||||
? StorageTemplateOption.Date
|
||||
: StorageTemplateOption.Custom;
|
||||
})(),
|
||||
);
|
||||
|
||||
const handleOptionSelection = (option: StorageTemplateOption) => {
|
||||
if (selectedOption === StorageTemplateOption.Custom) {
|
||||
customTemplate = configToEdit.storageTemplate.template;
|
||||
}
|
||||
|
||||
const templates: Record<StorageTemplateOption, string> = {
|
||||
[StorageTemplateOption.None]: configToEdit.storageTemplate.template,
|
||||
[StorageTemplateOption.Date]: DATE_TEMPLATE,
|
||||
[StorageTemplateOption.Custom]: customTemplate,
|
||||
};
|
||||
|
||||
selectedOption = option;
|
||||
configToEdit.storageTemplate.enabled = option !== StorageTemplateOption.None;
|
||||
configToEdit.storageTemplate.template = templates[option];
|
||||
};
|
||||
|
||||
const handleShowVariables = async () => {
|
||||
if (templateOptions) {
|
||||
await modalManager.show(StorageTemplateVariablesModal, { options: templateOptions });
|
||||
}
|
||||
};
|
||||
|
||||
let parsedCustomTemplate = $derived(() => {
|
||||
try {
|
||||
return renderTemplate(configToEdit.storageTemplate.template);
|
||||
return renderTemplate(
|
||||
selectedOption === StorageTemplateOption.Custom ? configToEdit.storageTemplate.template : customTemplate,
|
||||
);
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
@@ -106,30 +151,149 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="mt-2 dark:text-immich-dark-fg">
|
||||
<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'}
|
||||
<Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
|
||||
{:else if tag === 'implications-link'}
|
||||
<Link href="https://docs.immich.app/administration/backup-and-restore#asset-types-and-storage-locations">
|
||||
{message}
|
||||
</Link>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{#snippet templateOption(option: StorageTemplateOption, label: string, description: string, preview: string)}
|
||||
<div
|
||||
class="flex flex-col rounded-2xl border-2 p-4 transition-colors {selectedOption === option
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-primary/10 hover:border-primary/20'} {disabled ? 'opacity-50' : ''}"
|
||||
>
|
||||
<label class="flex items-center gap-3 {disabled ? 'cursor-not-allowed' : 'cursor-pointer'}">
|
||||
<input
|
||||
type="radio"
|
||||
name="storage-template-option"
|
||||
class="accent-primary focus-visible:ring"
|
||||
{disabled}
|
||||
checked={selectedOption === option}
|
||||
onchange={() => handleOptionSelection(option)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{label}</span>
|
||||
<Text size="small" color="muted">{description}</Text>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if option === StorageTemplateOption.Custom && selectedOption === StorageTemplateOption.Custom}
|
||||
<div class="mt-4 flex flex-col gap-2" transition:slide={{ duration }}>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-sm font-medium text-primary" for="preset-select">
|
||||
{$t('preset')}
|
||||
</label>
|
||||
<select
|
||||
class="mt-2 immich-form-input rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
|
||||
{disabled}
|
||||
name="presets"
|
||||
id="preset-select"
|
||||
bind:value={selectedPreset}
|
||||
onchange={handlePresetSelection}
|
||||
>
|
||||
{#each templateOptions?.presetOptions ?? [] as preset (preset)}
|
||||
<option value={preset}>{renderTemplate(preset)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<SettingInputField
|
||||
label={$t('template')}
|
||||
{disabled}
|
||||
required
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
bind:value={configToEdit.storageTemplate.template}
|
||||
isEdited={configToEdit.storageTemplate.template !== config.storageTemplate.template}
|
||||
>
|
||||
{#snippet trailingSnippet()}
|
||||
<IconButton
|
||||
class="shrink-0"
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('admin.storage_template_variables')}
|
||||
title={$t('admin.storage_template_variables')}
|
||||
icon={mdiInformationOutline}
|
||||
onclick={handleShowVariables}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
|
||||
<Text size="small">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_path_length"
|
||||
values={{
|
||||
length: preview.length + authManager.user.id.length + 'UPLOAD_LOCATION'.length,
|
||||
limit: 260,
|
||||
}}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<span class="font-semibold text-primary">{message}</span>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</Text>
|
||||
|
||||
<Text size="small">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_user_label"
|
||||
values={{ label: authManager.user.storageLabel || authManager.user.id }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<code class="text-primary">{message}</code>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</Text>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<Text size="small" class="mt-4">
|
||||
{$t('preview')}:
|
||||
<Code size="small"
|
||||
>{`UPLOAD_LOCATION/library/${authManager.user.storageLabel || authManager.user.id}/${preview}.jpg`}</Code
|
||||
>
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section class="mt-2 dark:text-immich-dark-fg">
|
||||
{#if !minified}
|
||||
<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'}
|
||||
<Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
|
||||
{:else if tag === 'implications-link'}
|
||||
<Link href="https://docs.immich.app/administration/backup-and-restore#asset-types-and-storage-locations">
|
||||
{message}
|
||||
</Link>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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={configToEdit.storageTemplate.enabled}
|
||||
isEdited={configToEdit.storageTemplate.enabled !== config.storageTemplate.enabled}
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
{@render templateOption(
|
||||
StorageTemplateOption.None,
|
||||
$t('admin.storage_template_option_none'),
|
||||
$t('admin.storage_template_option_none_description'),
|
||||
'',
|
||||
)}
|
||||
{@render templateOption(
|
||||
StorageTemplateOption.Date,
|
||||
'YYYY/MM/DD',
|
||||
$t('admin.storage_template_option_date_description'),
|
||||
renderTemplate(DATE_TEMPLATE),
|
||||
)}
|
||||
{@render templateOption(
|
||||
StorageTemplateOption.Custom,
|
||||
$t('admin.storage_template_option_custom'),
|
||||
$t('admin.storage_template_option_custom_description'),
|
||||
parsedCustomTemplate(),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{#if !minified}
|
||||
<SettingSwitch
|
||||
@@ -140,135 +304,27 @@
|
||||
isEdited={configToEdit.storageTemplate.hashVerificationEnabled !==
|
||||
config.storageTemplate.hashVerificationEnabled}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if configToEdit.storageTemplate.enabled}
|
||||
<hr />
|
||||
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('variables')}
|
||||
</Heading>
|
||||
|
||||
<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="mt-2 flex flex-col">
|
||||
<!-- <h3 class="text-base font-medium text-primary">{$t('template')}</h3> -->
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('template')}
|
||||
</Heading>
|
||||
|
||||
<div class="my-2">
|
||||
<Text size="small">{$t('preview')}</Text>
|
||||
</div>
|
||||
|
||||
<p class="text-sm">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_path_length"
|
||||
values={{
|
||||
length: parsedTemplate().length + authManager.user.id.length + 'UPLOAD_LOCATION'.length,
|
||||
limit: 260,
|
||||
}}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<span class="font-semibold text-primary">{message}</span>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<p class="text-sm">
|
||||
<FormatMessage
|
||||
key="admin.storage_template_user_label"
|
||||
values={{ label: authManager.user.storageLabel || authManager.user.id }}
|
||||
>
|
||||
{#snippet children({ message })}
|
||||
<code class="text-primary">{message}</code>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
|
||||
<p class="mt-2 rounded-lg bg-gray-200 p-4 py-2 text-xs dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
|
||||
>UPLOAD_LOCATION/library/{authManager.user.storageLabel || authManager.user.id}</span
|
||||
>/{parsedTemplate()}.jpg
|
||||
</p>
|
||||
|
||||
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="my-2 flex flex-col">
|
||||
{#if templateOptions}
|
||||
<label class="text-sm font-medium text-primary" for="preset-select">
|
||||
{$t('preset')}
|
||||
</label>
|
||||
<select
|
||||
class="mt-2 immich-form-input rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
|
||||
disabled={disabled || !configToEdit.storageTemplate.enabled}
|
||||
name="presets"
|
||||
id="preset-select"
|
||||
bind:value={selectedPreset}
|
||||
onchange={handlePresetSelection}
|
||||
{#if configToEdit.storageTemplate.enabled}
|
||||
<div id="migration-info" class="mt-2 text-sm">
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('notes')}
|
||||
</Heading>
|
||||
<section class="flex flex-col gap-2">
|
||||
<p>
|
||||
<FormatMessage
|
||||
key="admin.storage_template_migration_info"
|
||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||
>
|
||||
{#each templateOptions.presetOptions as preset (preset)}
|
||||
<option value={preset}>{renderTemplate(preset)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet children({ message })}
|
||||
<a href={Route.queues()} class="text-primary">{message}</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 align-bottom">
|
||||
<SettingInputField
|
||||
label={$t('template')}
|
||||
disabled={disabled || !configToEdit.storageTemplate.enabled}
|
||||
required
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
bind:value={configToEdit.storageTemplate.template}
|
||||
isEdited={configToEdit.storageTemplate.template !== config.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">
|
||||
<Heading size="tiny" color="primary">
|
||||
{$t('notes')}
|
||||
</Heading>
|
||||
<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={Route.queues()} class="text-primary">{message}</a>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !minified}
|
||||
<SettingButtonsRow bind:configToEdit keys={['storageTemplate']} {disabled} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { Card, CardBody, CardHeader, Text } from '@immich/ui';
|
||||
import { Card, CardBody, CardHeader, Heading, Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Text size="small">{$t('date_and_time')}</Text>
|
||||
<Heading size="small">{$t('date_and_time')}</Heading>
|
||||
|
||||
<Card class="mt-2 bg-light-50 text-sm shadow-none">
|
||||
<Card class="bg-light-50 text-sm shadow-none">
|
||||
<CardHeader>
|
||||
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
||||
<Text color="primary"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardBody, Text } from '@immich/ui';
|
||||
import { Card, CardBody, Heading, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<Text size="small">{$t('other_variables')}</Text>
|
||||
<Heading>{$t('other_variables')}</Heading>
|
||||
|
||||
<Card class="mt-2 bg-light-50 text-sm shadow-none">
|
||||
<Card class="bg-light-50 text-sm shadow-none">
|
||||
<CardBody>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
|
||||
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
|
||||
import type { SystemConfigTemplateStorageOptionDto } from '@immich/sdk';
|
||||
import { BasicModal, Stack } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
options: SystemConfigTemplateStorageOptionDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { options, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('variables')} size="giant" {onClose}>
|
||||
<Stack gap={4}>
|
||||
<SupportedDatetimePanel {options} />
|
||||
<SupportedVariablesPanel />
|
||||
</Stack>
|
||||
</BasicModal>
|
||||
@@ -2,16 +2,11 @@
|
||||
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p>
|
||||
<FormatMessage key="admin.storage_template_onboarding_description_v2">
|
||||
{#snippet children({ message })}
|
||||
<Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
<FormatMessage key="admin.storage_template_onboarding_description_v3" />
|
||||
</p>
|
||||
|
||||
{#if authManager.authenticated}
|
||||
|
||||
Reference in New Issue
Block a user