UI improvements

This commit is contained in:
Estifo77
2026-08-14 15:03:02 +03:00
parent 9a7b93cfb6
commit 7733fe8efd
5 changed files with 322 additions and 109 deletions

View File

@@ -7,15 +7,14 @@ import {
Stack, Stack,
Text, Text,
Title, Title,
UnstyledButton,
rem, rem,
useComputedColorScheme, useComputedColorScheme,
useMantineColorScheme,
useMantineTheme, useMantineTheme,
type BoxProps, type BoxProps,
} from '@mantine/core'; } from '@mantine/core';
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ColorSchemeToggle, LanguageSwitcher } from '@ema-platform/ui';
import { useAuthConfig } from '../AuthConfig'; import { useAuthConfig } from '../AuthConfig';
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) { export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
@@ -33,35 +32,6 @@ export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number
); );
} }
function ThemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light');
const isDark = computed === 'dark';
return (
<UnstyledButton
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
aria-label={t('authShell.toggleTheme', 'Toggle theme')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(36),
height: rem(36),
borderRadius: rem(10),
border: '1px solid var(--mantine-color-default-border)',
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-dimmed)',
cursor: 'pointer',
transition: 'all 150ms ease',
}}
>
{isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
</UnstyledButton>
);
}
interface AuthShellProps { interface AuthShellProps {
children: ReactNode; children: ReactNode;
brandTitle?: string; brandTitle?: string;
@@ -98,8 +68,9 @@ export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProp
}} }}
p="md" p="md"
> >
{/* Theme toggle — top-right corner */} {/* Language switcher & Theme toggle — top-right corner */}
<Box <Group
gap="xs"
style={{ style={{
position: 'absolute', position: 'absolute',
top: rem(16), top: rem(16),
@@ -107,8 +78,9 @@ export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProp
zIndex: 10, zIndex: 10,
}} }}
> >
<ThemeToggle /> <LanguageSwitcher supportedLanguages={['en', 'am']} />
</Box> <ColorSchemeToggle />
</Group>
<Flex <Flex
mih="100vh" mih="100vh"

View File

@@ -11,8 +11,10 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconArrowLeft,
IconArrowRight, IconArrowRight,
IconDeviceMobile, IconDeviceMobile,
IconLock, IconLock,
@@ -50,6 +52,14 @@ export function LoginPage() {
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>(); const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
};
// Built inside the component (not module scope) so validation messages // Built inside the component (not module scope) so validation messages
// pick up the active language — same pattern as ProfilePage's forms. // pick up the active language — same pattern as ProfilePage's forms.
const schema = z.object({ const schema = z.object({
@@ -149,6 +159,32 @@ export function LoginPage() {
return ( return (
<AuthShell> <AuthShell>
<Stack gap="lg"> <Stack gap="lg">
<UnstyledButton
onClick={handleBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 14,
fontWeight: 600,
color: "var(--mantine-color-dimmed)",
cursor: "pointer",
width: "fit-content",
transition: "all 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--mantine-primary-color-filled)";
e.currentTarget.style.transform = "translateX(-3px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--mantine-color-dimmed)";
e.currentTarget.style.transform = "translateX(0)";
}}
>
<IconArrowLeft size={18} />
{t("common.back", "Back")}
</UnstyledButton>
<div> <div>
<Title order={2} fz={30}> <Title order={2} fz={30}>
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })} {t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })}

View File

@@ -11,8 +11,10 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
UnstyledButton,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconArrowLeft,
IconArrowRight, IconArrowRight,
IconAt, IconAt,
IconDeviceMobile, IconDeviceMobile,
@@ -61,6 +63,14 @@ export function SignupPage() {
}>(); }>();
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
};
// Order matches passwordRules' default list: length, lowercase, uppercase, // Order matches passwordRules' default list: length, lowercase, uppercase,
// number, special character. Shared between the zod schema (field error) // number, special character. Shared between the zod schema (field error)
// and the live checklist below, so both agree on the wording. // and the live checklist below, so both agree on the wording.
@@ -155,6 +165,32 @@ export function SignupPage() {
})} })}
> >
<Stack gap="lg"> <Stack gap="lg">
<UnstyledButton
onClick={handleBack}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
color: 'var(--mantine-color-dimmed)',
cursor: 'pointer',
width: 'fit-content',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--mantine-primary-color-filled)';
e.currentTarget.style.transform = 'translateX(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--mantine-color-dimmed)';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
<IconArrowLeft size={18} />
{t('common.back', 'Back')}
</UnstyledButton>
<div> <div>
<Title order={2} fz={30}> <Title order={2} fz={30}>
{t('signup.title', 'Create account')} {t('signup.title', 'Create account')}

View File

@@ -1,45 +1,118 @@
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core'; import {
import { IconSun, IconMoon } from '@tabler/icons-react'; Menu,
UnstyledButton,
Text,
Group,
rem,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core';
import {
IconSun,
IconMoon,
IconDeviceDesktop,
IconCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export function ColorSchemeToggle() { export function ColorSchemeToggle() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme(); const { colorScheme, setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true }); const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computed === 'dark'; const isDark = computed === 'dark';
const themes = [
{
value: 'light' as const,
label: t('profile.appearance.light', 'Light'),
icon: IconSun,
color: '#e67e22',
},
{
value: 'dark' as const,
label: t('profile.appearance.dark', 'Dark'),
icon: IconMoon,
color: '#9b59b6',
},
{
value: 'auto' as const,
label: t('profile.appearance.system', 'System'),
icon: IconDeviceDesktop,
color: '#3498db',
},
];
const CurrentIcon = colorScheme === 'auto' ? IconDeviceDesktop : isDark ? IconMoon : IconSun;
return ( return (
<UnstyledButton <Menu shadow="lg" width={170} position="bottom-end" withinPortal transitionProps={{ transition: 'pop-top-right', duration: 150 }}>
aria-label={t('common.toggleTheme')} <Menu.Target>
onClick={() => setColorScheme(isDark ? 'light' : 'dark')} <UnstyledButton
style={{ aria-label={t('common.toggleTheme', 'Toggle light / dark mode')}
display: 'flex', style={{
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
width: rem(38), justifyContent: 'center',
height: rem(38), width: rem(38),
borderRadius: rem(12), height: rem(38),
background: 'var(--mantine-color-body)', borderRadius: rem(12),
color: 'var(--mantine-color-gray-6)', background: 'var(--mantine-color-body)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)', color: isDark ? 'var(--mantine-color-yellow-4)' : 'var(--mantine-color-blue-6)',
transition: 'all 150ms ease', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
cursor: 'pointer', transition: 'all 200ms ease',
border: 'none', cursor: 'pointer',
}} border: 'none',
onMouseEnter={(e) => { }}
e.currentTarget.style.background = 'var(--mantine-primary-color-light)'; onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--mantine-primary-color-6)'; e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)'; '0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)'; e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)'; e.currentTarget.style.boxShadow =
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)';
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; }}
}} >
> <CurrentIcon size={19} stroke={1.8} />
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />} </UnstyledButton>
</UnstyledButton> </Menu.Target>
<Menu.Dropdown
style={{
borderRadius: rem(14),
padding: rem(6),
boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
backdropFilter: 'blur(8px)',
}}
>
<Menu.Label style={{ fontWeight: 600, fontSize: rem(11), letterSpacing: '0.5px', textTransform: 'uppercase' }}>
{t('profile.appearance.title', 'Appearance')}
</Menu.Label>
{themes.map((theme) => {
const Icon = theme.icon;
const isSelected = colorScheme === theme.value;
return (
<Menu.Item
key={theme.value}
onClick={() => setColorScheme(theme.value)}
leftSection={<Icon size={16} stroke={1.8} style={{ color: isSelected ? 'var(--mantine-primary-color-filled)' : 'currentColor' }} />}
rightSection={
isSelected ? <IconCheck size={16} stroke={2.4} style={{ color: 'var(--mantine-primary-color-filled)' }} /> : undefined
}
style={{
borderRadius: rem(8),
fontWeight: isSelected ? 600 : 400,
backgroundColor: isSelected ? 'var(--mantine-primary-color-light)' : undefined,
color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined,
}}
>
<Text size="sm">{theme.label}</Text>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
); );
} }

View File

@@ -1,83 +1,179 @@
import { Menu, UnstyledButton, Text, rem } from '@mantine/core'; import type { ComponentType, SVGProps } from 'react';
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react'; import { Menu, UnstyledButton, Text, Group, rem } from '@mantine/core';
import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react';
import { GB, ET } from 'country-flag-icons/react/3x2';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface LanguageSwitcherProps { interface LanguageSwitcherProps {
supportedLanguages: readonly string[]; supportedLanguages: readonly string[];
/** `icon` renders a compact globe button; `button` shows the language label. */ /** `icon` renders a compact flag button; `button` shows the language label. */
variant?: 'icon' | 'button'; variant?: 'icon' | 'button';
} }
export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) { type FlagComponentType = ComponentType<SVGProps<SVGSVGElement>>;
const LANGUAGE_CONFIG: Record<
string,
{ Flag: FlagComponentType; nativeName: string; shortCode: string }
> = {
en: { Flag: GB, nativeName: 'English', shortCode: 'EN' },
am: { Flag: ET, nativeName: 'አማርኛ', shortCode: 'AM' },
};
export function LanguageSwitcher({
supportedLanguages,
variant = 'icon',
}: LanguageSwitcherProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const current = i18n.language; const current = i18n.language || 'en';
const change = (lng: string) => { const change = (lng: string) => {
if (lng !== current) i18n.changeLanguage(lng); if (lng !== current) i18n.changeLanguage(lng);
}; };
const currentConfig = LANGUAGE_CONFIG[current] || {
Flag: null,
nativeName: current.toUpperCase(),
shortCode: current.toUpperCase(),
};
const CurrentFlag = currentConfig.Flag;
return ( return (
<Menu shadow="md" width={160} position="bottom-end" withinPortal> <Menu
shadow="lg"
width={190}
position="bottom-end"
withinPortal
transitionProps={{ transition: 'pop-top-right', duration: 150 }}
>
<Menu.Target> <Menu.Target>
<UnstyledButton <UnstyledButton
aria-label={t('language.label')} aria-label={t('language.label', 'Language')}
style={{ style={{
display: 'flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: rem(4), gap: rem(6),
width: variant === 'icon' ? rem(38) : 'auto', width: variant === 'icon' ? rem(38) : 'auto',
height: rem(38), height: rem(38),
padding: variant === 'icon' ? 0 : '0 12px', padding: variant === 'icon' ? 0 : '0 12px',
borderRadius: rem(12), borderRadius: rem(12),
background: 'var(--mantine-color-body)', background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)', color: 'var(--mantine-color-gray-7)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
transition: 'all 150ms ease', transition: 'all 200ms ease',
cursor: 'pointer', cursor: 'pointer',
border: 'none', border: 'none',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-primary-color-light)'; e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
e.currentTarget.style.boxShadow = e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)'; '0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)'; e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow = e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)'; '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)';
}} }}
> >
<IconWorld size={19} /> {CurrentFlag ? (
<CurrentFlag
style={{
width: rem(20),
height: rem(14),
borderRadius: rem(2),
display: 'block',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
}}
/>
) : (
<IconWorld size={19} />
)}
{variant !== 'icon' && ( {variant !== 'icon' && (
<> <Group gap={6} wrap="nowrap">
<Text size="sm" fw={500}> <Text size="sm" fw={600}>
{t(`language.${current}`)} {t(`language.${current}`, currentConfig.nativeName)}
</Text> </Text>
<IconChevronDown size={14} /> <IconChevronDown size={14} stroke={2} style={{ opacity: 0.7 }} />
</> </Group>
)} )}
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
<Menu.Dropdown <Menu.Dropdown
style={{ borderRadius: rem(12), padding: rem(6) }} style={{
borderRadius: rem(14),
padding: rem(6),
boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
backdropFilter: 'blur(8px)',
}}
> >
<Menu.Label>{t('language.label')}</Menu.Label> <Menu.Label
{supportedLanguages.map((lng) => ( style={{
<Menu.Item fontWeight: 600,
key={lng} fontSize: rem(11),
onClick={() => change(lng)} letterSpacing: '0.5px',
rightSection={ textTransform: 'uppercase',
current === lng ? <IconCheck size={16} /> : undefined }}
} >
style={{ borderRadius: rem(8) }} {t('language.label', 'Language')}
> </Menu.Label>
{t(`language.${lng}`)} {supportedLanguages.map((lng) => {
</Menu.Item> const config = LANGUAGE_CONFIG[lng] || {
))} Flag: null,
nativeName: lng.toUpperCase(),
shortCode: lng.toUpperCase(),
};
const Flag = config.Flag;
const isSelected = current === lng;
return (
<Menu.Item
key={lng}
onClick={() => change(lng)}
leftSection={
Flag ? (
<Flag
style={{
width: rem(20),
height: rem(14),
borderRadius: rem(2),
display: 'block',
boxShadow: '0 1px 2px rgba(0,0,0,0.15)',
}}
/>
) : (
<IconWorld size={16} />
)
}
rightSection={
isSelected ? (
<IconCheck
size={16}
stroke={2.4}
style={{ color: 'var(--mantine-primary-color-filled)' }}
/>
) : undefined
}
style={{
borderRadius: rem(8),
fontWeight: isSelected ? 600 : 400,
backgroundColor: isSelected
? 'var(--mantine-primary-color-light)'
: undefined,
color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined,
}}
>
<Group justify="space-between" wrap="nowrap" style={{ width: '100%' }}>
<Text size="sm">{t(`language.${lng}`, config.nativeName)}</Text>
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
} }