feat: add runtime language selector in header (EN/FR toggle)
- 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
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
|
||||
@@ -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<FileAttachment[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LanguageSelector />
|
||||
{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)]" />
|
||||
|
||||
24
src/components/LanguageSelector.tsx
Normal file
24
src/components/LanguageSelector.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={cycle}
|
||||
className="flex items-center gap-1.5 rounded-2xl border border-white/8 bg-zinc-800/30 px-2.5 py-1.5 text-xs text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors"
|
||||
title="Change language"
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span className="font-medium">{localeLabels[current] || current.toUpperCase()}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user