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
This commit is contained in:
Nicolas Varrot
2026-02-11 13:19:20 +00:00
parent 8132ddb59f
commit 99b7db9793
9 changed files with 134 additions and 31 deletions

View File

@@ -1,3 +1,6 @@
# Optional: pre-fill the Gateway URL field on the login screen # Optional: pre-fill the Gateway URL field on the login screen
# If not set, defaults to ws://<current-hostname>:18789 # If not set, defaults to ws://<current-hostname>:18789
VITE_GATEWAY_WS_URL=ws://localhost:18789 VITE_GATEWAY_WS_URL=ws://localhost:18789
# Optional: UI locale (default: en). Supported: en, fr
VITE_LOCALE=en

View File

@@ -4,6 +4,7 @@ import { ChatInput } from './ChatInput';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import type { ChatMessage, ConnectionStatus } from '../types'; import type { ChatMessage, ConnectionStatus } from '../types';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { t } from '../lib/i18n';
interface Props { interface Props {
messages: ChatMessage[]; messages: ChatMessage[];
@@ -54,7 +55,7 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto" role="log" aria-label="Chat messages" aria-live="polite"> <div className="flex-1 overflow-y-auto" role="log" aria-label={t('chat.messages')} aria-live="polite">
<div className="max-w-4xl mx-auto py-4"> <div className="max-w-4xl mx-auto py-4">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500"> <div className="flex flex-col items-center justify-center h-[60vh] text-zinc-500">
@@ -64,8 +65,8 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
<Bot className="h-8 w-8 text-cyan-200" /> <Bot className="h-8 w-8 text-cyan-200" />
</div> </div>
</div> </div>
<div className="text-lg text-zinc-200 font-semibold">PinchChat</div> <div className="text-lg text-zinc-200 font-semibold">{t('chat.welcome')}</div>
<div className="text-sm mt-1 text-zinc-500">Send a message to get started</div> <div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div>
</div> </div>
)} )}
{messages.filter(hasVisibleContent).map(msg => ( {messages.filter(hasVisibleContent).map(msg => (

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Square, Paperclip, X, FileText } from 'lucide-react'; import { Send, Square, Paperclip, X, FileText } from 'lucide-react';
import { t } from '../lib/i18n';
interface FileAttachment { interface FileAttachment {
id: string; id: string;
@@ -180,7 +181,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
<div <div
className="border-t border-white/8 bg-[#1a1a20]/60 backdrop-blur-xl p-4" className="border-t border-white/8 bg-[#1a1a20]/60 backdrop-blur-xl p-4"
role="form" role="form"
aria-label="Message input" aria-label={t('chat.inputLabel')}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -218,8 +219,8 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={disabled} 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" 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" title={t('chat.attachFile')}
aria-label="Attach file" aria-label={t('chat.attachFile')}
> >
<Paperclip size={18} /> <Paperclip size={18} />
</button> </button>
@@ -238,8 +239,8 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
placeholder="Type a message…" placeholder={t('chat.inputPlaceholder')}
aria-label="Message" aria-label={t('chat.inputLabel')}
disabled={disabled} disabled={disabled}
rows={1} 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]" 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" 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"
> >
<Square size={16} /> <Square size={16} />
<span className="text-sm hidden sm:inline">Stop</span> <span className="text-sm hidden sm:inline">{t('chat.stop')}</span>
</button> </button>
) : ( ) : (
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={(!text.trim() && files.length === 0) || disabled} disabled={(!text.trim() && files.length === 0) || disabled}
aria-label="Send message" aria-label={t('chat.send')}
className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-zinc-900 font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2" className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-zinc-900 font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
> >
<Send size={16} /> <Send size={16} />
<span className="hidden sm:inline">Send</span> <span className="hidden sm:inline">{t('chat.send')}</span>
</button> </button>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { Menu, Bot, Sparkles, LogOut } from 'lucide-react'; import { Menu, Bot, Sparkles, LogOut } from 'lucide-react';
import type { ConnectionStatus, Session } from '../types'; import type { ConnectionStatus, Session } from '../types';
import { t } from '../lib/i18n';
interface Props { interface Props {
status: ConnectionStatus; status: ConnectionStatus;
@@ -15,7 +16,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
return ( return (
<> <>
<header className="h-14 border-b border-white/8 bg-[#232329]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner"> <header className="h-14 border-b border-white/8 bg-[#232329]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner">
<button onClick={onToggleSidebar} aria-label="Toggle sidebar" className="lg:hidden p-2 rounded-2xl hover:bg-white/5 text-zinc-400 transition-colors"> <button onClick={onToggleSidebar} aria-label={t('header.toggleSidebar')} className="lg:hidden p-2 rounded-2xl hover:bg-white/5 text-zinc-400 transition-colors">
<Menu size={20} /> <Menu size={20} />
</button> </button>
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
@@ -24,7 +25,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-zinc-300 text-sm tracking-wide">PinchChat</span> <span className="font-semibold text-zinc-300 text-sm tracking-wide">{t('header.title')}</span>
<Sparkles className="h-3.5 w-3.5 text-cyan-300/60" /> <Sparkles className="h-3.5 w-3.5 text-cyan-300/60" />
</div> </div>
<span className="text-xs text-zinc-500 truncate block">{sessionLabel}</span> <span className="text-xs text-zinc-500 truncate block">{sessionLabel}</span>
@@ -34,25 +35,25 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
{status === 'connected' ? ( {status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-cyan-300/80 shadow-[0_0_12px_rgba(34,211,238,0.6)]" /> <span className="w-2 h-2 rounded-full bg-cyan-300/80 shadow-[0_0_12px_rgba(34,211,238,0.6)]" />
<span className="text-xs text-zinc-300 hidden sm:inline">Connected</span> <span className="text-xs text-zinc-300 hidden sm:inline">{t('header.connected')}</span>
</div> </div>
) : status === 'connecting' ? ( ) : status === 'connecting' ? (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" /> <span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" />
<span className="text-xs text-zinc-300 hidden sm:inline">Connecting</span> <span className="text-xs text-zinc-300 hidden sm:inline">{t('login.connecting')}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5"> <div className="flex items-center gap-2 rounded-2xl border border-white/8 bg-zinc-800/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-red-400/80" /> <span className="w-2 h-2 rounded-full bg-red-400/80" />
<span className="text-xs text-zinc-300 hidden sm:inline">Disconnected</span> <span className="text-xs text-zinc-300 hidden sm:inline">{t('header.disconnected')}</span>
</div> </div>
)} )}
{onLogout && ( {onLogout && (
<button <button
onClick={onLogout} onClick={onLogout}
aria-label="Disconnect and logout" aria-label={t('header.logout')}
className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors" className="p-2 rounded-2xl hover:bg-white/5 text-zinc-500 hover:text-zinc-300 transition-colors"
title="Logout" title={t('header.logout')}
> >
<LogOut size={16} /> <LogOut size={16} />
</button> </button>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react'; import { Bot, Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
import { t } from '../lib/i18n';
interface Props { interface Props {
onConnect: (url: string, token: string) => void; onConnect: (url: string, token: string) => void;
@@ -56,17 +57,17 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
<Bot className="h-7 w-7 text-cyan-200" /> <Bot className="h-7 w-7 text-cyan-200" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-zinc-200 tracking-wide">PinchChat</h1> <h1 className="text-2xl font-bold text-zinc-200 tracking-wide">{t('login.title')}</h1>
<Sparkles className="h-5 w-5 text-cyan-300/60" /> <Sparkles className="h-5 w-5 text-cyan-300/60" />
</div> </div>
<p className="text-sm text-zinc-500">Connect to your OpenClaw gateway</p> <p className="text-sm text-zinc-500">{t('login.subtitle')}</p>
</div> </div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="rounded-2xl border border-white/8 bg-[#232329]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30"> <form onSubmit={handleSubmit} className="rounded-2xl border border-white/8 bg-[#232329]/80 backdrop-blur-xl p-6 space-y-5 shadow-2xl shadow-black/30">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider"> <label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
Gateway URL {t('login.gatewayUrl')}
</label> </label>
<input <input
id="gateway-url" id="gateway-url"
@@ -82,7 +83,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider"> <label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
Token {t('login.token')}
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@@ -90,7 +91,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
type={showToken ? 'text' : 'password'} type={showToken ? 'text' : 'password'}
value={token} value={token}
onChange={e => setToken(e.target.value)} onChange={e => 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" 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" autoComplete="current-password"
disabled={isConnecting} disabled={isConnecting}
@@ -100,7 +101,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
onClick={() => setShowToken(!showToken)} onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
tabIndex={-1} tabIndex={-1}
aria-label={showToken ? 'Hide token' : 'Show token'} aria-label={showToken ? t('login.hideToken') : t('login.showToken')}
> >
{showToken ? <EyeOff size={16} /> : <Eye size={16} />} {showToken ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
@@ -121,16 +122,16 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
{isConnecting ? ( {isConnecting ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
Connecting {t('login.connecting')}
</> </>
) : ( ) : (
'Connect' t('login.connect')
)} )}
</button> </button>
</form> </form>
<p className="text-center text-xs text-zinc-600 mt-6"> <p className="text-center text-xs text-zinc-600 mt-6">
Credentials are stored locally in your browser {t('login.storedLocally')}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { MessageSquare, X, Sparkles } from 'lucide-react'; import { MessageSquare, X, Sparkles } from 'lucide-react';
import type { Session } from '../types'; import type { Session } from '../types';
import { t } from '../lib/i18n';
interface Props { interface Props {
sessions: Session[]; sessions: Session[];
@@ -22,7 +23,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
<Sparkles className="h-4 w-4 text-cyan-200" /> <Sparkles className="h-4 w-4 text-cyan-200" />
</div> </div>
</div> </div>
<span className="font-semibold text-sm text-zinc-200 tracking-wide">Sessions</span> <span className="font-semibold text-sm text-zinc-200 tracking-wide">{t('sidebar.title')}</span>
</div> </div>
<button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors"> <button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors">
<X size={16} /> <X size={16} />
@@ -30,7 +31,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
</div> </div>
<div className="flex-1 overflow-y-auto py-2 px-2"> <div className="flex-1 overflow-y-auto py-2 px-2">
{sessions.length === 0 && ( {sessions.length === 0 && (
<div className="px-3 py-8 text-center text-zinc-500 text-sm">No sessions</div> <div className="px-3 py-8 text-center text-zinc-500 text-sm">{t('sidebar.empty')}</div>
)} )}
{sessions.map(s => { {sessions.map(s => {
const isActive = s.key === activeSession; const isActive = s.key === activeSession;

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, ChevronDown, Brain } from 'lucide-react'; import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
import { t } from '../lib/i18n';
export function ThinkingBlock({ text }: { text: string }) { export function ThinkingBlock({ text }: { text: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -11,7 +12,7 @@ export function ThinkingBlock({ text }: { text: string }) {
className="inline-flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-white/5 transition-colors" className="inline-flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/35 px-3 py-1.5 text-xs text-violet-300 hover:bg-white/5 transition-colors"
> >
<Brain size={13} /> <Brain size={13} />
<span className="font-medium">Thinking</span> <span className="font-medium">{t('thinking.label')}</span>
{open ? <ChevronDown size={12} className="ml-1 text-zinc-500" /> : <ChevronRight size={12} className="ml-1 text-zinc-500" />} {open ? <ChevronDown size={12} className="ml-1 text-zinc-500" /> : <ChevronRight size={12} className="ml-1 text-zinc-500" />}
</button> </button>
{open && ( {open && (

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { ChevronRight, ChevronDown, Terminal, Globe, Search, FileText, Wrench, Code, Database, Image, MessageSquare, Brain, Cpu } from 'lucide-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 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 }; 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 && (
<div> <div>
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>Result</div> <div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>{t('tool.result')}</div>
<HighlightedPre <HighlightedPre
text={result} text={result}
className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono" className="text-xs bg-[#1a1a20]/60 border border-white/5 p-2.5 rounded-xl overflow-x-auto text-zinc-300 max-h-64 overflow-y-auto font-mono"

93
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Lightweight i18n — no external deps.
* Locale is set via VITE_LOCALE env var (default: "en").
* Add new languages by adding a record to `messages`.
*/
const en = {
// Login screen
'login.title': 'PinchChat',
'login.subtitle': 'Connect to your OpenClaw gateway',
'login.gatewayUrl': 'Gateway URL',
'login.token': 'Token',
'login.tokenPlaceholder': 'Enter your gateway token',
'login.connect': 'Connect',
'login.connecting': 'Connecting…',
'login.showToken': 'Show token',
'login.hideToken': 'Hide token',
'login.storedLocally': 'Credentials are stored locally in your browser',
// Header
'header.title': 'PinchChat',
'header.connected': 'Connected',
'header.disconnected': 'Disconnected',
'header.logout': 'Logout',
'header.toggleSidebar': 'Toggle sidebar',
// Chat
'chat.welcome': 'PinchChat',
'chat.welcomeSub': 'Send a message to get started',
'chat.inputPlaceholder': 'Type a message…',
'chat.inputLabel': 'Message',
'chat.attachFile': 'Attach file',
'chat.send': 'Send',
'chat.stop': 'Stop',
'chat.messages': 'Chat messages',
// Sidebar
'sidebar.title': 'Sessions',
'sidebar.empty': 'No sessions',
// Thinking
'thinking.label': 'Thinking',
// Tool call
'tool.result': 'Result',
} as const;
const fr: Record<keyof typeof en, string> = {
'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<string, Record<string, string>> = { 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<string, string>)[key] ?? key;
}
export { locale };