feat(translations): add French language support and improve localization handling

This commit is contained in:
PiR1
2026-05-09 18:23:11 +02:00
parent fd98a78123
commit a5db4bd46e
12 changed files with 2352 additions and 126 deletions
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } from 'lucide-react'; import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts'; import { useAdminAuth } from '../../contexts';
@@ -25,8 +24,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const { user, logout } = useAdminAuth(); const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode(); const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format, formatDistanceToNow } = useLocalizedDate();
const { formatTimeAgo } = useLocalizedTimeAgo();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -190,7 +188,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{notificationsService.formatNotificationMessage(notification)} {notificationsService.formatNotificationMessage(notification)}
</p> </p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{formatTimeAgo(notification.createdAt)} {formatDistanceToNow(notification.createdAt, { addSuffix: true })}
</p> </p>
</div> </div>
</div> </div>
@@ -106,7 +106,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.dashboard.health.title')}</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.dashboard.health.title')}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 dark:bg-${healthColors[health.status]}-900/40 text-${healthColors[health.status]}-700 dark:text-${healthColors[health.status]}-300`}> <span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 dark:bg-${healthColors[health.status]}-900/40 text-${healthColors[health.status]}-700 dark:text-${healthColors[health.status]}-300`}>
{health.status.charAt(0).toUpperCase() + health.status.slice(1)} {t(`backup.dashboard.healthStatus.${health.status}`)}
</span> </span>
</div> </div>
@@ -46,19 +46,28 @@ const NLFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg> </svg>
); );
const languages = [ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#002395" d="M0 0h213.3v480H0z"/>
<path fill="#fff" d="M213.3 0h213.4v480H213.3z"/>
<path fill="#ED2939" d="M426.7 0H640v480H426.7z"/>
</svg>
);
export const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', Flag: GBFlag }, { code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag }, { code: 'de', name: 'Deutsch', Flag: DEFlag },
{ code: 'ru', name: 'Русский', Flag: RUFlag }, { code: 'ru', name: 'Русский', Flag: RUFlag },
{ code: 'pt', name: 'Português', Flag: PTBRFlag }, { code: 'pt', name: 'Português', Flag: PTBRFlag },
{ code: 'nl', name: 'Nederlands', Flag: NLFlag }, { code: 'nl', name: 'Nederlands', Flag: NLFlag },
{ code: 'fr', name: 'Français', Flag: FRFlag },
]; ];
export const LanguageSelector: React.FC = () => { export const LanguageSelector: React.FC = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0]; const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
const handleLanguageChange = (languageCode: string) => { const handleLanguageChange = (languageCode: string) => {
i18n.changeLanguage(languageCode); i18n.changeLanguage(languageCode);
@@ -78,7 +87,7 @@ export const LanguageSelector: React.FC = () => {
{isOpen && ( {isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 z-50"> <div className="absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 z-50">
{languages.map((language) => ( {SUPPORTED_LANGUAGES.map((language) => (
<button <button
key={language.code} key={language.code}
onClick={() => handleLanguageChange(language.code)} onClick={() => handleLanguageChange(language.code)}
@@ -81,12 +81,13 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
onClick={() => setShowSortMenu(!showSortMenu)} onClick={() => setShowSortMenu(!showSortMenu)}
className="w-full md:w-auto text-sm md:text-base" className="w-full md:w-auto text-sm md:text-base"
> >
<span className="hidden md:inline">{t('common.sortBy')} </span> <span className="hidden md:inline">{t('common.sortBy')}{" "}
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : {sortBy === 'date' ? t('gallery.sortByDate').replace(t('common.sortBy'), '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace(t('common.sortBy'), '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') : sortBy === 'size' ? t('gallery.sortBySize').replace(t('common.sortBy'), '') :
sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') : sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') :
t('gallery.sortByRating', 'Rating')} t('gallery.sortByRating', 'Rating')}
</span>
</Button> </Button>
{showSortMenu && ( {showSortMenu && (
@@ -4,6 +4,7 @@ import { Button, Card, Input, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { GeneralSettings } from '../hooks/useSettingsState'; import type { GeneralSettings } from '../hooks/useSettingsState';
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState'; import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
interface GeneralTabProps { interface GeneralTabProps {
generalSettings: GeneralSettings; generalSettings: GeneralSettings;
@@ -244,11 +245,9 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))} onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
> >
<option value="en">English</option> {SUPPORTED_LANGUAGES.map(lang => (
<option value="de">Deutsch</option> <option key={lang.code} value={lang.code}>{lang.name}</option>
<option value="nl">Nederlands</option> ))}
<option value="pt">Português (Brasil)</option>
<option value="ru">Русский</option>
</select> </select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')} {t('settings.general.defaultLanguageHelp')}
-1
View File
@@ -1,6 +1,5 @@
export * from './useSessionTimeout'; export * from './useSessionTimeout';
export * from './useOnClickOutside'; export * from './useOnClickOutside';
export * from './useLocalizedDate'; export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
export * from './usePermission'; export * from './usePermission';
export * from './usePublicSettings'; export * from './usePublicSettings';
+11 -3
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns'; import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS, ptBR } from 'date-fns/locale'; import { de, enUS, ptBR, fr } from 'date-fns/locale';
import { usePublicSettings } from './usePublicSettings'; import { usePublicSettings } from './usePublicSettings';
// Convert old date format strings to new date-fns format // Convert old date format strings to new date-fns format
@@ -17,9 +17,17 @@ export const useLocalizedDate = () => {
const { data: settings } = usePublicSettings(); const { data: settings } = usePublicSettings();
const getLocale = () => { const getLocale = () => {
if (i18n.language === 'de') return de; switch (i18n.language) {
if (i18n.language === 'pt' || i18n.language === 'pt-BR') return ptBR; case 'de':
return de;
case 'pt':
case 'pt-BR':
return ptBR;
case 'fr':
return fr;
default:
return enUS; return enUS;
}
}; };
const format = (date: Date | string, formatStr?: string) => { const format = (date: Date | string, formatStr?: string) => {
-72
View File
@@ -1,72 +0,0 @@
import { useTranslation } from 'react-i18next';
export const useLocalizedTimeAgo = () => {
const { i18n } = useTranslation();
const formatTimeAgo = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
const isGerman = i18n.language === 'de';
// Less than a minute
if (seconds < 60) {
return isGerman ? 'gerade eben' : 'just now';
}
// Minutes
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
if (minutes === 1) {
return isGerman ? 'vor 1 Minute' : '1 minute ago';
}
return isGerman ? `vor ${minutes} Minuten` : `${minutes} minutes ago`;
}
// Hours
const hours = Math.floor(minutes / 60);
if (hours < 24) {
if (hours === 1) {
return isGerman ? 'vor 1 Stunde' : '1 hour ago';
}
return isGerman ? `vor ${hours} Stunden` : `${hours} hours ago`;
}
// Days
const days = Math.floor(hours / 24);
if (days < 7) {
if (days === 1) {
return isGerman ? 'vor 1 Tag' : '1 day ago';
}
return isGerman ? `vor ${days} Tagen` : `${days} days ago`;
}
// Weeks
const weeks = Math.floor(days / 7);
if (weeks < 4) {
if (weeks === 1) {
return isGerman ? 'vor 1 Woche' : '1 week ago';
}
return isGerman ? `vor ${weeks} Wochen` : `${weeks} weeks ago`;
}
// Months
const months = Math.floor(days / 30);
if (months < 12) {
if (months === 1) {
return isGerman ? 'vor 1 Monat' : '1 month ago';
}
return isGerman ? `vor ${months} Monaten` : `${months} months ago`;
}
// Years
const years = Math.floor(days / 365);
if (years === 1) {
return isGerman ? 'vor 1 Jahr' : '1 year ago';
}
return isGerman ? `vor ${years} Jahren` : `${years} years ago`;
};
return { formatTimeAgo };
};
+9 -22
View File
@@ -3,11 +3,14 @@ import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend'; import HttpBackend from 'i18next-http-backend';
import enTranslations from './locales/en.json'; const localeFiles = import.meta.glob<Record<string, string>>('./locales/*.json', { eager: true, import: 'default' });
import deTranslations from './locales/de.json';
import ruTranslations from './locales/ru.json'; const resources = Object.fromEntries(
import ptTranslations from './locales/pt.json'; Object.entries(localeFiles).map(([path, translations]) => {
import nlTranslations from './locales/nl.json'; const lang = path.match(/\/(\w+)\.json$/)?.[1];
return [lang, { translation: translations }];
})
);
i18n i18n
.use(HttpBackend) .use(HttpBackend)
@@ -17,23 +20,7 @@ i18n
fallbackLng: 'en', fallbackLng: 'en',
debug: false, debug: false,
resources: { resources,
en: {
translation: enTranslations,
},
de: {
translation: deTranslations,
},
ru: {
translation: ruTranslations,
},
pt: {
translation: ptTranslations,
},
nl: {
translation: nlTranslations,
},
},
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -23,14 +23,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service'; import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'nl', name: 'Nederlands', flag: '🇳🇱' },
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
];
const defaultTemplateKeys = [ const defaultTemplateKeys = [
{ {
@@ -795,7 +788,7 @@ export const EmailConfigPage: React.FC = () => {
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200' : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200'
}`} }`}
> >
<span>{lang.flag}</span> <lang.Flag/>
<span>{lang.name}</span> <span>{lang.name}</span>
{!hasContent && lang.code !== 'en' && ( {!hasContent && lang.code !== 'en' && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" title={t('email.noTranslation')} /> <span className="w-1.5 h-1.5 rounded-full bg-amber-400" title={t('email.noTranslation')} />
@@ -17,11 +17,12 @@ import {
CheckCircle, CheckCircle,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, formatDistanceToNow, isPast } from 'date-fns'; import { parseISO, isPast } from 'date-fns';
import { Button, Input, Card, Loading } from '../../components/common'; import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service'; import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types'; import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
import { useLocalizedDate } from "../../hooks";
type TabType = 'users' | 'invitations'; type TabType = 'users' | 'invitations';
@@ -368,6 +369,7 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
export const UserManagementPage: React.FC = () => { export const UserManagementPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { formatDistanceToNow } = useLocalizedDate()
// State // State
const [activeTab, setActiveTab] = useState<TabType>('users'); const [activeTab, setActiveTab] = useState<TabType>('users');