From 9b3aed4adcb19ba5865ee62d5958ee99402cc235 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Wed, 11 Feb 2026 16:18:22 +0000 Subject: [PATCH] feat: add runtime language selector in header (EN/FR toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LanguageSelector component with globe icon + cycle button - Refactor i18n to support reactive locale switching via useSyncExternalStore - Locale priority: localStorage > VITE_LOCALE > navigator.language > 'en' - All components now use useT() hook for reactive re-rendering on locale change - Persists choice to localStorage (key: pinchchat-locale) - No page reload needed — instant switch --- FEEDBACK.md | 37 ++++++++++----- src/components/Chat.tsx | 3 +- src/components/ChatInput.tsx | 3 +- src/components/ChatMessage.tsx | 10 ++-- src/components/Header.tsx | 5 +- src/components/LanguageSelector.tsx | 24 ++++++++++ src/components/LoginScreen.tsx | 3 +- src/components/Sidebar.tsx | 3 +- src/components/ThinkingBlock.tsx | 3 +- src/components/ToolCall.tsx | 3 +- src/hooks/useLocale.ts | 16 +++++++ src/lib/i18n.ts | 74 ++++++++++++++++++++++++++--- 12 files changed, 155 insertions(+), 29 deletions(-) create mode 100644 src/components/LanguageSelector.tsx create mode 100644 src/hooks/useLocale.ts diff --git a/FEEDBACK.md b/FEEDBACK.md index b93f8da..283180c 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -19,21 +19,34 @@ - **Priority:** medium - **Status:** done - **Completed:** 2026-02-11 — commit `99b7db9` -- **Description:** Ajouter le support i18n (internationalisation) — le projet open-source est en anglais, mais le deploy perso de Nicolas doit rester en français. Soit via une config `.env` (ex: `VITE_LOCALE=fr`), soit via un système de traduction léger. Les strings UI (placeholder input, bouton envoyer, statut connexion, etc.) doivent être configurables. +- **Description:** i18n support ## Item #4 - **Date:** 2026-02-11 - **Priority:** high - **Status:** done - **Completed:** 2026-02-11 — commit `36f9480` -- **Description:** Supprimer le token du build — implémenter un écran de login au runtime - - Au premier lancement (ou si pas de credentials en localStorage), afficher un écran de connexion avec : - - Champ "Gateway URL" (ex: `ws://192.168.1.14:18789`) - - Champ "Token" (password field) - - Bouton "Connect" - - Stocker les credentials en `localStorage` (pas dans le bundle JS) - - Supprimer `VITE_GATEWAY_TOKEN` du `.env.example` et du code - - Garder `VITE_GATEWAY_WS_URL` uniquement comme valeur par défaut optionnelle pour pré-remplir le champ URL - - Ajouter un bouton "Disconnect" / "Logout" dans le header qui clear le localStorage et revient à l'écran de login - - L'écran de login doit suivre le même thème dark neon que le reste de l'app - - ⚠️ Après ce changement, le deploy perso (`~/marlbot-chat/.env`) n'a plus besoin de `VITE_GATEWAY_TOKEN` — l'utilisateur entrera le token via l'UI +- **Description:** Runtime login screen + +## Item #5 +- **Date:** 2026-02-11 +- **Priority:** high +- **Status:** pending +- **Description:** Ajouter un sélecteur de langue dans l'UI + - Un petit toggle/dropdown dans le header ou le login screen pour choisir la langue (EN/FR) + - Stocker le choix en localStorage (priorité sur `VITE_LOCALE` et le locale du navigateur) + - Ordre de priorité : localStorage > VITE_LOCALE > navigator.language > 'en' + - Le changement doit être immédiat (pas de reload nécessaire si possible, sinon reload OK) + - Garder ça minimaliste — juste un petit 🌐 ou drapeau dans le header + +## Item #6 +- **Date:** 2026-02-11 +- **Priority:** high +- **Status:** pending +- **Description:** Installation simplifiée — Docker + oneliner + - **Dockerfile** : image légère (nginx:alpine ou similar) qui sert le build statique. Multi-stage : node pour build, nginx pour serve. Pas de secrets dans l'image (tout est runtime via le login screen). + - **docker-compose.yml** : exemple simple avec juste le container PinchChat + - **Publier l'image sur ghcr.io** : `ghcr.io/marlburrow/pinchchat:latest` — le CI GitHub Actions doit build & push l'image à chaque push sur main + - **Oneliner** : `docker run -p 3000:80 ghcr.io/marlburrow/pinchchat:latest` dans le README + - Alternative sans Docker : `npx pinchchat` ou un script curl qui télécharge le dernier release (build statique) et lance un serveur + - Mettre à jour le README avec les nouvelles méthodes d'installation diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index fcacb65..8d7e6c5 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -4,7 +4,7 @@ import { ChatInput } from './ChatInput'; import { TypingIndicator } from './TypingIndicator'; import type { ChatMessage, ConnectionStatus } from '../types'; import { Bot } from 'lucide-react'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; interface Props { messages: ChatMessage[]; @@ -45,6 +45,7 @@ function hasStreamedText(messages: ChatMessage[]): boolean { const SCROLL_THRESHOLD = 150; export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) { + const t = useT(); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const isNearBottomRef = useRef(true); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 2405a1e..7be9575 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, Paperclip, X, FileText } from 'lucide-react'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; interface FileAttachment { id: string; @@ -80,6 +80,7 @@ function formatSize(bytes: number): string { } export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) { + const t = useT(); const [text, setText] = useState(''); const [files, setFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 9bdb4b7..446378a 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -7,13 +7,16 @@ import { ThinkingBlock } from './ThinkingBlock'; import { CodeBlock } from './CodeBlock'; import { ToolCall } from './ToolCall'; import { Bot, User, Wrench } from 'lucide-react'; -import { t, locale } from '../lib/i18n'; +import { t, getLocale } from '../lib/i18n'; +import { useLocale } from '../hooks/useLocale'; // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage -/** Map i18n locale code to BCP-47 locale for Intl formatting */ -const bcp47Locale = locale === 'fr' ? 'fr-FR' : 'en-US'; +function getBcp47(): string { + return getLocale() === 'fr' ? 'fr-FR' : 'en-US'; +} function formatTimestamp(ts: number): string { + const bcp47Locale = getBcp47(); const date = new Date(ts); const now = new Date(); const time = date.toLocaleTimeString(bcp47Locale, { hour: '2-digit', minute: '2-digit' }); @@ -192,6 +195,7 @@ function InternalOnlyMessage({ message }: { message: ChatMessageType }) { } export function ChatMessageComponent({ message }: { message: ChatMessageType }) { + useLocale(); // re-render on locale change const isUser = message.role === 'user'; // Assistant message with no text content — only tool calls / thinking diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 30168d9..9136076 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,7 @@ import { Menu, Bot, Sparkles, LogOut } from 'lucide-react'; import type { ConnectionStatus, Session } from '../types'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; +import { LanguageSelector } from './LanguageSelector'; interface Props { status: ConnectionStatus; @@ -11,6 +12,7 @@ interface Props { } export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) { + const t = useT(); const sessionLabel = sessionKey.split(':').pop() || sessionKey; return ( @@ -32,6 +34,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
+ {status === 'connected' ? (
diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..55a70ef --- /dev/null +++ b/src/components/LanguageSelector.tsx @@ -0,0 +1,24 @@ +import { Globe } from 'lucide-react'; +import { setLocale, supportedLocales, localeLabels } from '../lib/i18n'; +import { useLocale } from '../hooks/useLocale'; + +export function LanguageSelector() { + const current = useLocale(); + + const cycle = () => { + const idx = supportedLocales.indexOf(current); + const next = supportedLocales[(idx + 1) % supportedLocales.length]; + setLocale(next); + }; + + return ( + + ); +} diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index 4dd0304..6efd519 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; interface Props { onConnect: (url: string, token: string) => void; @@ -29,6 +29,7 @@ export function clearCredentials() { } export function LoginScreen({ onConnect, error, isConnecting }: Props) { + const t = useT(); const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`; const [url, setUrl] = useState(defaultUrl); const [token, setToken] = useState(''); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2d7effb..b3ecf7b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { MessageSquare, X, Sparkles } from 'lucide-react'; import type { Session } from '../types'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; interface Props { sessions: Session[]; @@ -11,6 +11,7 @@ interface Props { } export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Props) { + const t = useT(); return ( <> {open &&
} diff --git a/src/components/ThinkingBlock.tsx b/src/components/ThinkingBlock.tsx index eec56a1..e51d72c 100644 --- a/src/components/ThinkingBlock.tsx +++ b/src/components/ThinkingBlock.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { ChevronRight, ChevronDown, Brain } from 'lucide-react'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; export function ThinkingBlock({ text }: { text: string }) { + const t = useT(); const [open, setOpen] = useState(false); return ( diff --git a/src/components/ToolCall.tsx b/src/components/ToolCall.tsx index 2c6a37e..b747470 100644 --- a/src/components/ToolCall.tsx +++ b/src/components/ToolCall.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-react'; import hljs from 'highlight.js/lib/common'; -import { t } from '../lib/i18n'; +import { useT } from '../hooks/useLocale'; type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string }; @@ -149,6 +149,7 @@ function truncate(s: string, max: number): string { } export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) { + const t = useT(); const [open, setOpen] = useState(false); const c = getColor(name); diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts new file mode 100644 index 0000000..2144d11 --- /dev/null +++ b/src/hooks/useLocale.ts @@ -0,0 +1,16 @@ +import { useSyncExternalStore } from 'react'; +import { getLocale, onLocaleChange, t as rawT, type TranslationKey } from '../lib/i18n'; + +/** Re-renders component when locale changes. Returns the current locale string. */ +export function useLocale(): string { + return useSyncExternalStore(onLocaleChange, getLocale, getLocale); +} + +/** + * Reactive translation hook. + * Components using this will automatically re-render when locale changes. + */ +export function useT(): (key: TranslationKey) => string { + useLocale(); // subscribe to changes + return rawT; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 2ffc0d0..b375723 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -1,9 +1,12 @@ /** - * Lightweight i18n — no external deps. - * Locale is set via VITE_LOCALE env var (default: "en"). - * Add new languages by adding a record to `messages`. + * Lightweight reactive i18n — no external deps. + * + * Locale priority: localStorage > VITE_LOCALE > navigator.language > 'en' + * Changing locale at runtime triggers subscribed React components to re-render. */ +const STORAGE_KEY = 'pinchchat-locale'; + const en = { // Login screen 'login.title': 'PinchChat', @@ -23,6 +26,7 @@ const en = { 'header.disconnected': 'Disconnected', 'header.logout': 'Logout', 'header.toggleSidebar': 'Toggle sidebar', + 'header.changeLanguage': 'Change language', // Chat 'chat.welcome': 'PinchChat', @@ -65,6 +69,7 @@ const fr: Record = { 'header.disconnected': 'Déconnecté', 'header.logout': 'Déconnexion', 'header.toggleSidebar': 'Afficher/masquer la barre latérale', + 'header.changeLanguage': 'Changer de langue', 'chat.welcome': 'PinchChat', 'chat.welcomeSub': 'Envoyez un message pour commencer', @@ -85,14 +90,69 @@ const fr: Record = { 'time.yesterday': 'Hier', }; +export type TranslationKey = keyof typeof en; + const messages: Record> = { en, fr }; -const locale = (import.meta.env.VITE_LOCALE as string) || 'en'; -const dict = messages[locale] || messages.en; +export const supportedLocales = Object.keys(messages) as string[]; + +/** Labels shown in the language selector */ +export const localeLabels: Record = { + en: 'EN', + fr: 'FR', +}; + +function resolveInitialLocale(): string { + // 1. localStorage + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && messages[stored]) return stored; + } catch { /* SSR or blocked storage */ } + + // 2. VITE_LOCALE env var + const envLocale = (import.meta.env.VITE_LOCALE as string) || ''; + if (envLocale && messages[envLocale]) return envLocale; + + // 3. navigator.language + if (typeof navigator !== 'undefined') { + const navLang = navigator.language?.split('-')[0]; + if (navLang && messages[navLang]) return navLang; + } + + // 4. fallback + return 'en'; +} + +let currentLocale = resolveInitialLocale(); +let dict = messages[currentLocale] || messages.en; + +type Listener = () => void; +const listeners = new Set(); + +/** Subscribe to locale changes. Returns unsubscribe function. */ +export function onLocaleChange(fn: Listener): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +/** Get the current locale code */ +export function getLocale(): string { + return currentLocale; +} + +/** Switch locale at runtime. Persists to localStorage and notifies subscribers. */ +export function setLocale(loc: string): void { + if (!messages[loc] || loc === currentLocale) return; + currentLocale = loc; + dict = messages[loc]; + try { localStorage.setItem(STORAGE_KEY, loc); } catch { /* noop */ } + listeners.forEach((fn) => fn()); +} /** Return the translated string for the given key, falling back to English. */ -export function t(key: keyof typeof en): string { +export function t(key: TranslationKey): string { return dict[key] ?? (messages.en as Record)[key] ?? key; } -export { locale }; +// Keep backward-compat named export +export { currentLocale as locale };