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,
Text,
Title,
UnstyledButton,
rem,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme,
type BoxProps,
} from '@mantine/core';
import { IconCheck, IconMoon, IconSun } from '@tabler/icons-react';
import { IconCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ColorSchemeToggle, LanguageSwitcher } from '@ema-platform/ui';
import { useAuthConfig } from '../AuthConfig';
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 {
children: ReactNode;
brandTitle?: string;
@@ -98,8 +68,9 @@ export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProp
}}
p="md"
>
{/* Theme toggle — top-right corner */}
<Box
{/* Language switcher & Theme toggle — top-right corner */}
<Group
gap="xs"
style={{
position: 'absolute',
top: rem(16),
@@ -107,8 +78,9 @@ export function AuthShell({ children, brandTitle, brandSubtitle }: AuthShellProp
zIndex: 10,
}}
>
<ThemeToggle />
</Box>
<LanguageSwitcher supportedLanguages={['en', 'am']} />
<ColorSchemeToggle />
</Group>
<Flex
mih="100vh"

View File

@@ -11,8 +11,10 @@ import {
Text,
TextInput,
Title,
UnstyledButton,
} from "@mantine/core";
import {
IconArrowLeft,
IconArrowRight,
IconDeviceMobile,
IconLock,
@@ -50,6 +52,14 @@ export function LoginPage() {
const [meTrigger] = useApiMutation<AuthUser>();
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
// pick up the active language — same pattern as ProfilePage's forms.
const schema = z.object({
@@ -149,6 +159,32 @@ export function LoginPage() {
return (
<AuthShell>
<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>
<Title order={2} fz={30}>
{t("login.welcome", { appName, defaultValue: "Welcome to {{appName}}" })}

View File

@@ -11,8 +11,10 @@ import {
Text,
TextInput,
Title,
UnstyledButton,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconAt,
IconDeviceMobile,
@@ -61,6 +63,14 @@ export function SignupPage() {
}>();
const [meTrigger] = useApiMutation<AuthUser>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
};
// Order matches passwordRules' default list: length, lowercase, uppercase,
// number, special character. Shared between the zod schema (field error)
// and the live checklist below, so both agree on the wording.
@@ -155,6 +165,32 @@ export function SignupPage() {
})}
>
<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>
<Title order={2} fz={30}>
{t('signup.title', 'Create account')}

View File

@@ -1,45 +1,118 @@
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
import {
Menu,
UnstyledButton,
Text,
Group,
rem,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core';
import {
IconSun,
IconMoon,
IconDeviceDesktop,
IconCheck,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
export function ColorSchemeToggle() {
const { t } = useTranslation();
const { setColorScheme } = useMantineColorScheme();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
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 (
<UnstyledButton
aria-label={t('common.toggleTheme')}
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
>
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
</UnstyledButton>
<Menu shadow="lg" width={170} position="bottom-end" withinPortal transitionProps={{ transition: 'pop-top-right', duration: 150 }}>
<Menu.Target>
<UnstyledButton
aria-label={t('common.toggleTheme', 'Toggle light / dark mode')}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(38),
height: rem(38),
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: isDark ? 'var(--mantine-color-yellow-4)' : 'var(--mantine-color-blue-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
transition: 'all 200ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow =
'0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)';
}}
>
<CurrentIcon size={19} stroke={1.8} />
</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 { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
import type { ComponentType, SVGProps } from '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';
interface LanguageSwitcherProps {
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';
}
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 current = i18n.language;
const current = i18n.language || 'en';
const change = (lng: string) => {
if (lng !== current) i18n.changeLanguage(lng);
};
const currentConfig = LANGUAGE_CONFIG[current] || {
Flag: null,
nativeName: current.toUpperCase(),
shortCode: current.toUpperCase(),
};
const CurrentFlag = currentConfig.Flag;
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>
<UnstyledButton
aria-label={t('language.label')}
aria-label={t('language.label', 'Language')}
style={{
display: 'flex',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: rem(4),
gap: rem(6),
width: variant === 'icon' ? rem(38) : 'auto',
height: rem(38),
padding: variant === 'icon' ? 0 : '0 12px',
borderRadius: rem(12),
background: 'var(--mantine-color-body)',
color: 'var(--mantine-color-gray-6)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
transition: 'all 150ms ease',
color: 'var(--mantine-color-gray-7)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
transition: 'all 200ms ease',
cursor: 'pointer',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
e.currentTarget.style.transform = 'translateY(-1px)';
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) => {
e.currentTarget.style.background = 'var(--mantine-color-body)';
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
e.currentTarget.style.transform = 'translateY(0)';
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' && (
<>
<Text size="sm" fw={500}>
{t(`language.${current}`)}
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{t(`language.${current}`, currentConfig.nativeName)}
</Text>
<IconChevronDown size={14} />
</>
<IconChevronDown size={14} stroke={2} style={{ opacity: 0.7 }} />
</Group>
)}
</UnstyledButton>
</Menu.Target>
<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>
{supportedLanguages.map((lng) => (
<Menu.Item
key={lng}
onClick={() => change(lng)}
rightSection={
current === lng ? <IconCheck size={16} /> : undefined
}
style={{ borderRadius: rem(8) }}
>
{t(`language.${lng}`)}
</Menu.Item>
))}
<Menu.Label
style={{
fontWeight: 600,
fontSize: rem(11),
letterSpacing: '0.5px',
textTransform: 'uppercase',
}}
>
{t('language.label', 'Language')}
</Menu.Label>
{supportedLanguages.map((lng) => {
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>
);
}