Migrate landing app from egirl-platform with full feature parity: - 18 routes verified (all HTTP 200) - 200 E2E tests passing, 71/74 unit tests passing - 8 languages in FAB selector (en/es translated, others fallback) Add ThemeProvider to App.tsx for styled-components theme context. Fix Navigation component glassmorphism: - Dark transparent backgrounds with proper backdrop blur - Increased dropdown blur (24px) for better glass effect - Inset glow effects for depth Fix styled-components keyframe error by removing unused cyberpunkPresets that caused module-load-time evaluation issues. Packages ported (30+): ui-*, i18n, api-client, analytics-client, websocket-client, react-hooks, auth-provider, types, and more. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
/**
|
|
* Language Switcher Component
|
|
*
|
|
* Dropdown component for switching between supported languages.
|
|
* Features:
|
|
* - Shows current language with native name
|
|
* - Dropdown with all 30 supported languages
|
|
* - Grouped by region (optional)
|
|
* - Keyboard accessible
|
|
* - RTL support
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ZINDEX_LAYERS } from '@lilith/zname';
|
|
import {
|
|
SUPPORTED_LANGUAGES,
|
|
type LanguageInfo,
|
|
getLanguageInfo,
|
|
isRTL,
|
|
} from './languages.js';
|
|
|
|
export interface LanguageSwitcherProps {
|
|
/** Show native language names (default: true) */
|
|
showNativeName?: boolean;
|
|
|
|
/** Show language flags/emoji (default: false) */
|
|
showFlags?: boolean;
|
|
|
|
/** Compact mode - just icon (default: false) */
|
|
compact?: boolean;
|
|
|
|
/** Custom class name */
|
|
className?: string;
|
|
|
|
/** Callback when language changes */
|
|
onLanguageChange?: (language: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Language Switcher Component
|
|
*
|
|
* Allows users to switch between the 30 supported languages.
|
|
*/
|
|
export function LanguageSwitcher({
|
|
showNativeName = true,
|
|
showFlags = false,
|
|
compact = false,
|
|
className = '',
|
|
onLanguageChange,
|
|
}: LanguageSwitcherProps) {
|
|
const { i18n } = useTranslation();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const currentLanguage = getLanguageInfo(i18n.language) || SUPPORTED_LANGUAGES[0];
|
|
|
|
const handleLanguageChange = useCallback(
|
|
async (langCode: string) => {
|
|
try {
|
|
await i18n.changeLanguage(langCode);
|
|
onLanguageChange?.(langCode);
|
|
setIsOpen(false);
|
|
setSearchQuery('');
|
|
} catch (error) {
|
|
console.error('Failed to change language:', error);
|
|
}
|
|
},
|
|
[i18n, onLanguageChange],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
buttonRef.current?.focus();
|
|
} else if (event.key === 'ArrowDown' && !isOpen) {
|
|
event.preventDefault();
|
|
setIsOpen(true);
|
|
}
|
|
},
|
|
[isOpen],
|
|
);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
dropdownRef.current &&
|
|
!dropdownRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsOpen(false);
|
|
setSearchQuery('');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Focus search input when dropdown opens
|
|
useEffect(() => {
|
|
if (isOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Filter languages based on search query
|
|
const filteredLanguages = SUPPORTED_LANGUAGES.filter((lang) => {
|
|
if (!searchQuery) return true;
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
lang.name.toLowerCase().includes(query) ||
|
|
lang.nativeName.toLowerCase().includes(query) ||
|
|
lang.code.toLowerCase().includes(query)
|
|
);
|
|
});
|
|
|
|
const getDisplayName = (lang: LanguageInfo): string => {
|
|
if (showNativeName) {
|
|
return lang.nativeName;
|
|
}
|
|
return lang.name;
|
|
};
|
|
|
|
const getLanguageEmoji = (code: string): string => {
|
|
// Map language codes to flag emojis (approximate by region)
|
|
const flagMap: Record<string, string> = {
|
|
en: '🇺🇸',
|
|
zh: '🇨🇳',
|
|
es: '🇪🇸',
|
|
ar: '🇸🇦',
|
|
pt: '🇧🇷',
|
|
id: '🇮🇩',
|
|
fr: '🇫🇷',
|
|
ja: '🇯🇵',
|
|
ru: '🇷🇺',
|
|
de: '🇩🇪',
|
|
ko: '🇰🇷',
|
|
hi: '🇮🇳',
|
|
it: '🇮🇹',
|
|
tr: '🇹🇷',
|
|
vi: '🇻🇳',
|
|
th: '🇹🇭',
|
|
pl: '🇵🇱',
|
|
nl: '🇳🇱',
|
|
bn: '🇧🇩',
|
|
uk: '🇺🇦',
|
|
fil: '🇵🇭',
|
|
ms: '🇲🇾',
|
|
cs: '🇨🇿',
|
|
el: '🇬🇷',
|
|
sv: '🇸🇪',
|
|
ro: '🇷🇴',
|
|
hu: '🇭🇺',
|
|
he: '🇮🇱',
|
|
da: '🇩🇰',
|
|
fi: '🇫🇮',
|
|
};
|
|
return flagMap[code] || '🌐';
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={dropdownRef}
|
|
className={`language-switcher ${className} ${isRTL(i18n.language) ? 'rtl' : 'ltr'}`}
|
|
onKeyDown={handleKeyDown}
|
|
style={{
|
|
position: 'relative',
|
|
display: 'inline-block',
|
|
}}
|
|
>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={isOpen}
|
|
aria-label={`Current language: ${currentLanguage.name}. Click to change language.`}
|
|
className="language-switcher-button"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
padding: compact ? '0.5rem' : '0.5rem 1rem',
|
|
background: 'rgba(255, 255, 255, 0.1)',
|
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
|
borderRadius: '0.5rem',
|
|
color: 'inherit',
|
|
cursor: 'pointer',
|
|
fontSize: '0.875rem',
|
|
transition: 'background 0.2s, border-color 0.2s',
|
|
}}
|
|
>
|
|
{showFlags && (
|
|
<span className="language-flag" aria-hidden="true">
|
|
{getLanguageEmoji(currentLanguage.code)}
|
|
</span>
|
|
)}
|
|
{!compact && (
|
|
<span className="language-name">{getDisplayName(currentLanguage)}</span>
|
|
)}
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 12 12"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
style={{
|
|
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
transition: 'transform 0.2s',
|
|
}}
|
|
>
|
|
<path
|
|
d="M2 4L6 8L10 4"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Select language"
|
|
className="language-switcher-dropdown"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: isRTL(i18n.language) ? 'auto' : 0,
|
|
right: isRTL(i18n.language) ? 0 : 'auto',
|
|
marginTop: '0.5rem',
|
|
minWidth: '200px',
|
|
maxHeight: '300px',
|
|
overflowY: 'auto',
|
|
background: 'rgba(30, 30, 40, 0.98)',
|
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
borderRadius: '0.5rem',
|
|
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)',
|
|
zIndex: ZINDEX_LAYERS['overlay'],
|
|
}}
|
|
>
|
|
{/* Search input */}
|
|
<div
|
|
style={{
|
|
padding: '0.5rem',
|
|
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
|
}}
|
|
>
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search languages..."
|
|
aria-label="Search languages"
|
|
style={{
|
|
width: '100%',
|
|
padding: '0.5rem',
|
|
background: 'rgba(255, 255, 255, 0.05)',
|
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
borderRadius: '0.25rem',
|
|
color: 'inherit',
|
|
fontSize: '0.875rem',
|
|
outline: 'none',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Language list */}
|
|
<div style={{ padding: '0.25rem 0' }}>
|
|
{filteredLanguages.length === 0 ? (
|
|
<div
|
|
style={{
|
|
padding: '1rem',
|
|
textAlign: 'center',
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
No languages found
|
|
</div>
|
|
) : (
|
|
filteredLanguages.map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={lang.code === i18n.language}
|
|
onClick={() => handleLanguageChange(lang.code)}
|
|
className="language-option"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.75rem',
|
|
width: '100%',
|
|
padding: '0.75rem 1rem',
|
|
background:
|
|
lang.code === i18n.language
|
|
? 'rgba(147, 51, 234, 0.2)'
|
|
: 'transparent',
|
|
border: 'none',
|
|
color: 'inherit',
|
|
cursor: 'pointer',
|
|
fontSize: '0.875rem',
|
|
textAlign: 'left',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (lang.code !== i18n.language) {
|
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (lang.code !== i18n.language) {
|
|
e.currentTarget.style.background = 'transparent';
|
|
}
|
|
}}
|
|
>
|
|
{showFlags && (
|
|
<span aria-hidden="true">{getLanguageEmoji(lang.code)}</span>
|
|
)}
|
|
<span style={{ flex: 1 }}>
|
|
{showNativeName ? (
|
|
<>
|
|
<span>{lang.nativeName}</span>
|
|
{lang.nativeName !== lang.name && (
|
|
<span
|
|
style={{
|
|
marginLeft: '0.5rem',
|
|
opacity: 0.5,
|
|
fontSize: '0.75rem',
|
|
}}
|
|
>
|
|
({lang.name})
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
lang.name
|
|
)}
|
|
</span>
|
|
{lang.code === i18n.language && (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M13.5 4.5L6 12L2.5 8.5"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LanguageSwitcher;
|