mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: add configurable sidebar and top navigation layout modes commit
This commit is contained in:
@@ -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)" />
|
||||
|
||||
@@ -168,6 +168,12 @@ export const am: Translations = {
|
||||
title: 'ባለሁለት ደረጃ ማረጋገጫ',
|
||||
desc: 'በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።',
|
||||
},
|
||||
layout: {
|
||||
title: 'አቀማመጥ',
|
||||
subtitle: 'የማውጫ አቀማመጥ እንዴት እንደሚታይ ይምረጡ።',
|
||||
top: 'የላይኛው ትሮች',
|
||||
sidebar: 'የጎን አሞሌ',
|
||||
},
|
||||
notifications: {
|
||||
title: 'የኢሜይል ማሳወቂያዎች',
|
||||
desc: 'ስለ መለያ እንቅስቃሴዎ በኢሜይል ዝማኔዎችን ይቀበሉ።',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
apps/backoffice/src/app/store/preferences.slice.ts
Normal file
39
apps/backoffice/src/app/store/preferences.slice.ts
Normal 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;
|
||||
Reference in New Issue
Block a user