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:
@@ -1,3 +1,6 @@
|
||||
# Optional: pre-fill the Gateway URL field on the login screen
|
||||
# If not set, defaults to ws://<current-hostname>:18789
|
||||
VITE_GATEWAY_WS_URL=ws://localhost:18789
|
||||
|
||||
# Optional: UI locale (default: en). Supported: en, fr
|
||||
VITE_LOCALE=en
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
{messages.length === 0 && (
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg text-zinc-200 font-semibold">PinchChat</div>
|
||||
<div className="text-sm mt-1 text-zinc-500">Send a message to get started</div>
|
||||
<div className="text-lg text-zinc-200 font-semibold">{t('chat.welcome')}</div>
|
||||
<div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.filter(hasVisibleContent).map(msg => (
|
||||
|
||||
@@ -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) {
|
||||
<div
|
||||
className="border-t border-white/8 bg-[#1a1a20]/60 backdrop-blur-xl p-4"
|
||||
role="form"
|
||||
aria-label="Message input"
|
||||
aria-label={t('chat.inputLabel')}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -218,8 +219,8 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
@@ -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"
|
||||
>
|
||||
<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
|
||||
onClick={handleSubmit}
|
||||
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"
|
||||
>
|
||||
<Send size={16} />
|
||||
<span className="hidden sm:inline">Send</span>
|
||||
<span className="hidden sm:inline">{t('chat.send')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<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} />
|
||||
</button>
|
||||
<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 className="min-w-0">
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 truncate block">{sessionLabel}</span>
|
||||
@@ -34,25 +35,25 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
||||
{status === 'connected' ? (
|
||||
<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="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>
|
||||
) : status === 'connecting' ? (
|
||||
<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="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 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="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>
|
||||
)}
|
||||
{onLogout && (
|
||||
<button
|
||||
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"
|
||||
title="Logout"
|
||||
title={t('header.logout')}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
|
||||
@@ -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) {
|
||||
<Bot className="h-7 w-7 text-cyan-200" />
|
||||
</div>
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gateway-url" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
|
||||
Gateway URL
|
||||
{t('login.gatewayUrl')}
|
||||
</label>
|
||||
<input
|
||||
id="gateway-url"
|
||||
@@ -82,7 +83,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="gateway-token" className="block text-xs font-medium text-zinc-400 uppercase tracking-wider">
|
||||
Token
|
||||
{t('login.token')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -90,7 +91,7 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
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"
|
||||
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 ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
@@ -121,16 +122,16 @@ export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Connecting…
|
||||
{t('login.connecting')}
|
||||
</>
|
||||
) : (
|
||||
'Connect'
|
||||
t('login.connect')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-6">
|
||||
Credentials are stored locally in your browser
|
||||
{t('login.storedLocally')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
<Sparkles className="h-4 w-4 text-cyan-200" />
|
||||
</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>
|
||||
<button onClick={onClose} className="lg:hidden p-1.5 rounded-xl hover:bg-white/5 text-zinc-400 transition-colors">
|
||||
<X size={16} />
|
||||
@@ -30,7 +31,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Pr
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{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 => {
|
||||
const isActive = s.key === activeSession;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
|
||||
import { t } from '../lib/i18n';
|
||||
|
||||
export function ThinkingBlock({ text }: { text: string }) {
|
||||
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"
|
||||
>
|
||||
<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" />}
|
||||
</button>
|
||||
{open && (
|
||||
|
||||
@@ -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 && (
|
||||
<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
|
||||
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"
|
||||
|
||||
93
src/lib/i18n.ts
Normal file
93
src/lib/i18n.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user