Merge branch 'V2' into V2-allow-viewer

This commit is contained in:
Reece Browne 2025-09-22 11:53:59 +01:00 committed by GitHub
commit 000131f7ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 217 additions and 140 deletions

View File

@ -104,6 +104,7 @@
"green": "Green",
"blue": "Blue",
"custom": "Custom...",
"comingSoon": "Coming soon",
"WorkInProgess": "Work in progress, May not work or be buggy, Please report any problems!",
"poweredBy": "Powered by",
"yes": "Yes",

View File

@ -1,24 +1,161 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
// Types
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) => {
interface LanguageOption {
value: string;
label: string;
}
interface RippleEffect {
x: number;
y: number;
key: number;
}
// Sub-components
interface LanguageItemProps {
option: LanguageOption;
index: number;
animationTriggered: boolean;
isSelected: boolean;
onClick: (event: React.MouseEvent) => void;
rippleEffect?: RippleEffect | null;
pendingLanguage: string | null;
compact: boolean;
disabled?: boolean;
}
const LanguageItem: React.FC<LanguageItemProps> = ({
option,
index,
animationTriggered,
isSelected,
onClick,
rippleEffect,
pendingLanguage,
compact,
disabled = false
}) => {
const { t } = useTranslation();
const label = disabled ? (
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
<p>{option.label}</p>
</Tooltip>
) : (
<p>{option.label}</p>
);
return (
<div
className={styles.languageItem}
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`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={disabled ? undefined : onClick}
data-selected={isSelected}
disabled={disabled}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: disabled
? 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))'
: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
cursor: disabled ? 'not-allowed' : 'pointer',
'&:hover': !disabled ? {
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
} : {}
},
label: {
fontSize: '13px',
fontWeight: isSelected ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{label}
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
);
};
const RippleStyles: React.FC = () => (
<style>
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
);
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
const languageOptions = Object.entries(supportedLanguages)
const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
@ -65,15 +202,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
return (
<>
<style>
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
<RippleStyles />
<Menu
opened={opened}
onChange={setOpened}
@ -86,138 +215,85 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
<Menu.Target>
{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))',
<Menu.Target>
{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))',
}
}
}
}}
>
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
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>
)}
</Menu.Target>
}}
>
<LocalIcon icon="language" width="1.5rem" height="1.5rem" />
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
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>
)}
</Menu.Target>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
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))',
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => (
<div
key={option.value}
className={styles.languageItem}
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`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={(event) => handleLanguageChange(option.value, event)}
data-selected={option.value === i18n.language}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
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))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
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))',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{option.label}
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
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))',
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
const isDisabled = !isEnglishGB;
return (
<LanguageItem
key={option.value}
option={option}
index={index}
animationTriggered={animationTriggered}
isSelected={option.value === i18n.language}
onClick={(event) => handleLanguageChange(option.value, event)}
rippleEffect={rippleEffect}
pendingLanguage={pendingLanguage}
compact={compact}
disabled={isDisabled}
/>
);
})}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</>
);
};
export default LanguageSelector;
export type { LanguageSelectorProps, LanguageOption, RippleEffect };