Skip to content

Commit decf052

Browse files
pfillion42claude
andcommitted
feat(client): add EN/FR internationalization with language toggle
Add i18n infrastructure (translations.ts, LanguageContext) and migrate all 10 pages + 5 components to use t() translation function. English is the default language, persisted in localStorage. Toggle button in header. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 91e8e35 commit decf052

34 files changed

Lines changed: 988 additions & 288 deletions

client/src/App.tsx

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Logo } from './components/Logo';
1515
import { KeyboardHelp } from './components/KeyboardHelp';
1616
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
1717
import { useTheme } from './hooks/useTheme';
18+
import { LanguageProvider, useLanguage } from './i18n/LanguageContext';
1819

1920
const queryClient = new QueryClient({
2021
defaultOptions: {
@@ -45,6 +46,7 @@ const activeStyle: React.CSSProperties = {
4546
function AppContent() {
4647
const [showHelp, setShowHelp] = useState(false);
4748
const { theme, toggleTheme } = useTheme();
49+
const { language, setLanguage, t } = useLanguage();
4850
useKeyboardShortcuts({ onToggleHelp: () => setShowHelp(v => !v) });
4951

5052
return (
@@ -74,39 +76,69 @@ function AppContent() {
7476

7577
<nav style={{ display: 'flex', gap: '2px', marginLeft: '8px' }}>
7678
<NavLink to="/" end style={({ isActive }) => isActive ? activeStyle : navStyle}>
77-
Dashboard
79+
{t('nav_dashboard')}
7880
</NavLink>
7981
<NavLink to="/timeline" style={({ isActive }) => isActive ? activeStyle : navStyle}>
80-
Timeline
82+
{t('nav_timeline')}
8183
</NavLink>
8284
<NavLink to="/memories" style={({ isActive }) => isActive ? activeStyle : navStyle}>
83-
Memoires
85+
{t('nav_memories')}
8486
</NavLink>
8587
<NavLink to="/duplicates" style={({ isActive }) => isActive ? activeStyle : navStyle}>
86-
Doublons
88+
{t('nav_duplicates')}
8789
</NavLink>
8890
<NavLink to="/tags" style={({ isActive }) => isActive ? activeStyle : navStyle}>
89-
Tags
91+
{t('nav_tags')}
9092
</NavLink>
9193
<NavLink to="/stale" style={({ isActive }) => isActive ? activeStyle : navStyle}>
92-
Obsoletes
94+
{t('nav_stale')}
9395
</NavLink>
9496
<NavLink to="/embeddings" style={({ isActive }) => isActive ? activeStyle : navStyle}>
95-
Embeddings
97+
{t('nav_embeddings')}
9698
</NavLink>
9799
<NavLink to="/clusters" style={({ isActive }) => isActive ? activeStyle : navStyle}>
98-
Clusters
100+
{t('nav_clusters')}
99101
</NavLink>
100102
<NavLink to="/graph" style={({ isActive }) => isActive ? activeStyle : navStyle}>
101-
Graphe
103+
{t('nav_graph')}
102104
</NavLink>
103105
</nav>
104106

107+
<button
108+
onClick={() => setLanguage(language === 'en' ? 'fr' : 'en')}
109+
aria-label="Toggle language"
110+
style={{
111+
marginLeft: 'auto',
112+
background: 'transparent',
113+
border: '1px solid var(--border-default)',
114+
borderRadius: 'var(--radius-sm)',
115+
color: 'var(--text-secondary)',
116+
cursor: 'pointer',
117+
fontSize: '13px',
118+
fontWeight: 600,
119+
padding: '6px 10px',
120+
transition: 'all var(--transition-fast)',
121+
display: 'flex',
122+
alignItems: 'center',
123+
justifyContent: 'center',
124+
minWidth: '36px',
125+
}}
126+
onMouseEnter={(e) => {
127+
e.currentTarget.style.backgroundColor = 'var(--bg-hover)';
128+
e.currentTarget.style.borderColor = 'var(--border-accent)';
129+
}}
130+
onMouseLeave={(e) => {
131+
e.currentTarget.style.backgroundColor = 'transparent';
132+
e.currentTarget.style.borderColor = 'var(--border-default)';
133+
}}
134+
>
135+
{language === 'en' ? 'FR' : 'EN'}
136+
</button>
137+
105138
<button
106139
onClick={toggleTheme}
107140
aria-label="Toggle theme"
108141
style={{
109-
marginLeft: 'auto',
110142
background: 'transparent',
111143
border: '1px solid var(--border-default)',
112144
borderRadius: 'var(--radius-sm)',
@@ -160,7 +192,9 @@ function App() {
160192
return (
161193
<QueryClientProvider client={queryClient}>
162194
<BrowserRouter>
163-
<AppContent />
195+
<LanguageProvider>
196+
<AppContent />
197+
</LanguageProvider>
164198
</BrowserRouter>
165199
</QueryClientProvider>
166200
);

client/src/components/BulkActionBar.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useLanguage } from '../i18n/LanguageContext';
2+
13
interface BulkActionBarProps {
24
selectedHashes: string[];
35
onDelete: () => void;
@@ -13,26 +15,27 @@ export function BulkActionBar({
1315
onChangeType,
1416
onClear,
1517
}: BulkActionBarProps) {
18+
const { t } = useLanguage();
1619
const count = selectedHashes.length;
1720

1821
const handleDelete = () => {
1922
const confirmed = window.confirm(
20-
`Voulez-vous vraiment supprimer ${count} memoire${count > 1 ? 's' : ''} ?`
23+
t('bulk_confirm_delete').replace('{count}', String(count))
2124
);
2225
if (confirmed) {
2326
onDelete();
2427
}
2528
};
2629

2730
const handleAddTag = () => {
28-
const tag = window.prompt('Entrez le tag a ajouter :');
31+
const tag = window.prompt(t('bulk_prompt_tag'));
2932
if (tag && tag.trim()) {
3033
onAddTag(tag.trim());
3134
}
3235
};
3336

3437
const handleChangeType = () => {
35-
const type = window.prompt('Entrez le nouveau type :');
38+
const type = window.prompt(t('bulk_prompt_type'));
3639
if (type && type.trim()) {
3740
onChangeType(type.trim());
3841
}
@@ -56,7 +59,7 @@ export function BulkActionBar({
5659
}}
5760
>
5861
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)' }}>
59-
{count} memoire{count > 1 ? 's' : ''} selectionnee{count > 1 ? 's' : ''}
62+
{(count > 1 ? t('bulk_selected_other') : t('bulk_selected_one')).replace('{count}', String(count))}
6063
</span>
6164

6265
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto' }}>
@@ -74,7 +77,7 @@ export function BulkActionBar({
7477
transition: 'all var(--transition-fast)',
7578
}}
7679
>
77-
Supprimer
80+
{t('delete')}
7881
</button>
7982

8083
<button
@@ -91,7 +94,7 @@ export function BulkActionBar({
9194
transition: 'all var(--transition-fast)',
9295
}}
9396
>
94-
Ajouter tag
97+
{t('bulk_add_tag')}
9598
</button>
9699

97100
<button
@@ -108,7 +111,7 @@ export function BulkActionBar({
108111
transition: 'all var(--transition-fast)',
109112
}}
110113
>
111-
Changer type
114+
{t('bulk_change_type')}
112115
</button>
113116

114117
<button
@@ -125,7 +128,7 @@ export function BulkActionBar({
125128
transition: 'all var(--transition-fast)',
126129
}}
127130
>
128-
Deselectionner tout
131+
{t('bulk_deselect')}
129132
</button>
130133
</div>
131134
</div>

client/src/components/FilterPanel.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { useState, useEffect } from 'react';
22
import { useStats, useTags } from '../hooks/useStats';
33
import type { MemoryFilters } from '../types';
4+
import { useLanguage } from '../i18n/LanguageContext';
45

56
interface FilterPanelProps {
67
filters: MemoryFilters;
78
onApply: (filters: MemoryFilters) => void;
89
}
910

1011
export function FilterPanel({ filters, onApply }: FilterPanelProps) {
12+
const { t } = useLanguage();
1113
const [isOpen, setIsOpen] = useState(false);
1214
const [localFilters, setLocalFilters] = useState<MemoryFilters>(filters);
1315

@@ -150,14 +152,14 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
150152
return (
151153
<div>
152154
<button onClick={() => setIsOpen(!isOpen)} style={toggleBtnStyle}>
153-
Filtres
155+
{t('filter_button')}
154156
</button>
155157

156158
{isOpen && (
157159
<div style={panelStyle}>
158160
<div style={fieldStyle}>
159161
<label htmlFor="filter-type" style={labelStyle}>
160-
Type
162+
{t('type')}
161163
</label>
162164
<select
163165
id="filter-type"
@@ -166,7 +168,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
166168
aria-label="Type"
167169
style={inputStyle}
168170
>
169-
<option value="">Tous les types</option>
171+
<option value="">{t('filter_all_types')}</option>
170172
{types.map(type => (
171173
<option key={type} value={type}>
172174
{type}
@@ -177,7 +179,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
177179

178180
<div style={fieldStyle}>
179181
<label style={labelStyle} aria-label="Tags">
180-
Tags
182+
{t('tags')}
181183
</label>
182184
<div style={checkboxContainerStyle}>
183185
{availableTags.map(tag => {
@@ -207,7 +209,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
207209
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
208210
<div>
209211
<label htmlFor="filter-from" style={labelStyle}>
210-
Date debut
212+
{t('filter_date_from')}
211213
</label>
212214
<input
213215
id="filter-from"
@@ -219,7 +221,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
219221
</div>
220222
<div>
221223
<label htmlFor="filter-to" style={labelStyle}>
222-
Date fin
224+
{t('filter_date_to')}
223225
</label>
224226
<input
225227
id="filter-to"
@@ -234,7 +236,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
234236
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
235237
<div>
236238
<label htmlFor="filter-quality-min" style={labelStyle}>
237-
Qualite min ({localFilters.quality_min ?? 0})
239+
{t('filter_quality_min').replace('{value}', String(localFilters.quality_min ?? 0))}
238240
</label>
239241
<input
240242
id="filter-quality-min"
@@ -249,7 +251,7 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
249251
</div>
250252
<div>
251253
<label htmlFor="filter-quality-max" style={labelStyle}>
252-
Qualite max ({localFilters.quality_max ?? 1})
254+
{t('filter_quality_max').replace('{value}', String(localFilters.quality_max ?? 1))}
253255
</label>
254256
<input
255257
id="filter-quality-max"
@@ -266,10 +268,10 @@ export function FilterPanel({ filters, onApply }: FilterPanelProps) {
266268

267269
<div style={{ display: 'flex', gap: '8px' }}>
268270
<button onClick={handleApply} style={applyBtnStyle}>
269-
Appliquer
271+
{t('filter_apply')}
270272
</button>
271273
<button onClick={handleReset} style={resetBtnStyle}>
272-
Reinitialiser
274+
{t('filter_reset')}
273275
</button>
274276
</div>
275277
</div>

client/src/components/KeyboardHelp.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { useEffect } from 'react';
2+
import { useLanguage } from '../i18n/LanguageContext';
23

34
interface KeyboardHelpProps {
45
isOpen: boolean;
56
onClose: () => void;
67
}
78

8-
const shortcuts = [
9-
{ key: 'j', description: 'Ligne suivante' },
10-
{ key: 'k', description: 'Ligne precedente' },
11-
{ key: '/', description: 'Focus recherche' },
12-
{ key: 'Enter', description: 'Ouvrir le detail' },
13-
{ key: 'Escape', description: 'Fermer / deselectionner' },
14-
{ key: '?', description: 'Afficher cette aide' },
15-
];
16-
179
export function KeyboardHelp({ isOpen, onClose }: KeyboardHelpProps) {
10+
const { t } = useLanguage();
11+
12+
const shortcuts = [
13+
{ key: 'j', description: t('kb_next_line') },
14+
{ key: 'k', description: t('kb_prev_line') },
15+
{ key: '/', description: t('kb_focus_search') },
16+
{ key: 'Enter', description: t('kb_open_detail') },
17+
{ key: 'Escape', description: t('kb_close') },
18+
{ key: '?', description: t('kb_show_help') },
19+
];
1820
useEffect(() => {
1921
if (!isOpen) return;
2022

@@ -62,7 +64,7 @@ export function KeyboardHelp({ isOpen, onClose }: KeyboardHelpProps) {
6264
fontWeight: 600,
6365
color: 'var(--text-primary)',
6466
}}>
65-
Raccourcis clavier
67+
{t('kb_title')}
6668
</h3>
6769

6870
<table style={{ width: '100%', borderCollapse: 'collapse' }}>

client/src/components/Pagination.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useLanguage } from '../i18n/LanguageContext';
2+
13
interface PaginationProps {
24
total: number;
35
limit: number;
@@ -24,6 +26,7 @@ const btnDisabled: React.CSSProperties = {
2426
};
2527

2628
export function Pagination({ total, limit, offset, onPageChange }: PaginationProps) {
29+
const { t } = useLanguage();
2730
const currentPage = Math.floor(offset / limit) + 1;
2831
const totalPages = Math.max(1, Math.ceil(total / limit));
2932

@@ -38,21 +41,21 @@ export function Pagination({ total, limit, offset, onPageChange }: PaginationPro
3841
<button
3942
disabled={currentPage <= 1}
4043
onClick={() => onPageChange(Math.max(0, offset - limit))}
41-
aria-label="Page precedente"
44+
aria-label={t('page_aria_prev')}
4245
style={currentPage <= 1 ? btnDisabled : btnStyle}
4346
>
44-
Precedent
47+
{t('page_previous')}
4548
</button>
4649
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
47-
Page <span style={{ color: 'var(--text-primary)', fontWeight: 600 }}>{currentPage}</span> / {totalPages}
50+
{t('page_label')} <span style={{ color: 'var(--text-primary)', fontWeight: 600 }}>{currentPage}</span> / {totalPages}
4851
</span>
4952
<button
5053
disabled={currentPage >= totalPages}
5154
onClick={() => onPageChange(offset + limit)}
52-
aria-label="Page suivante"
55+
aria-label={t('page_aria_next')}
5356
style={currentPage >= totalPages ? btnDisabled : btnStyle}
5457
>
55-
Suivant
58+
{t('page_next')}
5659
</button>
5760
</nav>
5861
);

0 commit comments

Comments
 (0)