From 99b7db9793470ae2b7fc775ff5f92921c49e7320 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Wed, 11 Feb 2026 13:19:20 +0000 Subject: [PATCH] feat: add i18n support with VITE_LOCALE env var (en/fr) - Lightweight i18n system in src/lib/i18n.ts (no external deps) - All UI strings extracted to translation keys - English (default) and French locales included - Set VITE_LOCALE=fr in .env for French UI - Fallback to English for unknown locales --- .env.example | 3 ++ src/components/Chat.tsx | 7 +-- src/components/ChatInput.tsx | 17 +++--- src/components/Header.tsx | 15 +++--- src/components/LoginScreen.tsx | 19 +++---- src/components/Sidebar.tsx | 5 +- src/components/ThinkingBlock.tsx | 3 +- src/components/ToolCall.tsx | 3 +- src/lib/i18n.ts | 93 ++++++++++++++++++++++++++++++++ 9 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 src/lib/i18n.ts diff --git a/.env.example b/.env.example index 6bef0f9..df35bb3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ # Optional: pre-fill the Gateway URL field on the login screen # If not set, defaults to ws://:18789 VITE_GATEWAY_WS_URL=ws://localhost:18789 + +# Optional: UI locale (default: en). Supported: en, fr +VITE_LOCALE=en diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 506ea29..5283bf9 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -4,6 +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'; interface Props { messages: ChatMessage[]; @@ -54,7 +55,7 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) return (
-
+
{messages.length === 0 && (
@@ -64,8 +65,8 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
-
PinchChat
-
Send a message to get started
+
{t('chat.welcome')}
+
{t('chat.welcomeSub')}
)} {messages.filter(hasVisibleContent).map(msg => ( diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 7d5a1bd..2405a1e 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { Send, Square, Paperclip, X, FileText } from 'lucide-react'; +import { t } from '../lib/i18n'; interface FileAttachment { id: string; @@ -180,7 +181,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
fileInputRef.current?.click()} disabled={disabled} className="shrink-0 h-11 w-11 rounded-2xl border border-white/8 bg-zinc-800/30 flex items-center justify-center text-zinc-400 hover:text-cyan-300 hover:bg-white/5 transition-colors disabled:opacity-30" - title="Attach file" - aria-label="Attach file" + title={t('chat.attachFile')} + aria-label={t('chat.attachFile')} > @@ -238,8 +239,8 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) { onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder="Type a message…" - aria-label="Message" + placeholder={t('chat.inputPlaceholder')} + aria-label={t('chat.inputLabel')} disabled={disabled} rows={1} className="flex-1 bg-transparent resize-none rounded-2xl border border-white/8 bg-zinc-900/35 px-4 py-3 text-sm text-zinc-300 placeholder:text-zinc-500 outline-none focus:ring-2 focus:ring-cyan-400/30 transition-all max-h-[200px]" @@ -250,17 +251,17 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) { className="shrink-0 h-11 px-4 rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors flex items-center gap-2" > - Stop + {t('chat.stop')} ) : ( )}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ea7e152..30168d9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,6 @@ import { Menu, Bot, Sparkles, LogOut } from 'lucide-react'; import type { ConnectionStatus, Session } from '../types'; +import { t } from '../lib/i18n'; interface Props { status: ConnectionStatus; @@ -15,7 +16,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, return ( <>
-
@@ -24,7 +25,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
- PinchChat + {t('header.title')}
{sessionLabel} @@ -34,25 +35,25 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, {status === 'connected' ? (
- Connected + {t('header.connected')}
) : status === 'connecting' ? (
- Connecting… + {t('login.connecting')}
) : (
- Disconnected + {t('header.disconnected')}
)} {onLogout && ( diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index 6c0d287..4dd0304 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react'; +import { t } from '../lib/i18n'; interface Props { onConnect: (url: string, token: string) => void; @@ -56,17 +57,17 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
-

PinchChat

+

{t('login.title')}

-

Connect to your OpenClaw gateway

+

{t('login.subtitle')}

{/* Form */}
setToken(e.target.value)} - placeholder="Enter your gateway token" + placeholder={t('login.tokenPlaceholder')} className="w-full rounded-xl border border-white/8 bg-zinc-800/50 px-4 py-3 pr-12 text-sm text-zinc-200 placeholder:text-zinc-600 outline-none focus:border-cyan-400/40 focus:ring-1 focus:ring-cyan-400/20 transition-all" autoComplete="current-password" disabled={isConnecting} @@ -100,7 +101,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) { onClick={() => setShowToken(!showToken)} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" tabIndex={-1} - aria-label={showToken ? 'Hide token' : 'Show token'} + aria-label={showToken ? t('login.hideToken') : t('login.showToken')} > {showToken ? : } @@ -121,16 +122,16 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) { {isConnecting ? ( <> - Connecting… + {t('login.connecting')} ) : ( - 'Connect' + t('login.connect') )}

- Credentials are stored locally in your browser + {t('login.storedLocally')}

diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 67a1ad6..2d7effb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { MessageSquare, X, Sparkles } from 'lucide-react'; import type { Session } from '../types'; +import { t } from '../lib/i18n'; interface Props { sessions: Session[]; @@ -22,7 +23,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
- Sessions + {t('sidebar.title')} {open && ( diff --git a/src/components/ToolCall.tsx b/src/components/ToolCall.tsx index 520dac7..2c6a37e 100644 --- a/src/components/ToolCall.tsx +++ b/src/components/ToolCall.tsx @@ -1,6 +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'; type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string }; @@ -188,7 +189,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: any; r )} {result && (
-
Result
+
{t('tool.result')}
= { + 'login.title': 'PinchChat', + 'login.subtitle': 'Connectez-vous à votre gateway OpenClaw', + 'login.gatewayUrl': 'URL de la gateway', + 'login.token': 'Token', + 'login.tokenPlaceholder': 'Entrez votre token gateway', + 'login.connect': 'Connexion', + 'login.connecting': 'Connexion…', + 'login.showToken': 'Afficher le token', + 'login.hideToken': 'Masquer le token', + 'login.storedLocally': 'Les identifiants sont stockés localement dans votre navigateur', + + 'header.title': 'PinchChat', + 'header.connected': 'Connecté', + 'header.disconnected': 'Déconnecté', + 'header.logout': 'Déconnexion', + 'header.toggleSidebar': 'Afficher/masquer la barre latérale', + + 'chat.welcome': 'PinchChat', + 'chat.welcomeSub': 'Envoyez un message pour commencer', + 'chat.inputPlaceholder': 'Tapez un message…', + 'chat.inputLabel': 'Message', + 'chat.attachFile': 'Joindre un fichier', + 'chat.send': 'Envoyer', + 'chat.stop': 'Arrêter', + 'chat.messages': 'Messages du chat', + + 'sidebar.title': 'Sessions', + 'sidebar.empty': 'Aucune session', + + 'thinking.label': 'Réflexion', + + 'tool.result': 'Résultat', +}; + +const messages: Record> = { en, fr }; + +const locale = (import.meta.env.VITE_LOCALE as string) || 'en'; +const dict = messages[locale] || messages.en; + +/** Return the translated string for the given key, falling back to English. */ +export function t(key: keyof typeof en): string { + return dict[key] ?? (messages.en as Record)[key] ?? key; +} + +export { locale };