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:
37
FEEDBACK.md
37
FEEDBACK.md
@@ -19,21 +19,34 @@
|
|||||||
- **Priority:** medium
|
- **Priority:** medium
|
||||||
- **Status:** done
|
- **Status:** done
|
||||||
- **Completed:** 2026-02-11 — commit `99b7db9`
|
- **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
|
## Item #4
|
||||||
- **Date:** 2026-02-11
|
- **Date:** 2026-02-11
|
||||||
- **Priority:** high
|
- **Priority:** high
|
||||||
- **Status:** done
|
- **Status:** done
|
||||||
- **Completed:** 2026-02-11 — commit `36f9480`
|
- **Completed:** 2026-02-11 — commit `36f9480`
|
||||||
- **Description:** Supprimer le token du build — implémenter un écran de login au runtime
|
- **Description:** Runtime login screen
|
||||||
- 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`)
|
## Item #5
|
||||||
- Champ "Token" (password field)
|
- **Date:** 2026-02-11
|
||||||
- Bouton "Connect"
|
- **Priority:** high
|
||||||
- Stocker les credentials en `localStorage` (pas dans le bundle JS)
|
- **Status:** pending
|
||||||
- Supprimer `VITE_GATEWAY_TOKEN` du `.env.example` et du code
|
- **Description:** Ajouter un sélecteur de langue dans l'UI
|
||||||
- Garder `VITE_GATEWAY_WS_URL` uniquement comme valeur par défaut optionnelle pour pré-remplir le champ URL
|
- Un petit toggle/dropdown dans le header ou le login screen pour choisir la langue (EN/FR)
|
||||||
- Ajouter un bouton "Disconnect" / "Logout" dans le header qui clear le localStorage et revient à l'écran de login
|
- Stocker le choix en localStorage (priorité sur `VITE_LOCALE` et le locale du navigateur)
|
||||||
- L'écran de login doit suivre le même thème dark neon que le reste de l'app
|
- Ordre de priorité : localStorage > VITE_LOCALE > navigator.language > 'en'
|
||||||
- ⚠️ 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
|
- 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
|
||||||
|
|||||||
@@ -4,7 +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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@@ -45,6 +45,7 @@ function hasStreamedText(messages: ChatMessage[]): boolean {
|
|||||||
const SCROLL_THRESHOLD = 150;
|
const SCROLL_THRESHOLD = 150;
|
||||||
|
|
||||||
export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) {
|
export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props) {
|
||||||
|
const t = useT();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
|
|||||||
@@ -1,6 +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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,6 +80,7 @@ function formatSize(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
|
export function ChatInput({ onSend, onAbort, isGenerating, disabled }: Props) {
|
||||||
|
const t = useT();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import { ThinkingBlock } from './ThinkingBlock';
|
|||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { Bot, User, Wrench } from 'lucide-react';
|
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
|
// ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage
|
||||||
|
|
||||||
/** Map i18n locale code to BCP-47 locale for Intl formatting */
|
function getBcp47(): string {
|
||||||
const bcp47Locale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
return getLocale() === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
function formatTimestamp(ts: number): string {
|
||||||
|
const bcp47Locale = getBcp47();
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const time = date.toLocaleTimeString(bcp47Locale, { hour: '2-digit', minute: '2-digit' });
|
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 }) {
|
export function ChatMessageComponent({ message }: { message: ChatMessageType }) {
|
||||||
|
useLocale(); // re-render on locale change
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
// Assistant message with no text content — only tool calls / thinking
|
// Assistant message with no text content — only tool calls / thinking
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { LanguageSelector } from './LanguageSelector';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
@@ -11,6 +12,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) {
|
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout }: Props) {
|
||||||
|
const t = useT();
|
||||||
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
const sessionLabel = sessionKey.split(':').pop() || sessionKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +34,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LanguageSelector />
|
||||||
{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)]" />
|
||||||
|
|||||||
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 { 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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onConnect: (url: string, token: string) => void;
|
onConnect: (url: string, token: string) => void;
|
||||||
@@ -29,6 +29,7 @@ export function clearCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
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 defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
|
||||||
const [url, setUrl] = useState(defaultUrl);
|
const [url, setUrl] = useState(defaultUrl);
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
|||||||
@@ -1,6 +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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Props) {
|
export function Sidebar({ sessions, activeSession, onSwitch, open, onClose }: Props) {
|
||||||
|
const t = useT();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden" onClick={onClose} />}
|
{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 { useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
|
||||||
import { t } from '../lib/i18n';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
export function ThinkingBlock({ text }: { text: string }) {
|
export function ThinkingBlock({ text }: { text: string }) {
|
||||||
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +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';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
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 };
|
||||||
|
|
||||||
@@ -149,6 +149,7 @@ function truncate(s: string, max: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
||||||
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const c = getColor(name);
|
const c = getColor(name);
|
||||||
|
|
||||||
|
|||||||
16
src/hooks/useLocale.ts
Normal file
16
src/hooks/useLocale.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Lightweight i18n — no external deps.
|
* Lightweight reactive i18n — no external deps.
|
||||||
* Locale is set via VITE_LOCALE env var (default: "en").
|
*
|
||||||
* Add new languages by adding a record to `messages`.
|
* 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 = {
|
const en = {
|
||||||
// Login screen
|
// Login screen
|
||||||
'login.title': 'PinchChat',
|
'login.title': 'PinchChat',
|
||||||
@@ -23,6 +26,7 @@ const en = {
|
|||||||
'header.disconnected': 'Disconnected',
|
'header.disconnected': 'Disconnected',
|
||||||
'header.logout': 'Logout',
|
'header.logout': 'Logout',
|
||||||
'header.toggleSidebar': 'Toggle sidebar',
|
'header.toggleSidebar': 'Toggle sidebar',
|
||||||
|
'header.changeLanguage': 'Change language',
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
@@ -65,6 +69,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'header.disconnected': 'Déconnecté',
|
'header.disconnected': 'Déconnecté',
|
||||||
'header.logout': 'Déconnexion',
|
'header.logout': 'Déconnexion',
|
||||||
'header.toggleSidebar': 'Afficher/masquer la barre latérale',
|
'header.toggleSidebar': 'Afficher/masquer la barre latérale',
|
||||||
|
'header.changeLanguage': 'Changer de langue',
|
||||||
|
|
||||||
'chat.welcome': 'PinchChat',
|
'chat.welcome': 'PinchChat',
|
||||||
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
'chat.welcomeSub': 'Envoyez un message pour commencer',
|
||||||
@@ -85,14 +90,69 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'time.yesterday': 'Hier',
|
'time.yesterday': 'Hier',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|
||||||
const messages: Record<string, Record<string, string>> = { en, fr };
|
const messages: Record<string, Record<string, string>> = { en, fr };
|
||||||
|
|
||||||
const locale = (import.meta.env.VITE_LOCALE as string) || 'en';
|
export const supportedLocales = Object.keys(messages) as string[];
|
||||||
const dict = messages[locale] || messages.en;
|
|
||||||
|
/** Labels shown in the language selector */
|
||||||
|
export const localeLabels: Record<string, string> = {
|
||||||
|
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<Listener>();
|
||||||
|
|
||||||
|
/** 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. */
|
/** 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<string, string>)[key] ?? key;
|
return dict[key] ?? (messages.en as Record<string, string>)[key] ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { locale };
|
// Keep backward-compat named export
|
||||||
|
export { currentLocale as locale };
|
||||||
|
|||||||
Reference in New Issue
Block a user