Ruperth commited on
Commit
21e8095
·
1 Parent(s): ea0e222

feat: add i18n context with es and en dictionaries

Browse files

Introduces a runtime i18n layer so all user-facing strings can be swapped between Spanish and English without touching components. The provider persists the choice to localStorage and falls back to navigator.language on first load.

frontend/src/i18n/I18nContext.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { DICTIONARIES, type Dict, type Locale } from "./locales";
11
+
12
+ const STORAGE_KEY = "signalmod.locale";
13
+
14
+ type I18nValue = {
15
+ locale: Locale;
16
+ setLocale: (l: Locale) => void;
17
+ toggleLocale: () => void;
18
+ t: Dict;
19
+ };
20
+
21
+ const I18nContext = createContext<I18nValue | null>(null);
22
+
23
+ function detectInitialLocale(): Locale {
24
+ if (typeof window === "undefined") return "es";
25
+ const stored = window.localStorage.getItem(STORAGE_KEY);
26
+ if (stored === "es" || stored === "en") return stored;
27
+ const nav = window.navigator.language?.toLowerCase() ?? "";
28
+ return nav.startsWith("es") ? "es" : "en";
29
+ }
30
+
31
+ export function I18nProvider({ children }: { children: ReactNode }) {
32
+ const [locale, setLocaleState] = useState<Locale>(() => detectInitialLocale());
33
+
34
+ useEffect(() => {
35
+ if (typeof document !== "undefined") {
36
+ document.documentElement.lang = locale;
37
+ }
38
+ if (typeof window !== "undefined") {
39
+ window.localStorage.setItem(STORAGE_KEY, locale);
40
+ }
41
+ }, [locale]);
42
+
43
+ const setLocale = useCallback((l: Locale) => setLocaleState(l), []);
44
+ const toggleLocale = useCallback(
45
+ () => setLocaleState((prev) => (prev === "es" ? "en" : "es")),
46
+ []
47
+ );
48
+
49
+ const value = useMemo<I18nValue>(
50
+ () => ({ locale, setLocale, toggleLocale, t: DICTIONARIES[locale] }),
51
+ [locale, setLocale, toggleLocale]
52
+ );
53
+
54
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
55
+ }
56
+
57
+ export function useI18n() {
58
+ const ctx = useContext(I18nContext);
59
+ if (!ctx) throw new Error("useI18n must be used within I18nProvider");
60
+ return ctx;
61
+ }
frontend/src/i18n/locales.ts ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type Locale = "es" | "en";
2
+
3
+ export type Dict = {
4
+ brand: { tagline: string };
5
+ nav: {
6
+ home: string;
7
+ watch: string;
8
+ hub: string;
9
+ settings: string;
10
+ shorts: string;
11
+ subscriptions: string;
12
+ library: string;
13
+ history: string;
14
+ yourVideos: string;
15
+ moderation: string;
16
+ database: string;
17
+ };
18
+ topbar: {
19
+ searchPlaceholder: string;
20
+ signIn: string;
21
+ toggleSidebar: string;
22
+ toggleTheme: string;
23
+ toggleLanguage: string;
24
+ languageLabel: string;
25
+ themeLightLabel: string;
26
+ themeDarkLabel: string;
27
+ };
28
+ watch: {
29
+ defaultTitle: string;
30
+ defaultMeta: string;
31
+ suggestedVideo: string;
32
+ upNext: string;
33
+ externalOnly: string;
34
+ loadingComments: string;
35
+ loadingRecent: string;
36
+ commentsCount: (n: number) => string;
37
+ toxicDetected: (n: number) => string;
38
+ composePlaceholder: string;
39
+ composeAriaLabel: string;
40
+ analyzing: string;
41
+ liveScore: string;
42
+ toxicity: string;
43
+ cancel: string;
44
+ comment: string;
45
+ fromYoutube: string;
46
+ justNow: string;
47
+ posted: string;
48
+ posting: string;
49
+ you: string;
50
+ flagged: string;
51
+ demoBanner: string;
52
+ placeholderTitleBanner: string;
53
+ couldNotLoadVideos: string;
54
+ failedToLoadComments: string;
55
+ watchOnYoutube: string;
56
+ dismiss: string;
57
+ channelFallback: string;
58
+ };
59
+ hub: {
60
+ title: string;
61
+ threshold: string;
62
+ eventsLogged: string;
63
+ toxicSession: string;
64
+ safeVsToxic: string;
65
+ safe: string;
66
+ toxic: string;
67
+ recentScores: string;
68
+ recentActions: string;
69
+ colUser: string;
70
+ colComment: string;
71
+ colScore: string;
72
+ colAction: string;
73
+ emptyHistory: string;
74
+ };
75
+ settings: {
76
+ title: string;
77
+ activeModel: string;
78
+ productionNote: (f1: string, gap: string) => string;
79
+ baselinesNote: (lrF1: string, bertF1: string, bertGap: string) => string;
80
+ productionLabel: string;
81
+ lrLabel: string;
82
+ bertLabel: string;
83
+ installHint: string;
84
+ switching: string;
85
+ failedSwitch: string;
86
+ couldNotLoadStatus: string;
87
+ activeModelMsg: (name: string) => string;
88
+ thresholdTitle: string;
89
+ thresholdNote: string;
90
+ quickTest: string;
91
+ analyze: string;
92
+ analyzing: string;
93
+ testResult: (status: string, pct: string) => string;
94
+ analysisFailed: string;
95
+ defaultTestText: string;
96
+ };
97
+ badges: { safe: string; toxic: string };
98
+ modelBanner: { current: (name: string, f1: string, gap: string) => string };
99
+ time: {
100
+ justNow: string;
101
+ minutesAgo: (n: number) => string;
102
+ hoursAgo: (n: number) => string;
103
+ daysAgo: (n: number) => string;
104
+ };
105
+ };
106
+
107
+ const es: Dict = {
108
+ brand: { tagline: "Moderación inteligente para YouTube." },
109
+ nav: {
110
+ home: "Inicio",
111
+ watch: "Ver",
112
+ hub: "Panel",
113
+ settings: "Ajustes",
114
+ shorts: "Shorts",
115
+ subscriptions: "Suscripciones",
116
+ library: "Biblioteca",
117
+ history: "Historial",
118
+ yourVideos: "Tus vídeos",
119
+ moderation: "Moderación",
120
+ database: "Base de datos",
121
+ },
122
+ topbar: {
123
+ searchPlaceholder: "Buscar más vídeos...",
124
+ signIn: "Iniciar sesión",
125
+ toggleSidebar: "Mostrar menú",
126
+ toggleTheme: "Cambiar tema",
127
+ toggleLanguage: "Cambiar idioma",
128
+ languageLabel: "ES",
129
+ themeLightLabel: "Claro",
130
+ themeDarkLabel: "Oscuro",
131
+ },
132
+ watch: {
133
+ defaultTitle: "Ver y moderar comentarios",
134
+ defaultMeta: "Elige un vídeo de Siguiente para cargar y puntuar sus comentarios",
135
+ suggestedVideo: "Vídeo sugerido",
136
+ upNext: "Siguiente",
137
+ externalOnly: "Solo externo",
138
+ loadingComments: "Cargando comentarios…",
139
+ loadingRecent: "Cargando comentarios recientes…",
140
+ commentsCount: (n) => `${n} comentarios`,
141
+ toxicDetected: (n) => ` · ${n} tóxicos detectados`,
142
+ composePlaceholder: "Añade un comentario…",
143
+ composeAriaLabel: "Escribe un comentario",
144
+ analyzing: "Analizando…",
145
+ liveScore: "Puntuación en vivo",
146
+ toxicity: "Toxicidad",
147
+ cancel: "Cancelar",
148
+ comment: "Comentar",
149
+ fromYoutube: "desde YouTube",
150
+ justNow: "ahora mismo",
151
+ posted: "Publicado",
152
+ posting: "Publicando…",
153
+ you: "tú",
154
+ flagged: "Marcado para revisión",
155
+ demoBanner: "Usando comentarios demo — añade YOUTUBE_API_KEY a .env para hilos reales de YouTube.",
156
+ placeholderTitleBanner: "Metadatos demo — añade YOUTUBE_API_KEY a .env para títulos reales.",
157
+ couldNotLoadVideos: "No se pudieron cargar los vídeos sugeridos",
158
+ failedToLoadComments: "No se pudieron cargar los comentarios",
159
+ watchOnYoutube: "Ver en YouTube (embed bloqueado)",
160
+ dismiss: "Cerrar",
161
+ channelFallback: "YouTube",
162
+ },
163
+ hub: {
164
+ title: "Panel de moderación",
165
+ threshold: "Umbral",
166
+ eventsLogged: "Eventos registrados",
167
+ toxicSession: "Tóxicos (sesión)",
168
+ safeVsToxic: "Seguros vs Tóxicos",
169
+ safe: "Seguro",
170
+ toxic: "Tóxico",
171
+ recentScores: "Puntuaciones recientes (%)",
172
+ recentActions: "Acciones recientes",
173
+ colUser: "Usuario",
174
+ colComment: "Comentario",
175
+ colScore: "Puntuación",
176
+ colAction: "Acción",
177
+ emptyHistory: "Publica comentarios en la página Ver para poblar el historial.",
178
+ },
179
+ settings: {
180
+ title: "Ajustes",
181
+ activeModel: "Modelo activo",
182
+ productionNote: (f1, gap) => `Por defecto: Meta-Feature Stacking (Producción) (F1 ${f1}, gap ${gap}%).`,
183
+ baselinesNote: (lrF1, bertF1, bertGap) =>
184
+ `Bases: LR + TF-IDF (F1 ${lrF1}) y Frozen Toxic-BERT (F1 ${bertF1}, gap ${bertGap}%).`,
185
+ productionLabel: "Meta-Feature Stacking (Producción)",
186
+ lrLabel: "LR + TF-IDF",
187
+ bertLabel: "Frozen Toxic-BERT",
188
+ installHint:
189
+ "Producción y BERT congelado requieren uv sync --extra hf (o Docker INSTALL_HF=1). El baseline LR usa solo joblib. La primera carga del transformer puede descargar pesos (~1 min).",
190
+ switching: "Cambiando modelo… producción puede tardar hasta un minuto en la primera carga.",
191
+ failedSwitch: "No se pudo cambiar de modelo",
192
+ couldNotLoadStatus: "No se pudo cargar el estado de los modelos",
193
+ activeModelMsg: (name) => `Modelo activo: ${name}`,
194
+ thresholdTitle: "Umbral de toxicidad",
195
+ thresholdNote: "los comentarios con probabilidad igual o superior se marcan como Tóxicos.",
196
+ quickTest: "Prueba rápida",
197
+ analyze: "Analizar",
198
+ analyzing: "Analizando…",
199
+ testResult: (status, pct) => `${status} — ${pct}% tóxico`,
200
+ analysisFailed: "El análisis falló",
201
+ defaultTestText: "Eres un idiota",
202
+ },
203
+ badges: { safe: "Seguro", toxic: "Tóxico" },
204
+ modelBanner: {
205
+ current: (name, f1, gap) => `En uso: ${name} (F1: ${f1}, Gap: ${gap}%)`,
206
+ },
207
+ time: {
208
+ justNow: "ahora mismo",
209
+ minutesAgo: (n) => `hace ${n} min`,
210
+ hoursAgo: (n) => `hace ${n} h`,
211
+ daysAgo: (n) => `hace ${n} d`,
212
+ },
213
+ };
214
+
215
+ const en: Dict = {
216
+ brand: { tagline: "Intelligent moderation for YouTube." },
217
+ nav: {
218
+ home: "Home",
219
+ watch: "Watch",
220
+ hub: "Hub",
221
+ settings: "Settings",
222
+ shorts: "Shorts",
223
+ subscriptions: "Subscriptions",
224
+ library: "Library",
225
+ history: "History",
226
+ yourVideos: "Your videos",
227
+ moderation: "Moderation",
228
+ database: "Database",
229
+ },
230
+ topbar: {
231
+ searchPlaceholder: "Search more videos...",
232
+ signIn: "Sign in",
233
+ toggleSidebar: "Toggle sidebar",
234
+ toggleTheme: "Toggle theme",
235
+ toggleLanguage: "Toggle language",
236
+ languageLabel: "EN",
237
+ themeLightLabel: "Light",
238
+ themeDarkLabel: "Dark",
239
+ },
240
+ watch: {
241
+ defaultTitle: "Watch and moderate comments",
242
+ defaultMeta: "Choose a video from Up next to load and score its comments",
243
+ suggestedVideo: "Suggested video",
244
+ upNext: "Up next",
245
+ externalOnly: "External only",
246
+ loadingComments: "Loading comments…",
247
+ loadingRecent: "Loading recent comments…",
248
+ commentsCount: (n) => `${n} comments`,
249
+ toxicDetected: (n) => ` · ${n} toxic detected`,
250
+ composePlaceholder: "Add a comment…",
251
+ composeAriaLabel: "Write a comment",
252
+ analyzing: "Analyzing…",
253
+ liveScore: "Live score",
254
+ toxicity: "Toxicity",
255
+ cancel: "Cancel",
256
+ comment: "Comment",
257
+ fromYoutube: "from YouTube",
258
+ justNow: "just now",
259
+ posted: "Posted",
260
+ posting: "Posting…",
261
+ you: "you",
262
+ flagged: "Flagged for review",
263
+ demoBanner: "Using demo comments — add YOUTUBE_API_KEY to .env for real YouTube threads.",
264
+ placeholderTitleBanner: "Demo metadata — add YOUTUBE_API_KEY to .env for real titles.",
265
+ couldNotLoadVideos: "Could not load suggested videos",
266
+ failedToLoadComments: "Failed to load comments",
267
+ watchOnYoutube: "Watch on YouTube (embedding blocked)",
268
+ dismiss: "Dismiss",
269
+ channelFallback: "YouTube",
270
+ },
271
+ hub: {
272
+ title: "Moderator Hub",
273
+ threshold: "Threshold",
274
+ eventsLogged: "Events logged",
275
+ toxicSession: "Toxic (session)",
276
+ safeVsToxic: "Safe vs Toxic",
277
+ safe: "Safe",
278
+ toxic: "Toxic",
279
+ recentScores: "Recent scores (%)",
280
+ recentActions: "Recent actions",
281
+ colUser: "User",
282
+ colComment: "Comment",
283
+ colScore: "Score",
284
+ colAction: "Action",
285
+ emptyHistory: "Post comments on the Watch page to populate history.",
286
+ },
287
+ settings: {
288
+ title: "Settings",
289
+ activeModel: "Active model",
290
+ productionNote: (f1, gap) => `Default: Meta-Feature Stacking (Production) (F1 ${f1}, gap ${gap}%).`,
291
+ baselinesNote: (lrF1, bertF1, bertGap) =>
292
+ `Baselines: LR + TF-IDF (F1 ${lrF1}) and Frozen Toxic-BERT (F1 ${bertF1}, gap ${bertGap}%).`,
293
+ productionLabel: "Meta-Feature Stacking (Production)",
294
+ lrLabel: "LR + TF-IDF",
295
+ bertLabel: "Frozen Toxic-BERT",
296
+ installHint:
297
+ "Production and frozen BERT need uv sync --extra hf (or Docker INSTALL_HF=1). LR baseline uses joblib only. First transformer load may download weights (~1 min).",
298
+ switching: "Switching model… production may take up to a minute on first load.",
299
+ failedSwitch: "Failed to switch model",
300
+ couldNotLoadStatus: "Could not load model status",
301
+ activeModelMsg: (name) => `Active model: ${name}`,
302
+ thresholdTitle: "Toxicity threshold",
303
+ thresholdNote: "comments at or above this probability are flagged as Toxic.",
304
+ quickTest: "Quick test",
305
+ analyze: "Analyze",
306
+ analyzing: "Analyzing…",
307
+ testResult: (status, pct) => `${status} — ${pct}% toxic`,
308
+ analysisFailed: "Analysis failed",
309
+ defaultTestText: "You are an idiot",
310
+ },
311
+ badges: { safe: "Safe", toxic: "Toxic" },
312
+ modelBanner: {
313
+ current: (name, f1, gap) => `Currently using: ${name} (F1: ${f1}, Gap: ${gap}%)`,
314
+ },
315
+ time: {
316
+ justNow: "just now",
317
+ minutesAgo: (n) => `${n}m ago`,
318
+ hoursAgo: (n) => `${n}h ago`,
319
+ daysAgo: (n) => `${n}d ago`,
320
+ },
321
+ };
322
+
323
+ export const DICTIONARIES: Record<Locale, Dict> = { es, en };