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", "green": "Green",
"blue": "Blue", "blue": "Blue",
"custom": "Custom...", "custom": "Custom...",
"comingSoon": "Coming soon",
"WorkInProgess": "Work in progress, May not work or be buggy, Please report any problems!", "WorkInProgess": "Work in progress, May not work or be buggy, Please report any problems!",
"poweredBy": "Powered by", "poweredBy": "Powered by",
"yes": "Yes", "yes": "Yes",

View File

@ -1,24 +1,161 @@
import React, { useState, useEffect } from 'react'; 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 { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n'; import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon'; import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css'; import styles from './LanguageSelector.module.css';
// Types
interface LanguageSelectorProps { interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position']; position?: React.ComponentProps<typeof Menu>['position'];
offset?: number; offset?: number;
compact?: boolean; // icon-only trigger 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 { i18n } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null); 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)) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({ .map(([code, name]) => ({
value: code, value: code,
@ -65,15 +202,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
return ( return (
<> <>
<style> <RippleStyles />
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
<Menu <Menu
opened={opened} opened={opened}
onChange={setOpened} 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)' timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}} }}
> >
<Menu.Target> <Menu.Target>
{compact ? ( {compact ? (
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
radius="md" radius="md"
title={currentLanguage} title={currentLanguage}
className="right-rail-icon" className="right-rail-icon"
styles={{ styles={{
root: { root: {
color: 'var(--right-rail-icon)', color: 'var(--right-rail-icon)',
'&:hover': { '&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
} }
} }}
}} >
> <LocalIcon icon="language" width="1.5rem" height="1.5rem" />
<LocalIcon icon="language" width="1.5rem" height="1.5rem" /> </ActionIcon>
</ActionIcon> ) : (
) : ( <Button
<Button variant="subtle"
variant="subtle" size="sm"
size="sm" leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />} styles={{
styles={{ root: {
root: { border: 'none',
border: 'none', color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
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)',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': {
'&:hover': { backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', }
} },
}, label: { fontSize: '12px', fontWeight: 500 }
label: { fontSize: '12px', fontWeight: 500 } }}
}} >
> <span className={styles.languageText}>
<span className={styles.languageText}> {currentLanguage}
{currentLanguage} </span>
</span> </Button>
</Button> )}
)} </Menu.Target>
</Menu.Target>
<Menu.Dropdown <Menu.Dropdown
style={{ style={{
padding: '12px', padding: '12px',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', 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))', border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
}} }}
> >
<ScrollArea h={190} type="scroll"> <ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}> <div className={styles.languageGrid}>
{languageOptions.map((option, index) => ( {languageOptions.map((option, index) => {
<div const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
key={option.value} const isDisabled = !isEnglishGB;
className={styles.languageItem}
style={{ return (
opacity: animationTriggered ? 1 : 0, <LanguageItem
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)', key={option.value}
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`, option={option}
}} index={index}
> animationTriggered={animationTriggered}
<Button isSelected={option.value === i18n.language}
variant="subtle" onClick={(event) => handleLanguageChange(option.value, event)}
size="sm" rippleEffect={rippleEffect}
fullWidth pendingLanguage={pendingLanguage}
onClick={(event) => handleLanguageChange(option.value, event)} compact={compact}
data-selected={option.value === i18n.language} disabled={isDisabled}
styles={{ />
root: { );
borderRadius: '4px', })}
minHeight: '32px', </div>
padding: '4px 8px', </ScrollArea>
justifyContent: 'flex-start', </Menu.Dropdown>
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> </Menu>
</> </>
); );
}; };
export default LanguageSelector; export default LanguageSelector;
export type { LanguageSelectorProps, LanguageOption, RippleEffect };