2025-06-18 18:12:15 +01:00
|
|
|
import React, { useState, useEffect } from 'react';
|
2025-08-25 12:53:33 +01:00
|
|
|
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
|
2025-05-29 17:26:32 +01:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2025-06-19 22:41:05 +01:00
|
|
|
import { supportedLanguages } from '../../i18n';
|
2025-05-29 17:26:32 +01:00
|
|
|
import LanguageIcon from '@mui/icons-material/Language';
|
|
|
|
import styles from './LanguageSelector.module.css';
|
|
|
|
|
2025-08-25 12:53:33 +01:00
|
|
|
interface LanguageSelectorProps {
|
|
|
|
position?: React.ComponentProps<typeof Menu>['position'];
|
|
|
|
offset?: number;
|
|
|
|
compact?: boolean; // icon-only trigger
|
|
|
|
}
|
|
|
|
|
|
|
|
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
|
2025-05-29 17:26:32 +01:00
|
|
|
const { i18n } = useTranslation();
|
|
|
|
const [opened, setOpened] = useState(false);
|
2025-06-18 18:12:15 +01:00
|
|
|
const [animationTriggered, setAnimationTriggered] = useState(false);
|
|
|
|
const [isChanging, setIsChanging] = useState(false);
|
|
|
|
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
|
|
|
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
|
2025-05-29 17:26:32 +01:00
|
|
|
|
|
|
|
const languageOptions = Object.entries(supportedLanguages)
|
|
|
|
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
|
|
|
.map(([code, name]) => ({
|
|
|
|
value: code,
|
|
|
|
label: name,
|
|
|
|
}));
|
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
2025-08-25 12:53:33 +01:00
|
|
|
// Create ripple effect at click position (only for button mode)
|
|
|
|
if (!compact) {
|
|
|
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
|
|
const x = event.clientX - rect.left;
|
|
|
|
const y = event.clientY - rect.top;
|
|
|
|
setRippleEffect({ x, y, key: Date.now() });
|
|
|
|
}
|
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Start transition animation
|
|
|
|
setIsChanging(true);
|
|
|
|
setPendingLanguage(value);
|
2025-08-25 12:53:33 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Simulate processing time for smooth transition
|
|
|
|
setTimeout(() => {
|
|
|
|
i18n.changeLanguage(value);
|
2025-08-25 12:53:33 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
setTimeout(() => {
|
|
|
|
setIsChanging(false);
|
|
|
|
setPendingLanguage(null);
|
|
|
|
setOpened(false);
|
2025-08-25 12:53:33 +01:00
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Clear ripple effect
|
|
|
|
setTimeout(() => setRippleEffect(null), 100);
|
|
|
|
}, 300);
|
|
|
|
}, 200);
|
2025-05-29 17:26:32 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
|
|
|
supportedLanguages['en-GB'];
|
|
|
|
|
2025-06-18 18:12:15 +01:00
|
|
|
// Trigger animation when dropdown opens
|
|
|
|
useEffect(() => {
|
|
|
|
if (opened) {
|
|
|
|
setAnimationTriggered(false);
|
|
|
|
// Small delay to ensure DOM is ready
|
|
|
|
setTimeout(() => setAnimationTriggered(true), 50);
|
|
|
|
}
|
|
|
|
}, [opened]);
|
|
|
|
|
2025-05-29 17:26:32 +01:00
|
|
|
return (
|
2025-06-18 18:12:15 +01:00
|
|
|
<>
|
|
|
|
<style>
|
|
|
|
{`
|
|
|
|
@keyframes ripple-expand {
|
2025-08-25 12:53:33 +01:00
|
|
|
0% { width: 0; height: 0; opacity: 0.6; }
|
|
|
|
50% { opacity: 0.3; }
|
|
|
|
100% { width: 100px; height: 100px; opacity: 0; }
|
2025-06-18 18:12:15 +01:00
|
|
|
}
|
|
|
|
`}
|
|
|
|
</style>
|
|
|
|
<Menu
|
|
|
|
opened={opened}
|
|
|
|
onChange={setOpened}
|
|
|
|
width={600}
|
2025-08-25 12:53:33 +01:00
|
|
|
position={position}
|
|
|
|
offset={offset}
|
2025-06-18 18:12:15 +01:00
|
|
|
transitionProps={{
|
|
|
|
transition: 'scale-y',
|
|
|
|
duration: 200,
|
|
|
|
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|
|
|
}}
|
|
|
|
>
|
2025-05-29 17:26:32 +01:00
|
|
|
<Menu.Target>
|
2025-08-25 12:53:33 +01:00
|
|
|
{compact ? (
|
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
title={currentLanguage}
|
|
|
|
className="right-rail-icon"
|
|
|
|
styles={{
|
|
|
|
root: {
|
|
|
|
color: 'var(--right-rail-icon)',
|
|
|
|
'&:hover': {
|
|
|
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
|
|
|
}
|
2025-05-29 17:26:32 +01:00
|
|
|
}
|
2025-08-25 12:53:33 +01:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">language</span>
|
|
|
|
</ActionIcon>
|
|
|
|
) : (
|
|
|
|
<Button
|
|
|
|
variant="subtle"
|
|
|
|
size="sm"
|
|
|
|
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
|
|
|
styles={{
|
|
|
|
root: {
|
|
|
|
border: 'none',
|
|
|
|
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
|
|
|
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
|
|
'&:hover': {
|
|
|
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
|
|
|
}
|
|
|
|
},
|
|
|
|
label: { fontSize: '12px', fontWeight: 500 }
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<span className={styles.languageText}>
|
|
|
|
{currentLanguage}
|
|
|
|
</span>
|
|
|
|
</Button>
|
|
|
|
)}
|
2025-05-29 17:26:32 +01:00
|
|
|
</Menu.Target>
|
|
|
|
|
|
|
|
<Menu.Dropdown
|
|
|
|
style={{
|
|
|
|
padding: '12px',
|
|
|
|
borderRadius: '8px',
|
|
|
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
2025-08-01 14:22:19 +01:00
|
|
|
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
|
|
|
|
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
|
2025-05-29 17:26:32 +01:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
<ScrollArea h={190} type="scroll">
|
|
|
|
<div className={styles.languageGrid}>
|
2025-06-18 18:12:15 +01:00
|
|
|
{languageOptions.map((option, index) => (
|
2025-05-29 17:26:32 +01:00
|
|
|
<div
|
|
|
|
key={option.value}
|
|
|
|
className={styles.languageItem}
|
2025-06-18 18:12:15 +01:00
|
|
|
style={{
|
|
|
|
opacity: animationTriggered ? 1 : 0,
|
|
|
|
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
|
|
|
|
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
|
|
|
|
}}
|
2025-05-29 17:26:32 +01:00
|
|
|
>
|
|
|
|
<Button
|
|
|
|
variant="subtle"
|
|
|
|
size="sm"
|
|
|
|
fullWidth
|
2025-06-18 18:12:15 +01:00
|
|
|
onClick={(event) => handleLanguageChange(option.value, event)}
|
2025-08-01 14:22:19 +01:00
|
|
|
data-selected={option.value === i18n.language}
|
2025-05-29 17:26:32 +01:00
|
|
|
styles={{
|
|
|
|
root: {
|
|
|
|
borderRadius: '4px',
|
|
|
|
minHeight: '32px',
|
|
|
|
padding: '4px 8px',
|
|
|
|
justifyContent: 'flex-start',
|
2025-06-18 18:12:15 +01:00
|
|
|
position: 'relative',
|
|
|
|
overflow: 'hidden',
|
2025-08-01 14:22:19 +01:00
|
|
|
backgroundColor: option.value === i18n.language
|
|
|
|
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
|
|
|
: 'transparent',
|
|
|
|
color: option.value === i18n.language
|
|
|
|
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
|
|
|
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
|
2025-06-18 18:12:15 +01:00
|
|
|
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
2025-05-29 17:26:32 +01:00
|
|
|
'&:hover': {
|
2025-08-01 14:22:19 +01:00
|
|
|
backgroundColor: option.value === i18n.language
|
|
|
|
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
|
|
|
|
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
2025-06-18 18:12:15 +01:00
|
|
|
transform: 'translateY(-1px)',
|
|
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
2025-05-29 17:26:32 +01:00
|
|
|
}
|
|
|
|
},
|
|
|
|
label: {
|
|
|
|
fontSize: '13px',
|
|
|
|
fontWeight: option.value === i18n.language ? 600 : 400,
|
|
|
|
textAlign: 'left',
|
|
|
|
overflow: 'hidden',
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
whiteSpace: 'nowrap',
|
2025-06-18 18:12:15 +01:00
|
|
|
position: 'relative',
|
|
|
|
zIndex: 2,
|
2025-05-29 17:26:32 +01:00
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{option.label}
|
2025-08-25 12:53:33 +01:00
|
|
|
{!compact && rippleEffect && pendingLanguage === option.value && (
|
2025-06-18 18:12:15 +01:00
|
|
|
<div
|
|
|
|
key={rippleEffect.key}
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
left: rippleEffect.x,
|
|
|
|
top: rippleEffect.y,
|
|
|
|
width: 0,
|
|
|
|
height: 0,
|
|
|
|
borderRadius: '50%',
|
2025-08-01 14:22:19 +01:00
|
|
|
backgroundColor: 'var(--mantine-color-blue-4)',
|
2025-06-18 18:12:15 +01:00
|
|
|
opacity: 0.6,
|
|
|
|
transform: 'translate(-50%, -50%)',
|
|
|
|
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
|
|
zIndex: 1,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
)}
|
2025-05-29 17:26:32 +01:00
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</ScrollArea>
|
|
|
|
</Menu.Dropdown>
|
2025-06-18 18:12:15 +01:00
|
|
|
</Menu>
|
|
|
|
</>
|
2025-05-29 17:26:32 +01:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default LanguageSelector;
|