feat(i18n): Hebrew locale + full RTL support

This commit is contained in:
Shuki Vaknin
2026-07-08 00:07:34 +02:00
committed by Linus Rath
parent 8904d724bb
commit 22418c17cf
113 changed files with 3958 additions and 494 deletions
+2 -2
View File
@@ -36,7 +36,7 @@ function VersionUpdateTag() {
return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
"ms-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
important
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
@@ -121,7 +121,7 @@ export function AboutDataSettings() {
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-start focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
@@ -69,7 +69,7 @@ function PasswordChangeSection() {
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
className="pe-10"
/>
<button
type="button"
@@ -90,7 +90,7 @@ function PasswordChangeSection() {
required
minLength={8}
autoComplete="new-password"
className="pr-10"
className="pe-10"
/>
<button
type="button"
@@ -120,7 +120,7 @@ function PasswordChangeSection() {
size="sm"
disabled={isSaving || !currentPassword || !newPassword || !confirmPassword}
>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-2 animate-spin" /> : null}
{t('password.submit')}
</Button>
</form>
@@ -294,7 +294,7 @@ function TotpSection() {
</SettingItem>
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<div className="ms-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
@@ -315,7 +315,7 @@ function TotpSection() {
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
@@ -324,7 +324,7 @@ function TotpSection() {
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<div className="ms-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
@@ -336,7 +336,7 @@ function TotpSection() {
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
@@ -470,7 +470,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t('app_passwords.add')}
</Button>
</div>
@@ -521,7 +521,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
@@ -768,9 +768,9 @@ function LinkDeviceSection() {
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
<Loader2 className="w-3 h-3 me-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
<QrCode className="w-3 h-3 me-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
+3 -3
View File
@@ -223,7 +223,7 @@ export function AccountSettings() {
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-4 h-4 me-2" />
{t('accounts.add')}
</Button>
)}
@@ -245,7 +245,7 @@ export function AccountSettings() {
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-start transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
@@ -359,7 +359,7 @@ function AccountRow({
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-left',
'min-w-0 flex-1 text-start',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
+1 -1
View File
@@ -141,7 +141,7 @@ export function AppearanceSettings() {
onClick={() => { resetTourCompletion(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
<PlayCircle className="w-3.5 h-3.5 me-1" />
{tTour('restart_button')}
</Button>
</SettingItem>
+9 -1
View File
@@ -23,6 +23,7 @@ export function ComposingSettings() {
const {
autoSelectReplyIdentity,
plainTextMode,
rtlEditingSupport,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
@@ -52,6 +53,13 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('rtl_editing.label')} description={t('rtl_editing.description')}>
<ToggleSwitch
checked={rtlEditingSupport}
onChange={(checked) => updateSetting('rtlEditingSupport', checked)}
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
@@ -65,7 +73,7 @@ export function ComposingSettings() {
]}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
<p className="max-w-64 text-end text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
)}
</div>
</SettingItem>
+2 -2
View File
@@ -73,7 +73,7 @@ export function ContactsSettings() {
description={tSettings("import_description")}
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 mr-2" />
<Upload className="w-4 h-4 me-2" />
{t("import.title")}
</Button>
</SettingItem>
@@ -88,7 +88,7 @@ export function ContactsSettings() {
onClick={handleExport}
disabled={individuals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
<Download className="w-4 h-4 me-2" />
{t("export.title")}
</Button>
</SettingItem>
+1 -1
View File
@@ -25,7 +25,7 @@ export function DebugSettings() {
</SettingItem>
{debugMode && (
<div className="ml-4 border-l-2 border-muted pl-4 space-y-1">
<div className="ms-4 border-s-2 border-muted ps-4 space-y-1">
<p className="text-xs text-muted-foreground mb-2">{t('debug_categories.description')}</p>
{ALL_DEBUG_CATEGORIES.map((cat) => (
<SettingItem
+5 -5
View File
@@ -84,8 +84,8 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[10px] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-right">Size</span>
<span className="w-16 text-right">Modified</span>
<span className="w-14 text-end">Size</span>
<span className="w-16 text-end">Modified</span>
</div>
{sortedFiles.map((file) => (
<div
@@ -103,10 +103,10 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
<span className={cn("flex-1 min-w-0 truncate text-[11px]", file.isFolder && "font-medium")}>
{file.name}
</span>
<span className="w-14 text-right text-[10px] text-muted-foreground tabular-nums">
<span className="w-14 text-end text-[10px] text-muted-foreground tabular-nums">
{formatSize(file.size)}
</span>
<span className="w-16 text-right text-[10px] text-muted-foreground tabular-nums">
<span className="w-16 text-end text-[10px] text-muted-foreground tabular-nums">
{file.modified.slice(5)}
</span>
</div>
@@ -142,7 +142,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
);
const sidebar = settings.folderLayout === "sidebar" && (
<div className="w-24 border-r border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="w-24 border-e border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium text-foreground">
<Home className="w-3 h-3 flex-shrink-0" />
<span className="truncate">Files</span>
+3 -3
View File
@@ -487,7 +487,7 @@ export function FilterSettings() {
try { localStorage.setItem('settings-active-tab', 'vacation'); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'vacation' }));
}}
className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-left"
className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-start"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/40">
<PalmtreeIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
@@ -661,7 +661,7 @@ export function FilterSettings() {
setShowRuleModal(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("add_rule")}
</Button>
)}
@@ -670,7 +670,7 @@ export function FilterSettings() {
size="sm"
onClick={() => setShowSieveEditor(true)}
>
<Code className="w-4 h-4 mr-1" />
<Code className="w-4 h-4 me-1" />
{t("raw_editor")}
</Button>
</div>
+1 -1
View File
@@ -57,7 +57,7 @@ export function LanguageSettings() {
{ value: 'full', label: t('date_format.full') },
]}
/>
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
<div className="text-xs text-muted-foreground text-end space-y-0.5 font-mono">
<div>
<span className="opacity-70">{t('date_format.preview_today')} </span>
<span className="text-foreground/90">{preview.today}</span>
+6 -6
View File
@@ -38,11 +38,11 @@ function MailLayoutPreview({
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
<div className="w-11 border-e border-border bg-muted/40" />
{value === 'split' && (
<>
<div className="w-28 border-r border-border bg-background">
<div className="w-28 border-e border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
@@ -245,7 +245,7 @@ export function LayoutSettings() {
)}
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
<SettingItem
label={t('unified_mailbox.include_group.label')}
description={t('unified_mailbox.include_group.description')}
@@ -260,7 +260,7 @@ export function LayoutSettings() {
)}
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
<SettingItem
@@ -293,7 +293,7 @@ export function LayoutSettings() {
)}
{allMailViewAllowed && enableAllMailView && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
@@ -312,7 +312,7 @@ export function LayoutSettings() {
key={mb.id}
type="button"
onClick={() => toggleAllMailFolder(mb.id)}
className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-left"
className="w-full flex items-center gap-2.5 py-1.5 px-2 rounded-md hover:bg-muted/50 text-start"
role="checkbox"
aria-checked={checked}
>
+2 -2
View File
@@ -35,7 +35,7 @@ export function SettingItem({ label, description, children, locked }: SettingIte
data-search-label={label}
className={cn("flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", locked && "opacity-60")}
>
<div className="flex-1 min-w-0 sm:pr-4">
<div className="flex-1 min-w-0 sm:pe-4">
<div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
@@ -72,7 +72,7 @@ export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps)
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150',
checked ? 'translate-x-6' : 'translate-x-1'
checked ? 'ltr:translate-x-6 rtl:-translate-x-6' : 'ltr:translate-x-1 rtl:-translate-x-1'
)}
/>
</button>
@@ -284,7 +284,7 @@ export function ShareCollectionDialog({
value={preset}
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
disabled={savingId === principalId}
className="appearance-none rounded-md border border-input bg-background pl-3 pr-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
>
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
@@ -318,7 +318,7 @@ export function ShareCollectionDialog({
onClick={() => setShowAdd(true)}
className="w-full"
>
<UserPlus className="w-4 h-4 mr-2" />
<UserPlus className="w-4 h-4 me-2" />
{t("add_person")}
</Button>
)}
@@ -336,7 +336,7 @@ export function ShareCollectionDialog({
<div className="max-h-48 overflow-y-auto -mx-1">
{loadingPrincipals && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{t("loading_principals")}
</div>
)}
@@ -350,7 +350,7 @@ export function ShareCollectionDialog({
key={p.id}
onClick={() => handleAdd(p)}
disabled={savingId === p.id}
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<Avatar
@@ -330,7 +330,7 @@ export function SidebarAppsSettings() {
onClick={() => setShowAddForm(true)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-4 h-4 me-2" />
{tApps("add_new")}
</Button>
)}
+1 -1
View File
@@ -304,7 +304,7 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
Close
</Button>
<Button size="sm" onClick={startGame}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
<RotateCcw className="w-3.5 h-3.5 me-1.5" />
Again
</Button>
</div>
+2 -2
View File
@@ -104,7 +104,7 @@ export function TemplateSettings() {
onClick={handleExport}
disabled={templates.length === 0}
>
<Download className="w-4 h-4 mr-1" />
<Download className="w-4 h-4 me-1" />
{t('export')}
</Button>
<div>
@@ -120,7 +120,7 @@ export function TemplateSettings() {
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-1" />
<Upload className="w-4 h-4 me-1" />
{t('import')}
</Button>
</div>
+1 -1
View File
@@ -139,7 +139,7 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
onClick={onActivate}
disabled={disabled}
className={cn(
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-start w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
+1 -1
View File
@@ -258,7 +258,7 @@ export function VacationSettings() {
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Loader2 className="w-4 h-4 me-2 animate-spin" />
{t('saving')}
</>
) : (