From da78e20e4dc0db1040e9c63ddd7602f79a806ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ricardo=20Louren=C3=A7o?= Date: Sat, 19 Sep 2026 06:50:22 +0100 Subject: [PATCH 1/5] Add English and Portuguese to the UI The interface can now run in English, French or Portuguese. Language starts from the browser, and there is a sidebar picker if you want to change it. French stays as it is today. Missing translations fall back to French. The choice is saved only after you pick one. Co-authored-by: Cursor --- .gitignore | 5 +- frontend/package-lock.json | 96 ++- frontend/package.json | 3 + frontend/src/components/layout/Sidebar.tsx | 32 +- .../components/progress/LiveProgressPanel.tsx | 8 +- .../remotes/GenericConfigFields.tsx | 12 +- .../src/components/remotes/OAuthButton.tsx | 13 +- .../remotes/StandardRemoteFields.tsx | 33 +- .../src/components/tasks/EncryptionFields.tsx | 18 +- frontend/src/components/tasks/KeygenModal.tsx | 28 +- .../src/components/tasks/RestoreModal.tsx | 37 +- .../src/components/tasks/RetryPreview.tsx | 26 + frontend/src/components/ui/ConfirmDialog.tsx | 8 +- frontend/src/components/ui/CronPreview.tsx | 30 +- frontend/src/components/ui/StatusBadge.tsx | 20 +- frontend/src/config/remoteFieldSchemas.ts | 11 +- frontend/src/i18n/LanguagePicker.tsx | 68 ++ frontend/src/i18n/LanguageSwitcher.tsx | 24 + frontend/src/i18n/format.ts | 38 ++ frontend/src/i18n/index.ts | 87 +++ frontend/src/i18n/locales/en.json | 614 ++++++++++++++++++ frontend/src/i18n/locales/fr.json | 614 ++++++++++++++++++ frontend/src/i18n/locales/pt.json | 614 ++++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/RemoteFormPage.tsx | 51 +- frontend/src/pages/RemotesPage.tsx | 40 +- frontend/src/pages/TaskDetailPage.tsx | 109 ++-- frontend/src/pages/TaskEditPage.tsx | 69 +- frontend/src/pages/TaskFormPage.tsx | 107 ++- frontend/src/pages/TasksPage.tsx | 73 +-- frontend/tsconfig.app.json | 1 + 31 files changed, 2546 insertions(+), 344 deletions(-) create mode 100644 frontend/src/components/tasks/RetryPreview.tsx create mode 100644 frontend/src/i18n/LanguagePicker.tsx create mode 100644 frontend/src/i18n/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/format.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/fr.json create mode 100644 frontend/src/i18n/locales/pt.json diff --git a/.gitignore b/.gitignore index 6b7adc8..0698a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ frontend/dist/ .DS_Store tmp/ -.claude \ No newline at end of file +.claude + +# Local screenshot mocks — never commit +/*.png \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f4f29e0..6879a4f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,10 +17,13 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "cronstrue": "^3.14.0", + "i18next": "^26.4.2", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.72.1", + "react-i18next": "^17.0.14", "react-router-dom": "^7.14.1", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" @@ -268,6 +271,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2722,6 +2734,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2735,6 +2756,43 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3518,6 +3576,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.14", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz", + "integrity": "sha512-ZpMBfJL3BiXPuYHj5QMY1GwvKJNhE1vCxOJQ8BoMBdHP5XONTfi/Qss1nuRlnFdIl72PKo2jPmw+fxuLjRFgaQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.14.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", @@ -3770,7 +3855,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3845,6 +3930,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3e0dc42..27f4b51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,10 +19,13 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "cronstrue": "^3.14.0", + "i18next": "^26.4.2", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.72.1", + "react-i18next": "^17.0.14", "react-router-dom": "^7.14.1", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9e012b1..0ae33cd 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,32 +1,33 @@ import { NavLink } from 'react-router-dom'; import { Server, RefreshCw, Bell } from 'lucide-react'; - -const links = [ - { to: '/tasks', label: 'Tâches', desc: 'Gérer les réplications', icon: RefreshCw }, - { to: '/remotes', label: 'Stockages', desc: 'Sources et destinations', icon: Server }, - { to: '/notifications', label: 'Notifications', desc: 'Alertes et canaux', icon: Bell }, -]; +import { useTranslation } from 'react-i18next'; +import { LanguageSwitcher } from '../../i18n/LanguageSwitcher'; export function Sidebar() { + const { t } = useTranslation(); + const links = [ + { to: '/tasks', label: t('nav.tasks'), desc: t('nav.tasksDesc'), icon: RefreshCw }, + { to: '/remotes', label: t('nav.remotes'), desc: t('nav.remotesDesc'), icon: Server }, + { to: '/notifications', label: t('nav.notifications'), desc: t('nav.notificationsDesc'), icon: Bell }, + ]; + return ( diff --git a/frontend/src/components/progress/LiveProgressPanel.tsx b/frontend/src/components/progress/LiveProgressPanel.tsx index 0c6168c..dc5519e 100644 --- a/frontend/src/components/progress/LiveProgressPanel.tsx +++ b/frontend/src/components/progress/LiveProgressPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import { Terminal, CheckCircle2, XCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { lines: string[]; @@ -8,6 +9,7 @@ interface Props { } export function LiveProgressPanel({ lines, done, status }: Props) { + const { t } = useTranslation(); const bottomRef = useRef(null); useEffect(() => { @@ -18,17 +20,17 @@ export function LiveProgressPanel({ lines, done, status }: Props) {
- Progression en temps réel + {t('progress.title')} {done && status && ( - {status === 'success' ? <> Terminé : <> Échec} + {status === 'success' ? <> {t('progress.finished')} : <> {t('progress.failed')}} )}
{lines.length === 0 && !done && ( -

En attente des logs...

+

{t('progress.waiting')}

)} {lines.map((line, i) => (
{line}
diff --git a/frontend/src/components/remotes/GenericConfigFields.tsx b/frontend/src/components/remotes/GenericConfigFields.tsx index b719ccf..115c2ef 100644 --- a/frontend/src/components/remotes/GenericConfigFields.tsx +++ b/frontend/src/components/remotes/GenericConfigFields.tsx @@ -1,5 +1,6 @@ import { Control, UseFormRegister, useFieldArray } from 'react-hook-form'; import { Plus, Trash2, Info } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -9,28 +10,29 @@ interface Props { } export function GenericConfigFields({ control, register }: Props) { + const { t } = useTranslation(); const { fields, append, remove } = useFieldArray({ control, name: 'configEntries' }); return (
- Configuration avancée + {t('remotes.generic.legend')}

- Entrez les paires clé/valeur correspondant aux options de configuration rclone pour ce type de stockage. + {t('remotes.generic.help')}

{fields.map((field, index) => (
); diff --git a/frontend/src/components/remotes/OAuthButton.tsx b/frontend/src/components/remotes/OAuthButton.tsx index b24281b..febb0da 100644 --- a/frontend/src/components/remotes/OAuthButton.tsx +++ b/frontend/src/components/remotes/OAuthButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { KeyRound, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; interface Props { /** Type rclone : 'dropbox', 'drive', 'onedrive' */ @@ -16,13 +17,14 @@ interface Props { * via window.postMessage envoyé par la page de callback du backend. */ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess }: Props) { + const { t } = useTranslation(); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const handleClick = () => { setError(null); if (!clientId || !clientSecret) { - setError('Renseignez d\'abord le Client ID et le Client Secret avant de connecter.'); + setError(t('remotes.oauth.needCredentials')); return; } @@ -35,7 +37,7 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess const popup = window.open(url, 'rclone-ui-oauth', 'width=600,height=720,scrollbars=yes'); if (!popup) { - setError('Impossible d\'ouvrir la fenêtre OAuth. Autorisez les popups pour ce site.'); + setError(t('remotes.oauth.popupBlocked')); return; } @@ -49,12 +51,11 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess if (e.data.success) { onSuccess(JSON.stringify(e.data.token)); } else { - setError(e.data.error || 'Échec du flow OAuth.'); + setError(e.data.error || t('remotes.oauth.failed')); } }; window.addEventListener('message', onMessage); - // Détecte la fermeture manuelle de la popup const closedCheck = window.setInterval(() => { if (popup.closed) { window.removeEventListener('message', onMessage); @@ -74,10 +75,10 @@ export function OAuthButton({ provider, label, clientId, clientSecret, onSuccess className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors" > {pending ? : } - Connecter via OAuth ({label}) + {t('remotes.oauth.connect', { provider: label })}

- Auth interactive — remplit le champ « Token » automatiquement. + {t('remotes.oauth.hint')}

{error &&

{error}

} diff --git a/frontend/src/components/remotes/StandardRemoteFields.tsx b/frontend/src/components/remotes/StandardRemoteFields.tsx index 569d9d7..a041230 100644 --- a/frontend/src/components/remotes/StandardRemoteFields.tsx +++ b/frontend/src/components/remotes/StandardRemoteFields.tsx @@ -1,4 +1,5 @@ import { UseFormRegister, UseFormWatch, UseFormSetValue, FieldErrors } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import type { RemoteFieldDef } from '../../config/remoteFieldSchemas'; import { OAuthButton } from './OAuthButton'; @@ -20,6 +21,11 @@ interface Props { const inputCls = "w-full border border-surface-300 rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-shadow"; +function translatedLabel(exists: boolean, value: unknown, fallback: string): string { + if (!exists || typeof value !== 'string' || value.length === 0) return fallback; + return value; +} + /** Mapping des types rclone supportant le flow OAuth intégré. */ const OAUTH_PROVIDERS: Record = { dropbox: 'Dropbox', @@ -28,6 +34,7 @@ const OAUTH_PROVIDERS: Record = { }; export function StandardRemoteFields({ fields, remoteType, register, watch, setValue, errors, isEdit }: Props) { + const { t, i18n } = useTranslation(); const oauthLabel = OAUTH_PROVIDERS[remoteType]; const clientId = (watch('config.client_id') ?? '') as string; const clientSecret = (watch('config.client_secret') ?? '') as string; @@ -35,7 +42,7 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV return (
- Paramètres de connexion + {t('remotes.form.connectionParams')} {oauthLabel && ( @@ -45,8 +52,6 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV clientId={clientId} clientSecret={clientSecret} onSuccess={(tokenJson) => { - // Le token rclone est stocké dans `config.token` (Dropbox/OneDrive) - // ou peut servir pour Drive si l'on passe en mode OAuth (rare en pratique). setValue('config.token', tokenJson, { shouldDirty: true, shouldValidate: true }); }} /> @@ -55,19 +60,31 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV {fields.map((field) => { const fieldError = (errors.config as Record | undefined)?.[field.key]; const isPassword = field.type === 'password'; - const editPlaceholder = isPassword && isEdit ? '•••••• (laisser vide pour conserver)' : field.placeholder; + const labelKey = `remotes.types.${remoteType}.fields.${field.key}.label`; + const helpKey = `remotes.types.${remoteType}.fields.${field.key}.help`; + const placeholderKey = `remotes.types.${remoteType}.fields.${field.key}.placeholder`; + const label = translatedLabel(i18n.exists(labelKey), t(labelKey), field.label); + const help = translatedLabel(i18n.exists(helpKey), t(helpKey), field.helpText ?? ''); + const placeholder = translatedLabel(i18n.exists(placeholderKey), t(placeholderKey), field.placeholder ?? ''); + const editPlaceholder = isPassword && isEdit ? t('remotes.form.keepSecret') : placeholder || undefined; return (
{field.type === 'select' ? ( ) : ( @@ -81,10 +98,10 @@ export function StandardRemoteFields({ fields, remoteType, register, watch, setV /> )} - {field.helpText &&

{field.helpText}

} + {help &&

{help}

} {isEdit && isPassword && (

- Stocké de manière sécurisée. Laissez vide pour conserver la valeur actuelle. + {t('remotes.form.keepSecretHelp')}

)} {fieldError?.message &&

{fieldError.message}

} diff --git a/frontend/src/components/tasks/EncryptionFields.tsx b/frontend/src/components/tasks/EncryptionFields.tsx index f252c9e..761c66a 100644 --- a/frontend/src/components/tasks/EncryptionFields.tsx +++ b/frontend/src/components/tasks/EncryptionFields.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Lock, KeyRound, Info } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { KeygenModal } from './KeygenModal'; interface Props { @@ -15,6 +16,7 @@ interface Props { * et saisir / générer la clé publique age. */ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublicKeyChange, error }: Props) { + const { t } = useTranslation(); const [keygenOpen, setKeygenOpen] = useState(false); const inputCls = 'w-full border border-surface-300 rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-shadow'; @@ -22,7 +24,7 @@ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublic return (
- Chiffrement de la destination + {t('encryption.legend')} @@ -44,7 +45,7 @@ export function EncryptionFields({ enabled, publicKey, onEnabledChange, onPublic {enabled && (