chore(web): more translations for user settings and admin pages (#10161)

* chore(web): more translations for user settings and admin pages

* JobSettings translations

* feedback

* missed one

* feedback
This commit is contained in:
Michel Heusschen
2024-06-12 12:54:40 +02:00
committed by GitHub
parent 0e1311e3d3
commit 9e5c52b7b7
34 changed files with 300 additions and 160 deletions

View File

@@ -76,13 +76,10 @@
</div>
{#if forceDelete}
<p class="text-immich-error">
WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be
recovered.
</p>
<p class="text-immich-error">{$t('admin.force_delete_user_warning')}</p>
<p class="immich-form-label text-sm" id="confirm-user-desc">
To confirm, type "{user.email}" below
{$t('admin.confirm_email_below', { values: { email: user.email } })}
</p>
<input

View File

@@ -58,7 +58,7 @@
<Badge color="primary">
<div class="flex flex-row gap-1">
<span class="text-sm">
{jobCounts.failed.toLocaleString($locale)} failed
{$t('admin.jobs_failed', { values: { jobCount: jobCounts.failed.toLocaleString($locale) } })}
</span>
<CircleIconButton
color="primary"
@@ -74,7 +74,7 @@
{#if jobCounts.delayed > 0}
<Badge color="secondary">
<span class="text-sm">
{jobCounts.delayed.toLocaleString($locale)} delayed
{$t('admin.jobs_delayed', { values: { jobCount: jobCounts.delayed.toLocaleString($locale) } })}
</span>
</Badge>
{/if}
@@ -119,12 +119,14 @@
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<Icon path={mdiAlertCircle} size="36" /> DISABLED
<Icon path={mdiAlertCircle} size="36" />
{$t('disabled').toUpperCase()}
</JobTileButton>
{:else if !isIdle}
{#if waitingCount > 0}
<JobTileButton color="gray" on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}>
<Icon path={mdiClose} size="24" /> CLEAR
<Icon path={mdiClose} size="24" />
{$t('clear').toUpperCase()}
</JobTileButton>
{/if}
{#if queueStatus.isPaused}
@@ -134,14 +136,16 @@
on:click={() => dispatch('command', { command: JobCommand.Resume, force: false })}
>
<!-- size property is not reactive, so have to use width and height -->
<Icon path={mdiFastForward} {size} /> RESUME
<Icon path={mdiFastForward} {size} />
{$t('resume').toUpperCase()}
</JobTileButton>
{:else}
<JobTileButton
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Pause, force: false })}
>
<Icon path={mdiPause} size="24" /> PAUSE
<Icon path={mdiPause} size="24" />
{$t('pause').toUpperCase()}
</JobTileButton>
{/if}
{:else if allowForceCommand}
@@ -161,7 +165,8 @@
color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
>
<Icon path={mdiPlay} size="48" /> START
<Icon path={mdiPlay} size="48" />
{$t('start').toUpperCase()}
</JobTileButton>
{/if}
</div>

View File

@@ -43,7 +43,7 @@
if (dto.force) {
const isConfirmed = await dialogController.show({
id: 'confirm-reprocess-all-faces',
prompt: 'Are you sure you want to reprocess all faces? This will also clear named people.',
prompt: $t('admin.confirm_reprocess_all_faces'),
});
if (isConfirmed) {
@@ -60,23 +60,23 @@
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
[JobName.ThumbnailGeneration]: {
icon: mdiFileJpgBox,
title: getJobName(JobName.ThumbnailGeneration),
title: $getJobName(JobName.ThumbnailGeneration),
subtitle: $t('admin.thumbnail_generation_job_description'),
},
[JobName.MetadataExtraction]: {
icon: mdiTable,
title: getJobName(JobName.MetadataExtraction),
title: $getJobName(JobName.MetadataExtraction),
subtitle: $t('admin.metadata_extraction_job_description'),
},
[JobName.Library]: {
icon: mdiLibraryShelves,
title: getJobName(JobName.Library),
title: $getJobName(JobName.Library),
subtitle: $t('admin.library_tasks_description'),
allText: $t('all').toUpperCase(),
missingText: $t('refresh').toUpperCase(),
},
[JobName.Sidecar]: {
title: getJobName(JobName.Sidecar),
title: $getJobName(JobName.Sidecar),
icon: mdiFileXmlBox,
subtitle: $t('admin.sidecar_job_description'),
allText: $t('sync').toUpperCase(),
@@ -85,46 +85,44 @@
},
[JobName.SmartSearch]: {
icon: mdiImageSearch,
title: getJobName(JobName.SmartSearch),
title: $getJobName(JobName.SmartSearch),
subtitle: $t('admin.smart_search_job_description'),
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
title: $getJobName(JobName.DuplicateDetection),
subtitle: $t('admin.duplicate_detection_job_description'),
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
icon: mdiFaceRecognition,
title: getJobName(JobName.FaceDetection),
subtitle:
'Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. "All" (re-)processes all assets. "Missing" queues assets that haven\'t been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.',
title: $getJobName(JobName.FaceDetection),
subtitle: $t('admin.face_detection_description'),
handleCommand: handleConfirmCommand,
disabled: !$featureFlags.facialRecognition,
},
[JobName.FacialRecognition]: {
icon: mdiTagFaces,
title: getJobName(JobName.FacialRecognition),
subtitle:
'Group detected faces into people. This step runs after Face Detection is complete. "All" (re-)clusters all faces. "Missing" queues faces that don\'t have a person assigned.',
title: $getJobName(JobName.FacialRecognition),
subtitle: $t('admin.facial_recognition_job_description'),
handleCommand: handleConfirmCommand,
disabled: !$featureFlags.facialRecognition,
},
[JobName.VideoConversion]: {
icon: mdiVideo,
title: getJobName(JobName.VideoConversion),
title: $getJobName(JobName.VideoConversion),
subtitle: $t('admin.video_conversion_job_description'),
},
[JobName.StorageTemplateMigration]: {
icon: mdiFolderMove,
title: getJobName(JobName.StorageTemplateMigration),
title: $getJobName(JobName.StorageTemplateMigration),
allowForceCommand: false,
description: StorageMigrationDescription,
},
[JobName.Migration]: {
icon: mdiFolderMove,
title: getJobName(JobName.Migration),
title: $getJobName(JobName.Migration),
subtitle: $t('admin.migration_job_description'),
allowForceCommand: false,
},
@@ -140,14 +138,14 @@
switch (jobCommand.command) {
case JobCommand.Empty: {
notificationController.show({
message: `Cleared jobs for: ${title}`,
message: $t('admin.cleared_jobs', { values: { job: title } }),
type: NotificationType.Info,
});
break;
}
}
} catch (error) {
handleError(error, `Command '${jobCommand.command}' failed for job: ${title}`);
handleError(error, $t('admin.failed_job_command', { values: { command: jobCommand.command, job: title } }));
}
}
</script>

View File

@@ -110,7 +110,7 @@
{#if user.quotaSizeInBytes}
({((user.usage / user.quotaSizeInBytes) * 100).toFixed(0)}%)
{:else}
(Unlimited)
({$t('unlimited')})
{/if}
</span>
</td>

View File

@@ -53,7 +53,7 @@
}
notificationController.show({
message: 'Reset settings to the recent saved settings',
message: $t('admin.reset_settings_to_recent_saved'),
type: NotificationType.Info,
});
};
@@ -64,7 +64,7 @@
}
notificationController.show({
message: $t('reset_settings_to_default'),
message: $t('admin.reset_settings_to_default'),
type: NotificationType.Info,
});
};

View File

@@ -270,7 +270,7 @@
},
{
value: TranscodeHWAccel.Disabled,
text: $t('admin.disabled'),
text: $t('disabled'),
},
]}
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}

View File

@@ -9,6 +9,7 @@
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -45,7 +46,7 @@
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
{disabled}
label="{getJobName(jobName)} Concurrency"
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
desc=""
bind:value={config.job[jobName].concurrency}
required={true}
@@ -54,11 +55,11 @@
{:else}
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="{getJobName(jobName)} Concurrency"
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
desc=""
value="1"
disabled={true}
title="This job is not concurrency-safe."
title={$t('admin.job_not_concurrency_safe')}
/>
{/if}
</div>

View File

@@ -84,7 +84,9 @@
{#if $featureFlags.email}
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="send-welcome-email"> Send welcome email </label>
<label class="text-sm dark:text-immich-dark-fg" for="send-welcome-email">
{$t('admin.send_welcome_email')}
</label>
<Slider id="send-welcome-email" bind:checked={notify} />
</div>
{/if}
@@ -101,7 +103,7 @@
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="require-password-change">
Require user to change password on first login
{$t('admin.require_password_change_on_login')}
</label>
<Slider id="require-password-change" bind:checked={shouldChangePassword} />
</div>
@@ -113,9 +115,9 @@
<div class="my-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
Quota Size (GiB)
{$t('admin.quota_size_gib')}
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
<p class="text-red-400 text-sm">{$t('admin.quota_higher_than_disk_size')}</p>
{/if}
</label>
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />

View File

@@ -55,7 +55,7 @@
const resetPassword = async () => {
const isConfirmed = await dialogController.show({
id: 'confirm-reset-password',
prompt: `Are you sure you want to reset ${user.name}'s password?`,
prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }),
});
if (!isConfirmed) {
@@ -110,13 +110,14 @@
</div>
<div class="my-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
>Quota Size (GiB) {#if quotaSizeWarning}
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
{$t('admin.quota_size_gib')}
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
{/if}</label
>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
<p>Note: Enter 0 for unlimited quota</p>
<p>{$t('admin.note_unlimited_quota')}</p>
</div>
<div class="my-4 flex flex-col gap-2">
@@ -130,10 +131,10 @@
/>
<p>
Note: To apply the Storage Label to previously uploaded assets, run the
{$t('admin.note_apply_storage_label_previous_assets')}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
Storage Migration Job</a
>
{$t('admin.storage_template_migration_job')}
</a>
</p>
</div>

View File

@@ -32,11 +32,9 @@
<FullScreenModal title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} onClose={handleCancel}>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form">
<p class="py-5 text-sm">
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
folders that contain files you don't want to import, such as RAW files.
{$t('admin.exclusion_pattern_description')}
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore/**".
{$t('admin.add_exclusion_pattern_description')}
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
@@ -50,7 +48,7 @@
</div>
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This exclusion pattern already exists.</p>
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
{/if}
</div>
</form>

View File

@@ -33,9 +33,7 @@
<FullScreenModal {title} icon={mdiFolderSync} onClose={handleCancel}>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="library-import-path-form">
<p class="py-5 text-sm">
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.
</p>
<p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="path">{$t('path')}</label>
@@ -44,7 +42,7 @@
<div class="mt-8 flex w-full gap-4">
{#if isDuplicate}
<p class="text-red-500 text-sm">This import path already exists.</p>
<p class="text-red-500 text-sm">{$t('admin.import_path_already_exists')}</p>
{/if}
</div>
</form>

View File

@@ -9,7 +9,6 @@
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
import { t } from 'svelte-i18n';
export let library: LibraryResponseDto;
@@ -54,13 +53,13 @@
if (failedPaths === 0) {
if (notifyIfSuccessful) {
notificationController.show({
message: `All paths validated successfully`,
message: $t('admin.paths_validated_successfully'),
type: NotificationType.Info,
});
}
} else {
notificationController.show({
message: `${failedPaths} path${s(failedPaths)} failed validation`,
message: $t('errors.paths_validation_failed', { values: { paths: failedPaths } }),
type: NotificationType.Warning,
});
}
@@ -95,7 +94,7 @@
await revalidate(false);
}
} catch (error) {
handleError(error, 'Unable to add import path');
handleError(error, $t('errors.unable_to_add_import_path'));
} finally {
addImportPath = false;
importPathToAdd = null;
@@ -121,7 +120,7 @@
}
} catch (error) {
editImportPath = null;
handleError(error, 'Unable to edit import path');
handleError(error, $t('errors.unable_to_edit_import_path'));
} finally {
editImportPath = null;
}
@@ -141,7 +140,7 @@
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
await handleValidation();
} catch (error) {
handleError(error, 'Unable to delete import path');
handleError(error, $t('errors.unable_to_delete_import_path'));
} finally {
editImportPath = null;
}
@@ -230,7 +229,7 @@
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}
No paths added
{$t('admin.no_paths_added')}
{/if}</td
>
<td class="w-1/5 text-ellipsis px-4 text-sm"

View File

@@ -54,7 +54,7 @@
exclusionPatterns = library.exclusionPatterns;
}
} catch (error) {
handleError(error, 'Unable to add exclusion pattern');
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
} finally {
exclusionPatternToAdd = '';
addExclusionPattern = false;
@@ -74,7 +74,7 @@
library.exclusionPatterns[editExclusionPattern] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, 'Unable to edit exclude pattern');
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
} finally {
editExclusionPattern = null;
}
@@ -94,7 +94,7 @@
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != pathToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, 'Unable to delete exclude pattern');
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
} finally {
editExclusionPattern = null;
}
@@ -162,7 +162,7 @@
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
No pattern added
{$t('admin.no_pattern_added')}
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm"

View File

@@ -30,7 +30,7 @@
<FullScreenModal title={$t('select_library_owner')} icon={mdiFolderSync} onClose={handleCancel}>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off" id="select-library-owner-form">
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
</form>

View File

@@ -17,6 +17,7 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { t, init } from 'svelte-i18n';
import { invalidateAll } from '$app/navigation';
let time = new Date();
@@ -77,6 +78,7 @@
}
await init({ fallbackLocale: defaultLang.code, initialLocale: newLang });
await invalidateAll();
}
};
@@ -143,7 +145,7 @@
</div>
<div class="ml-4">
<SettingSwitch
title="Play video thumbnail on hover"
title={$t('video_hover_setting')}
subtitle={$t('video_hover_setting_description')}
bind:checked={$playVideoThumbnailOnHover}
on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)}

View File

@@ -32,7 +32,7 @@
} catch (error) {
console.error('Error [user-profile] [changePassword]', error);
notificationController.show({
message: (error as HttpError)?.body?.message || 'Unable to change password',
message: (error as HttpError)?.body?.message || $t('errors.unable_to_change_password'),
type: NotificationType.Error,
});
}

View File

@@ -68,7 +68,7 @@
</div>
<div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSave()}>Save</Button>
<Button type="submit" size="sm" on:click={() => handleSave()}>{$t('save')}</Button>
</div>
</div>
</form>

View File

@@ -27,7 +27,7 @@
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to link OAuth account');
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
await goto('?open=oauth');
}

View File

@@ -79,8 +79,8 @@
const handleRemovePartner = async (partner: PartnerResponseDto) => {
const isConfirmed = await dialogController.show({
id: 'remove-partner',
title: 'Stop sharing your photos?',
prompt: `${partner.name} will no longer be able to access your photos.`,
title: $t('stop_photo_sharing'),
prompt: $t('stop_photo_sharing_description', { values: { partner: partner.name } }),
});
if (!isConfirmed) {
@@ -115,7 +115,7 @@
partner.inTimeline = inTimeline;
partners = partners;
} catch (error) {
handleError(error, 'Unable to update timeline display status');
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}
};
</script>
@@ -142,7 +142,7 @@
on:click={() => handleRemovePartner(partner.user)}
icon={mdiClose}
size={'16'}
title="Stop sharing your photos with this user"
title={$t('stop_sharing_photos_with_user')}
/>
{/if}
</div>
@@ -151,14 +151,18 @@
<!-- I am sharing my assets with this user -->
{#if partner.sharedByMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<p class="text-xs font-medium my-4">SHARED WITH {partner.user.name.toUpperCase()}</p>
<p class="text-md">{partner.user.name} can access</p>
<p class="text-xs font-medium my-4">
{$t('shared_with_partner', { values: { partner: partner.user.name } }).toUpperCase()}
</p>
<p class="text-md">{$t('partner_can_access', { values: { partner: partner.user.name } })}</p>
<ul class="text-sm">
<li class="flex gap-2 place-items-center py-1 mt-2">
<Icon path={mdiCheck} /> All your photos and videos except those in Archived and Deleted
<Icon path={mdiCheck} />
{$t('partner_can_access_assets')}
</li>
<li class="flex gap-2 place-items-center py-1">
<Icon path={mdiCheck} /> The location where your photos were taken
<Icon path={mdiCheck} />
{$t('partner_can_access_location')}
</li>
</ul>
{/if}
@@ -166,7 +170,9 @@
<!-- this user is sharing assets with me -->
{#if partner.sharedWithMe}
<hr class="my-4 border border-gray-200 dark:border-gray-700" />
<p class="text-xs font-medium my-4">PHOTOS FROM {partner.user.name.toUpperCase()}</p>
<p class="text-xs font-medium my-4">
{$t('shared_from_partner', { values: { partner: partner.user.name } }).toUpperCase()}
</p>
<SettingSwitch
title={$t('show_in_timeline')}
subtitle={$t('show_in_timeline_setting_description')}

View File

@@ -33,7 +33,7 @@
const data = await createApiKey({ apiKeyCreateDto: detail });
secret = data.secret;
} catch (error) {
handleError(error, 'Unable to create a new API Key');
handleError(error, $t('errors.unable_to_create_api_key'));
} finally {
await refreshKeys();
newKey = null;
@@ -48,11 +48,11 @@
try {
await updateApiKey({ id: editKey.id, apiKeyUpdateDto: { name: detail.name } });
notificationController.show({
message: `Saved API Key`,
message: $t('saved_api_key'),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save API Key');
handleError(error, $t('errors.unable_to_save_api_key'));
} finally {
await refreshKeys();
editKey = null;
@@ -62,7 +62,7 @@
const handleDelete = async (key: ApiKeyResponseDto) => {
const isConfirmed = await dialogController.show({
id: 'delete-api-key',
prompt: 'Are you sure you want to delete this API key?',
prompt: $t('delete_api_key_prompt'),
});
if (!isConfirmed) {
@@ -72,11 +72,11 @@
try {
await deleteApiKey({ id: key.id });
notificationController.show({
message: `Removed API Key: ${key.name}`,
message: $t('removed_api_key', { values: { name: key.name } }),
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove API Key');
handleError(error, $t('errors.unable_to_remove_api_key'));
} finally {
await refreshKeys();
}