zeroclaw/web/src/App.tsx
argenis de la rosa 3f5c57634b feat(web): replace native language selects with custom dropdown and fix RTL text alignment
This commit modernizes the language selector UI across the ZeroClaw web dashboard by replacing
native <select> elements with a shared custom dropdown component featuring styled flag icons,
proper RTL/LTR text direction support, and consistent left-aligned text for all languages.

Changes:
- Add LanguageSelector component with custom dropdown, flag badges, and check mark for selected option
- Wire document direction updates on locale change for Arabic, Hebrew, Urdu, and other RTL languages
- Fix RTL text alignment in dropdown options by applying dir attribute only to text spans
- Update pairing dialog and authenticated header to use the shared LanguageSelector
- Add locale metadata helpers: getLanguageOption, getLanguageOptionLabel, getLocaleDirection, applyLocaleToDocument
- Add Vitest configuration and unit tests for i18n helpers
- Add Playwright E2E tests verifying all 31 locales with flag visibility and lang/dir attributes

Testing:
- Unit tests: 5 passed (npm run test:unit)
- Build: passed (npm run build)
- E2E tests: 34 passed (npx playwright test)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:50:21 -04:00

202 lines
6.4 KiB
TypeScript

import { Routes, Route, Navigate } from 'react-router-dom';
import { useState, useEffect, createContext, useContext } from 'react';
import Layout from './components/layout/Layout';
import Dashboard from './pages/Dashboard';
import AgentChat from './pages/AgentChat';
import Tools from './pages/Tools';
import Cron from './pages/Cron';
import Integrations from './pages/Integrations';
import Memory from './pages/Memory';
import Config from './pages/Config';
import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import { AuthProvider, useAuth } from './hooks/useAuth';
import {
applyLocaleToDocument,
coerceLocale,
getLocaleDirection,
setLocale,
tLocale,
type Locale,
} from './lib/i18n';
import LanguageSelector from './components/controls/LanguageSelector';
const LOCALE_STORAGE_KEY = 'zeroclaw:locale';
// Locale context
interface LocaleContextType {
locale: Locale;
setAppLocale: (locale: Locale) => void;
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
// Pairing dialog component
function PairingDialog({
locale,
setAppLocale,
onPair,
}: {
locale: Locale;
setAppLocale: (locale: Locale) => void;
onPair: (code: string) => Promise<void>;
}) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const translate = (key: string) => tLocale(key, locale);
const localeDirection = getLocaleDirection(locale);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await onPair(code);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : translate('auth.pairing_failed'));
} finally {
setLoading(false);
}
};
return (
<div className="pairing-shell min-h-screen flex items-center justify-center px-4" dir={localeDirection}>
<div className="pairing-card w-full max-w-md rounded-2xl p-8">
<div className="mb-4 flex justify-end">
<LanguageSelector
locale={locale}
onChange={setAppLocale}
ariaLabel={translate('common.select_language')}
title={translate('common.languages')}
align="right"
buttonClassName="inline-flex min-w-[12rem] items-center gap-2 rounded-xl border border-[#2b4f97] bg-[#091937]/75 px-3 py-2 text-sm text-[#c4d8ff] shadow-[0_0_0_1px_rgba(79,131,255,0.08)] transition hover:border-[#4f83ff] hover:text-white"
/>
</div>
<div className="text-center mb-6">
<h1 className="mb-2 text-2xl font-semibold tracking-[0.16em] pairing-brand">ZEROCLAW</h1>
<p className="text-sm text-[#9bb8e8]">{translate('auth.enter_code')}</p>
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={translate('auth.code_placeholder')}
className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4"
maxLength={6}
autoFocus
/>
{error && (
<p className="mb-4 text-center text-sm text-rose-300">{error}</p>
)}
<button
type="submit"
disabled={loading || code.length < 6}
data-testid="pair-button"
className="electric-button w-full rounded-xl py-3 font-medium text-white disabled:opacity-50"
>
{loading ? translate('auth.pairing_progress') : translate('auth.pair_button')}
</button>
</form>
</div>
</div>
);
}
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState<Locale>(() => {
const initialLocale = (() => {
if (typeof window === 'undefined') {
return 'en';
}
const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved) {
return coerceLocale(saved);
}
return coerceLocale(window.navigator.language);
})();
setLocale(initialLocale);
if (typeof document !== 'undefined') {
applyLocaleToDocument(initialLocale, document);
}
return initialLocale;
});
useEffect(() => {
setLocale(locale);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
applyLocaleToDocument(locale, document);
}
}, [locale]);
const setAppLocale = (newLocale: Locale) => {
setLocale(newLocale);
setLocaleState(newLocale);
};
// Listen for 401 events to force logout
useEffect(() => {
const handler = () => {
logout();
};
window.addEventListener('zeroclaw-unauthorized', handler);
return () => window.removeEventListener('zeroclaw-unauthorized', handler);
}, [logout]);
if (loading) {
return (
<div className="pairing-shell min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="electric-loader h-10 w-10 rounded-full" />
<p className="text-[#a7c4f3]">{tLocale('common.connecting', locale)}</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <PairingDialog locale={locale} setAppLocale={setAppLocale} onPair={pair} />;
}
return (
<LocaleContext.Provider value={{ locale, setAppLocale }}>
<Routes key={locale}>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/agent" element={<AgentChat />} />
<Route path="/tools" element={<Tools />} />
<Route path="/cron" element={<Cron />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/memory" element={<Memory />} />
<Route path="/config" element={<Config />} />
<Route path="/cost" element={<Cost />} />
<Route path="/logs" element={<Logs />} />
<Route path="/doctor" element={<Doctor />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</LocaleContext.Provider>
);
}
export default function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
);
}