feat(translations): add French language support and improve localization handling
This commit is contained in:
@@ -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,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';
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user