feat: add configurable sidebar and top navigation layout modes commit

This commit is contained in:
mengstabketemaw
2026-06-19 16:17:01 +03:00
parent ddd91b6125
commit 1f27a36b90
6 changed files with 196 additions and 67 deletions

View File

@@ -26,6 +26,8 @@ import {
IconCircleCheckFilled,
IconDeviceDesktop,
IconDeviceFloppy,
IconLayoutNavbar,
IconLayoutSidebar,
IconLock,
IconMail,
IconMoon,
@@ -46,6 +48,8 @@ import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setLayoutMode } from '../../../store/preferences.slice';
import type { LayoutMode } from '../../../store/preferences.slice';
import classes from './ProfilePage.module.css';
function getInitials(name: string, fallback: string) {
@@ -72,6 +76,7 @@ export function ProfilePage() {
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const [updateTrigger] = useApiMutation<AuthUser>();
const [meTrigger] = useApiMutation<AuthUser>();
@@ -572,6 +577,48 @@ export function ProfilePage() {
<Divider />
<div>
<Title order={5}>{t('profile.layout.title')}</Title>
<Text size="sm" c="dimmed" mb="md">
{t('profile.layout.subtitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{([{ value: 'top', label: t('profile.layout.top'), icon: IconLayoutNavbar }, { value: 'sidebar', label: t('profile.layout.sidebar'), icon: IconLayoutSidebar }] as const).map(({ value, label, icon: Icon }) => {
const active = layoutMode === value;
return (
<UnstyledButton
key={value}
onClick={() => dispatch(setLayoutMode(value))}
className={`${classes.choice} ${active ? classes.choiceActive : ''}`}
p="md"
>
<Group wrap="nowrap">
<Icon
size={20}
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
{label}
</Text>
{active && (
<IconCircleCheckFilled
size={18}
color="var(--mantine-color-emaPrimary-6)"
/>
)}
</Group>
</UnstyledButton>
);
})}
</SimpleGrid>
</div>
<Divider />
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-gray-6)" />

View File

@@ -168,6 +168,12 @@ export const am: Translations = {
title: 'ባለሁለት ደረጃ ማረጋገጫ',
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
},
layout: {
title: 'አቀማመጥ',
subtitle: 'የማውጫ አቀማመጥ እንዴት እንደሚታይ ይምረጡ።',
top: 'የላይኛው ትሮች',
sidebar: 'የጎን አሞሌ',
},
notifications: {
title: 'የኢሜይል ማሳወቂያዎች',
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',

View File

@@ -166,6 +166,12 @@ export const en = {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
},
layout: {
title: 'Layout',
subtitle: 'Choose how the navigation is displayed.',
top: 'Top tabs',
sidebar: 'Sidebar',
},
notifications: {
title: 'Email notifications',
desc: 'Receive updates about your account activity by email.',

View File

@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { AppShell, rem } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { logout } from '@ema-platform/auth';
import { AppHeader } from '@ema-platform/ui';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem } from '@ema-platform/ui';
import {
IconLayoutDashboard,
IconUsers,
@@ -14,14 +15,6 @@ import {
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { Icon } from '@tabler/icons-react';
interface NavItem {
label: string;
icon: Icon;
to?: string;
soon?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
@@ -38,7 +31,9 @@ export function BackofficeLayout() {
const location = useLocation();
const dispatch = useAppDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure();
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const displayName = user?.name?.en || user?.username || '';
const initials = displayName
@@ -70,9 +65,20 @@ export function BackofficeLayout() {
}
};
const handleToggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const isSidebar = layoutMode === 'sidebar';
return (
<AppShell
header={{ height: HEADER_HEIGHT }}
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
navbar={isSidebar ? {
width: collapsed ? 72 : 264,
breakpoint: 'sm',
collapsed: { mobile: !opened },
} : undefined}
padding="lg"
>
<AppShell.Header
@@ -83,11 +89,10 @@ export function BackofficeLayout() {
flexDirection: 'column',
}}
>
{/* Top bar */}
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
<AppHeader
onToggleNav={toggleNav}
onToggleSidebar={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened}
breadcrumbs={crumbs}
onNavigate={navigate}
@@ -98,63 +103,87 @@ export function BackofficeLayout() {
/>
</div>
{/* Legacy-style tab bar — matching UM AppMenuTabs look */}
<div
{!isSidebar && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{item.label}</span>
</button>
);
})}
</div>
)}
</AppShell.Header>
{isSidebar && (
<AppShell.Navbar
p={0}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(2),
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
overflowX: 'auto',
flexShrink: 0,
overflow: 'hidden',
transition: 'width 200ms ease',
background: 'var(--mantine-color-body)',
borderRight: '1px solid var(--mantine-color-gray-2)',
}}
>
{NAV_ITEMS.map((item) => {
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
const ItemIcon = item.icon;
return (
<button
key={item.to}
onClick={() => go(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: '8px 16px',
border: 'none',
borderBottom: '2px solid',
borderBottomColor: active
? 'var(--mantine-color-blue-6)'
: 'transparent',
background: 'transparent',
color: active
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
cursor: 'pointer',
transition: 'all 150ms ease',
height: '100%',
marginBottom: -1,
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
}}
onMouseLeave={(e) => {
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
}}
>
<ItemIcon size={18} stroke={1.6} />
<span>{item.label}</span>
</button>
);
})}
</div>
</AppShell.Header>
<AppSidebar
navItems={NAV_ITEMS}
collapsed={collapsed}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={go}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
/>
</AppShell.Navbar>
)}
<AppShell.Main>
<div key={location.pathname} className="ema-page-enter">
<Outlet />

View File

@@ -9,6 +9,7 @@ import {
logout,
} from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { preferencesReducer } from './preferences.slice';
configureAuthStorage('ema-backoffice');
@@ -25,6 +26,7 @@ export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
preferences: preferencesReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,

View File

@@ -0,0 +1,39 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type LayoutMode = 'top' | 'sidebar';
interface PreferencesState {
layoutMode: LayoutMode;
}
const PREFERENCES_KEY = 'ema-backoffice-preferences';
const loadPreferences = (): PreferencesState => {
try {
const stored = localStorage.getItem(PREFERENCES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
return { layoutMode: 'top' };
};
const savePreferences = (state: PreferencesState) => {
try {
localStorage.setItem(PREFERENCES_KEY, JSON.stringify(state));
} catch {}
};
const initialState: PreferencesState = loadPreferences();
const preferencesSlice = createSlice({
name: 'preferences',
initialState,
reducers: {
setLayoutMode(state, action: PayloadAction<LayoutMode>) {
state.layoutMode = action.payload;
savePreferences(state);
},
},
});
export const { setLayoutMode } = preferencesSlice.actions;
export const preferencesReducer = preferencesSlice.reducer;