changes regarding the mui to astryx tempalate based design
This commit is contained in:
@@ -1,43 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Box,
|
||||
InputBase,
|
||||
Badge,
|
||||
Avatar,
|
||||
Typography,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Divider,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Button,
|
||||
Stack,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Grid,
|
||||
alpha,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import { Settings, LogOut, Bell, MessageSquare, Search, Truck, AlertTriangle, ArrowRight, Send, CheckCircle2 } from 'lucide-react';
|
||||
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Avatar } from '@astryxdesign/core/Avatar';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Logo from '@/components/Logo';
|
||||
import { getStaff, getHubContext, clearSession } from '@/auth/session';
|
||||
import {
|
||||
@@ -49,18 +18,16 @@ import {
|
||||
markConversationRead
|
||||
} from '@/api/hub';
|
||||
|
||||
const RED = '#C01227';
|
||||
const RED = 'var(--color-brand)';
|
||||
|
||||
// Map a notification `type` to an icon component (real API sends type, not an icon).
|
||||
const NOTIF_ICON = {
|
||||
exception: WarningAmberIcon,
|
||||
inbound: LocalShippingOutlinedIcon,
|
||||
dispatch: LocalShippingOutlinedIcon,
|
||||
warning: WarningAmberIcon,
|
||||
alert: NotificationsActiveIcon
|
||||
exception: AlertTriangle,
|
||||
inbound: Truck,
|
||||
dispatch: Truck,
|
||||
warning: AlertTriangle,
|
||||
alert: Bell
|
||||
};
|
||||
|
||||
// Build initials from a display name — falls back to a sensible default.
|
||||
const toInitials = (name) =>
|
||||
(name || '')
|
||||
.split(' ')
|
||||
@@ -70,8 +37,6 @@ const toInitials = (name) =>
|
||||
.join('')
|
||||
.toUpperCase() || 'HB';
|
||||
|
||||
// Normalise a conversation summary from GET /hub/messages. Be tolerant of a few
|
||||
// backend field-name variants so the list still renders if a key is named differently.
|
||||
const toConversation = (c) => {
|
||||
const name = c.name || c.milername || c.displayname || `Miler ${c.mileruserid ?? c.id}`;
|
||||
return {
|
||||
@@ -84,14 +49,13 @@ const toConversation = (c) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Normalise one message in a thread from GET /hub/messages/:id.
|
||||
const toMessage = (m) => ({
|
||||
sender: m.sender === 'me' ? 'me' : 'them',
|
||||
text: m.text ?? m.body ?? m.message ?? '',
|
||||
time: m.time ?? m.createdat ?? m.sentat ?? ''
|
||||
});
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
export default function Header({ isSidebarCollapsed }) {
|
||||
const navigate = useNavigate();
|
||||
const staff = getStaff();
|
||||
const hub = getHubContext();
|
||||
@@ -103,14 +67,9 @@ export default function Header({ onToggle }) {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const [account, setAccount] = useState(null);
|
||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||
const [msgAnchor, setMsgAnchor] = useState(null);
|
||||
|
||||
// Dialog State
|
||||
const [selectedNotif, setSelectedNotif] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [activeChat, setActiveChat] = useState(null); // { id, name, initials, messages: [] }
|
||||
const [activeChat, setActiveChat] = useState(null);
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [typedMessage, setTypedMessage] = useState('');
|
||||
@@ -122,7 +81,6 @@ export default function Header({ onToggle }) {
|
||||
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
// Load real notifications from the API (map `type` → an icon component).
|
||||
const loadNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await getNotifications();
|
||||
@@ -133,43 +91,39 @@ export default function Header({ onToggle }) {
|
||||
time: n.time,
|
||||
read: Boolean(n.read),
|
||||
type: n.type,
|
||||
icon: NOTIF_ICON[n.type] || NotificationsNoneIcon
|
||||
icon: NOTIF_ICON[n.type] || Bell,
|
||||
desc: n.desc,
|
||||
stats: n.stats || [],
|
||||
to: n.to,
|
||||
actionText: n.actionText
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal: leave the bell empty if it can't load.
|
||||
setNotifications([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
const t = setInterval(loadNotifications, 60000); // refresh every 60s
|
||||
const t = setInterval(loadNotifications, 60000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadNotifications]);
|
||||
|
||||
// Load conversations (one per miler at the hub) for the messages dropdown.
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const res = await getConversations();
|
||||
setConversations((res?.data || []).map(toConversation));
|
||||
} catch {
|
||||
// Non-fatal: leave the messages list empty if it can't load.
|
||||
setConversations([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
const t = setInterval(loadConversations, 60000); // refresh every 60s
|
||||
const t = setInterval(loadConversations, 60000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadConversations]);
|
||||
|
||||
const openNotif = (e) => { setNotifAnchor(e.currentTarget); loadNotifications(); };
|
||||
const closeNotif = () => setNotifAnchor(null);
|
||||
|
||||
const openMessages = (e) => { setMsgAnchor(e.currentTarget); loadConversations(); };
|
||||
|
||||
const markAllRead = async () => {
|
||||
const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id);
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
@@ -178,18 +132,14 @@ export default function Header({ onToggle }) {
|
||||
|
||||
const onNotifClick = async (n) => {
|
||||
setNotifications((prev) => prev.map((x) => (x.id === n.id ? { ...x, read: true } : x)));
|
||||
closeNotif();
|
||||
if (n.desc || n.stats) setSelectedNotif(n);
|
||||
else if (n.to) navigate(n.to);
|
||||
try {
|
||||
await markNotificationRead(n.id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Open a conversation: show the header immediately, then load the thread and
|
||||
// mark the other party's messages as read (which clears the unread badge).
|
||||
const onMessageClick = async (conv) => {
|
||||
setMsgAnchor(null);
|
||||
setTypedMessage('');
|
||||
setActiveChat({ id: conv.id, name: conv.name, initials: conv.initials, messages: [] });
|
||||
setChatLoading(true);
|
||||
@@ -207,35 +157,30 @@ export default function Header({ onToggle }) {
|
||||
setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
|
||||
}
|
||||
} catch {
|
||||
// Leave the (empty) thread open; the header still shows who it's with.
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
|
||||
const closeChat = () => { setActiveChat(null); setTypedMessage(''); };
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const text = typedMessage.trim();
|
||||
if (!text || !activeChat || sending) return;
|
||||
setSending(true);
|
||||
// Optimistically append; reconcile with the server's stored copy on success.
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
setActiveChat((c) => ({ ...c, messages: [...c.messages, { sender: 'me', text, time: timeStr }] }));
|
||||
setTypedMessage('');
|
||||
try {
|
||||
await sendMessage(activeChat.id, text);
|
||||
// Refresh the thread so the persisted message (and its real timestamp) shows.
|
||||
const res = await getConversation(activeChat.id);
|
||||
const thread = res?.data || {};
|
||||
setActiveChat((c) => c && { ...c, messages: (thread.messages || thread.chat || []).map(toMessage) });
|
||||
// Keep the dropdown preview in sync.
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === activeChat.id ? { ...c, lastMessage: text, time: timeStr } : c))
|
||||
);
|
||||
} catch {
|
||||
// On failure, drop the optimistic bubble and restore the draft to retry.
|
||||
setActiveChat((c) => c && { ...c, messages: c.messages.filter((m) => !(m.sender === 'me' && m.text === text && m.time === timeStr)) });
|
||||
setTypedMessage(text);
|
||||
} finally {
|
||||
@@ -250,336 +195,118 @@ export default function Header({ onToggle }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: '#FFFFFF',
|
||||
color: 'text.primary',
|
||||
zIndex: (t) => t.zIndex.drawer + 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
||||
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Brand wordmark — left side */}
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Logo height={22} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search */}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
'&:hover': { bgcolor: 'grey.200' }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'text.secondary' }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Scan package, check destination…"
|
||||
sx={{ fontSize: '0.875rem', flex: 1 }}
|
||||
inputProps={{ 'aria-label': 'search' }}
|
||||
<>
|
||||
<TopNav
|
||||
label="Main navigation"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
|
||||
}}
|
||||
heading={
|
||||
<TopNavHeading
|
||||
logo={<Logo compact={isSidebarCollapsed} size={isSidebarCollapsed ? 36 : 32} height={28} />}
|
||||
headingHref="/dashboard"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
endContent={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: 1, justifyContent: 'flex-end' }}>
|
||||
<DropdownMenu
|
||||
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><MessageSquare size={20} />{unreadMessages > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unreadMessages}</span>}</div> }}
|
||||
items={[
|
||||
{ label: 'Messages', type: 'label' },
|
||||
...conversations.map(m => ({
|
||||
label: m.name,
|
||||
description: m.lastMessage,
|
||||
onClick: () => onMessageClick(m)
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
button={{ variant: 'ghost', icon: <div style={{ position: 'relative' }}><Bell size={20} />{unread > 0 && <span style={{ position: 'absolute', top: -4, right: -4, background: RED, color: '#fff', fontSize: '10px', borderRadius: '10px', padding: '0 4px' }}>{unread}</span>}</div> }}
|
||||
items={[
|
||||
{ label: 'Notifications', type: 'label' },
|
||||
{ label: 'Mark all read', icon: <CheckCircle2 size={16} />, onClick: markAllRead, disabled: unread === 0 },
|
||||
{ type: 'divider' },
|
||||
...(notifications.length === 0 ? [{ label: 'No notifications', disabled: true }] : notifications.map((n, i) => ({
|
||||
label: n.title + '\u200B'.repeat(i),
|
||||
description: n.time,
|
||||
icon: n.icon ? (() => { const Icon = n.icon; return <Icon size={16} />; })() : undefined,
|
||||
onClick: () => onNotifClick(n)
|
||||
})))
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip title="Messages">
|
||||
<IconButton color="inherit" onClick={openMessages}>
|
||||
<Badge badgeContent={unreadMessages} color="error">
|
||||
<ChatIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton color="inherit" onClick={openNotif}>
|
||||
<Badge badgeContent={unread} color="error">
|
||||
<NotificationsNoneIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'ghost',
|
||||
size: 'lg',
|
||||
icon: <Avatar name={staffName} fallback={toInitials(staffName)} size="sm" />,
|
||||
label: staffName
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Settings', icon: <Settings size={16} />, onClick: () => navigate('/hub-settings') },
|
||||
{ type: 'divider' },
|
||||
{ label: 'Logout', icon: <LogOut size={16} />, onClick: handleLogout }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Box
|
||||
onClick={(e) => setAccount(e.currentTarget)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
ml: 0.5,
|
||||
cursor: 'pointer',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
borderRadius: 2,
|
||||
'&:hover': { bgcolor: 'grey.100' }
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{toInitials(staffName)}</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{staffName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{hubName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
<Menu
|
||||
anchorEl={notifAnchor}
|
||||
open={Boolean(notifAnchor)}
|
||||
onClose={closeNotif}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, width: 360, maxWidth: '90vw' } }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Notifications
|
||||
</Typography>
|
||||
<Button size="small" startIcon={<DoneAllIcon fontSize="small" />} onClick={markAllRead} disabled={unread === 0}>
|
||||
Mark all read
|
||||
</Button>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{notifications.length === 0 && (
|
||||
<MenuItem disabled>
|
||||
<ListItemText primary="No notifications" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{notifications.map((n) => {
|
||||
const Icon = n.icon;
|
||||
return (
|
||||
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
|
||||
<Icon fontSize="small" />
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={n.title}
|
||||
secondary={n.time}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: n.read ? 500 : 700 }}
|
||||
secondaryTypographyProps={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
{!n.read && <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: RED, mt: 1, ml: 0.5 }} />}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
|
||||
{/* Messages dropdown */}
|
||||
<Menu
|
||||
anchorEl={msgAnchor}
|
||||
open={Boolean(msgAnchor)}
|
||||
onClose={() => setMsgAnchor(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
|
||||
Messages
|
||||
</Typography>
|
||||
<Divider />
|
||||
{conversations.length === 0 && (
|
||||
<MenuItem disabled>
|
||||
<ListItemText primary="No messages" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{conversations.map((m) => (
|
||||
<MenuItem key={m.id} onClick={() => onMessageClick(m)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
||||
{m.initials}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={m.name}
|
||||
secondary={m.lastMessage || 'No messages yet'}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: m.unread > 0 ? 700 : 600 }}
|
||||
secondaryTypographyProps={{ fontSize: '0.8rem', noWrap: true }}
|
||||
/>
|
||||
<Stack alignItems="flex-end" sx={{ ml: 1, flexShrink: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{m.time}
|
||||
</Typography>
|
||||
{m.unread > 0 && (
|
||||
<Badge badgeContent={m.unread} color="error" sx={{ mt: 1, mr: 0.75 }} />
|
||||
)}
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Account dropdown */}
|
||||
<Menu
|
||||
anchorEl={account}
|
||||
open={Boolean(account)}
|
||||
onClose={() => setAccount(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, minWidth: 200 } }}
|
||||
>
|
||||
<MenuItem onClick={() => { setAccount(null); handleLogout(); }} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
|
||||
{/* High Fidelity Notification Detail Dialog */}
|
||||
<Dialog open={Boolean(selectedNotif)} onClose={() => setSelectedNotif(null)} fullWidth maxWidth="sm">
|
||||
{selectedNotif && (
|
||||
<>
|
||||
<DialogTitle sx={{ fontWeight: 700, bgcolor: 'grey.50', py: 2 }}>
|
||||
{selectedNotif.title}
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent sx={{ py: 3 }}>
|
||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||
{selectedNotif.desc}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'text.secondary' }}>
|
||||
Operational Details
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{selectedNotif.stats.map((stat, index) => (
|
||||
<Grid size={{ xs: 6 }} key={index}>
|
||||
<Box sx={{ p: 2, bgcolor: 'grey.50', borderRadius: 2, border: '1px solid', borderColor: 'grey.200' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontWeight: 600 }}>
|
||||
{stat.label}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mt: 0.5 }}>
|
||||
{stat.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<Divider />
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={() => setSelectedNotif(null)}>Dismiss</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowForwardIcon fontSize="small" />}
|
||||
onClick={() => {
|
||||
setSelectedNotif(null);
|
||||
navigate(selectedNotif.to);
|
||||
}}
|
||||
>
|
||||
{selectedNotif.actionText}
|
||||
{selectedNotif && (
|
||||
<dialog open style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, background: '#fff', padding: '24px', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', maxWidth: '500px', width: '100%' }}>
|
||||
<h2 style={{ margin: '0 0 16px 0', fontSize: '1.25rem' }}>{selectedNotif.title}</h2>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#475569' }}>{selectedNotif.desc}</p>
|
||||
<div style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
|
||||
{selectedNotif.stats.map((s, i) => (
|
||||
<div key={i} style={{ background: '#f8fafc', padding: '12px', borderRadius: '8px', flex: 1 }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 'bold' }}>{s.label}</div>
|
||||
<div style={{ fontSize: '1.125rem', fontWeight: 'bold', marginTop: '4px' }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<Button variant="ghost" onClick={() => setSelectedNotif(null)}>Dismiss</Button>
|
||||
{selectedNotif.actionText && (
|
||||
<Button onClick={() => { setSelectedNotif(null); navigate(selectedNotif.to); }}>
|
||||
{selectedNotif.actionText} <ArrowRight size={16} />
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
|
||||
{/* High Fidelity Chat Message Dialog */}
|
||||
<Dialog open={Boolean(activeChat)} onClose={closeChat} fullWidth maxWidth="xs">
|
||||
{activeChat && (
|
||||
<>
|
||||
<DialogTitle sx={{ fontWeight: 700, py: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar sx={{ bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, width: 34, height: 34 }}>
|
||||
{activeChat.initials}
|
||||
</Avatar>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
{activeChat.name}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<Divider />
|
||||
<DialogContent sx={{ p: 2, bgcolor: 'grey.50', height: 280, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<Stack spacing={2} sx={{ overflowY: 'auto', pr: 0.5, flexGrow: 1, mb: 2 }}>
|
||||
{chatLoading && activeChat.messages.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
||||
Loading…
|
||||
</Typography>
|
||||
)}
|
||||
{!chatLoading && activeChat.messages.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
||||
No messages yet. Say hello.
|
||||
</Typography>
|
||||
)}
|
||||
{activeChat.messages.map((msg, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
alignSelf: msg.sender === 'me' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '80%'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.sender === 'me' ? RED : '#FFFFFF',
|
||||
color: msg.sender === 'me' ? '#FFFFFF' : 'text.primary',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||
border: msg.sender === 'me' ? 'none' : '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
{msg.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 0.5,
|
||||
textAlign: msg.sender === 'me' ? 'right' : 'left'
|
||||
}}
|
||||
>
|
||||
{msg.time}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<Divider />
|
||||
<DialogActions sx={{ p: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Type your message..."
|
||||
value={typedMessage}
|
||||
onChange={(e) => setTypedMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={handleSendMessage} size="small" color="primary" disabled={sending || !typedMessage.trim()}>
|
||||
<SendIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</AppBar>
|
||||
{activeChat && (
|
||||
<dialog open style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000, background: '#fff', borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 10px 25px rgba(0,0,0,0.1)', width: '360px', padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: '12px', background: '#f8fafc' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: RED, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }}>{activeChat.initials}</div>
|
||||
<h3 style={{ margin: 0, flex: 1 }}>{activeChat.name}</h3>
|
||||
<Button variant="ghost" size="icon" onClick={closeChat}>×</Button>
|
||||
</div>
|
||||
<div style={{ height: '300px', overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', background: '#fff' }}>
|
||||
{chatLoading ? (
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>Loading...</div>
|
||||
) : activeChat.messages.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', margin: 'auto' }}>No messages yet.</div>
|
||||
) : (
|
||||
activeChat.messages.map((m, i) => (
|
||||
<div key={i} style={{ alignSelf: m.sender === 'me' ? 'flex-end' : 'flex-start', maxWidth: '80%' }}>
|
||||
<div style={{ background: m.sender === 'me' ? RED : '#f1f5f9', color: m.sender === 'me' ? '#fff' : '#0f172a', padding: '8px 12px', borderRadius: '12px', borderBottomRightRadius: m.sender === 'me' ? 0 : '12px', borderBottomLeftRadius: m.sender === 'them' ? 0 : '12px' }}>
|
||||
{m.text}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#94a3b8', textAlign: m.sender === 'me' ? 'right' : 'left', marginTop: '4px' }}>{m.time}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '12px', borderTop: '1px solid #e2e8f0', background: '#fff', display: 'flex', gap: '8px' }}>
|
||||
<TextInput style={{ flex: 1 }} value={typedMessage} onChange={(e) => setTypedMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()} placeholder="Type a message..." />
|
||||
<Button size="icon" onClick={handleSendMessage} disabled={!typedMessage.trim() || sending}><Send size={16} /></Button>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,16 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import navItems from '@/menu/navItems';
|
||||
import Logo from '@/components/Logo';
|
||||
import { isDoormileStaff } from '@/auth/session';
|
||||
|
||||
export const DRAWER_WIDTH = 240;
|
||||
export const MINI_WIDTH = 72;
|
||||
|
||||
const BRAND_RED = '#C01227';
|
||||
|
||||
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||
const Icon = item.icon;
|
||||
|
||||
const button = (
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: 1.25,
|
||||
px: open ? 1.5 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: '8px',
|
||||
color: active ? BRAND_RED : 'text.primary',
|
||||
transition: (theme) => theme.transitions.create(['background-color', 'color', 'padding'], {
|
||||
duration: theme.transitions.duration.shorter,
|
||||
}),
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: active ? BRAND_RED : 'text.secondary',
|
||||
minWidth: open ? 32 : 0,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.primary',
|
||||
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: alpha(BRAND_RED, 0.08),
|
||||
color: BRAND_RED,
|
||||
'& .MuiListItemIcon-root': { color: BRAND_RED },
|
||||
'&:hover': { bgcolor: alpha(BRAND_RED, 0.12) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{depth > 0 && !Icon ? (
|
||||
<FiberManualRecordIcon sx={{ fontSize: 6 }} />
|
||||
) : Icon ? (
|
||||
<Icon fontSize="small" />
|
||||
) : null}
|
||||
</ListItemIcon>
|
||||
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: active ? 600 : 500,
|
||||
noWrap: true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Tooltip title={item.title} placement="right" arrow disableInteractive>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
export default function Sidebar({ isCollapsed, onCollapsedChange }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const expanded = open || isMobile;
|
||||
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
||||
const doormile = isDoormileStaff();
|
||||
|
||||
// Partner accounts don't see Doormile-only groups/items (e.g. Hub Settings).
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
navItems
|
||||
@@ -110,213 +20,121 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
[doormile]
|
||||
);
|
||||
|
||||
const isActive = (url) => !!(url && location.pathname.startsWith(url));
|
||||
|
||||
// Memoize initial open state to prevent recalculation on every drawer toggle
|
||||
const initialOpen = useMemo(() => {
|
||||
return navItems
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||
.map((i) => i.id);
|
||||
}, [location.pathname]);
|
||||
|
||||
const [collapse, setCollapse] = useState(initialOpen);
|
||||
|
||||
const handleToggleCollapse = (id) => {
|
||||
setCollapse((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const go = (url) => {
|
||||
navigate(url);
|
||||
if (isMobile) onMobileClose();
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid',
|
||||
borderColor: 'divider'
|
||||
}}
|
||||
>
|
||||
{/* Top Branding Section */}
|
||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||
<Logo compact={!expanded} />
|
||||
</Toolbar>
|
||||
|
||||
{/* Navigation Scroll Area */}
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexGrow: 1,
|
||||
pb: 2,
|
||||
scrollbarWidth: 'thin',
|
||||
'&::-webkit-scrollbar': { width: 5 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'action.hover',
|
||||
borderRadius: 4,
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'action.focus',
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SideNav
|
||||
className="doormile-side-nav"
|
||||
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||
paddingBlock: '12px',
|
||||
paddingInline: '8px',
|
||||
boxSizing: 'border-box',
|
||||
'--spacing-12': '72px'
|
||||
}}
|
||||
>
|
||||
{groups.map((grp) => (
|
||||
<Box key={grp.group} sx={{ mt: 2.5 }}>
|
||||
{expanded && (
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
px: 2.5,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.6875rem',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
{grp.group}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<List disablePadding sx={{ mt: 0.5 }}>
|
||||
{grp.items.map((item) => {
|
||||
if (item.children) {
|
||||
const opened = collapse.includes(item.id);
|
||||
const childActive = item.children.some((c) => isActive(c.url));
|
||||
const Icon = item.icon;
|
||||
|
||||
const headerButton = (
|
||||
<ListItemButton
|
||||
onClick={() => expanded ? handleToggleCollapse(item.id) : go(item.children[0].url)}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: 1.25,
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: '8px',
|
||||
color: 'text.primary',
|
||||
bgcolor: childActive && !opened ? alpha(BRAND_RED, 0.04) : 'transparent',
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: childActive ? BRAND_RED : 'text.secondary',
|
||||
minWidth: expanded ? 32 : 0,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiListItemIcon-root': { color: 'text.primary' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{expanded && (
|
||||
<>
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: childActive ? 600 : 500 }}
|
||||
/>
|
||||
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||
</>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
{expanded ? headerButton : <Tooltip title={item.title} placement="right" arrow>{headerButton}</Tooltip>}
|
||||
{expanded && (
|
||||
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ mt: 0.25 }}>
|
||||
{item.children.map((c) => (
|
||||
<NavLeaf
|
||||
key={c.id}
|
||||
item={c}
|
||||
open
|
||||
depth={1}
|
||||
active={isActive(c.url)}
|
||||
onClick={() => go(c.url)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLeaf
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={expanded}
|
||||
active={isActive(item.url)}
|
||||
onClick={() => go(item.url)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
<SideNavSection key={grp.group} title={grp.group}>
|
||||
{grp.items.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.id}
|
||||
label={item.title}
|
||||
// If you migrate icons to Lucide or similar, you pass it here.
|
||||
// Currently keeping the existing MUI icon or whatever item.icon provides.
|
||||
icon={item.icon}
|
||||
href={item.url}
|
||||
isSelected={isActive(item.url)}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
))}
|
||||
</Box>
|
||||
</SideNav>
|
||||
<style>{`
|
||||
.doormile-side-nav .astryx-side-nav-section > div:last-child {
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
{/* Bottom Footer Branding */}
|
||||
{expanded && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', bgcolor: 'background.default' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 600, display: 'block', lineHeight: 1.3 }}>
|
||||
Hub Control Panel
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
Doormile Logistics · v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-inline: auto;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
||||
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
||||
background-color: rgba(10, 19, 23, 0.12) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
||||
color: #0A1317 !important;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onMobileClose}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
|
||||
position: relative;
|
||||
margin-inline: 2px;
|
||||
height: auto;
|
||||
padding-block: 12px !important;
|
||||
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: #0A1317;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
||||
background-color: rgba(10, 19, 23, 0.05) !important;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
||||
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
||||
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
|
||||
color: #0A1317 !important;
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
border: 'none',
|
||||
overflowX: 'hidden',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.standard,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{sidebarContent}
|
||||
</Drawer>
|
||||
.doormile-side-nav > div:last-child {
|
||||
padding-block: 8px !important;
|
||||
}
|
||||
|
||||
.doormile-side-nav button[aria-label*="sidebar"],
|
||||
.doormile-side-nav button[aria-label*="navigation"] {
|
||||
border-radius: 50% !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
}
|
||||
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
||||
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
||||
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box, useMediaQuery } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default function MainLayout() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const toggle = () => {
|
||||
if (isMobile) setMobileOpen((p) => !p);
|
||||
else setOpen((p) => !p);
|
||||
};
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
||||
<Header onToggle={toggle} />
|
||||
<Sidebar
|
||||
open={open}
|
||||
isMobile={isMobile}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
||||
}}
|
||||
>
|
||||
<Box sx={{ height: 64, flexShrink: 0 }} />
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'hidden',
|
||||
px: { xs: 1.5, sm: 2.5, md: 3.5 },
|
||||
py: { xs: 2, sm: 2.5, md: 3 }
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<AppShell
|
||||
variant="section"
|
||||
height="fill"
|
||||
contentPadding={0}
|
||||
topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
|
||||
sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
|
||||
mobileNav={{ breakpoint: 'lg' }}
|
||||
>
|
||||
<div className="main-content-area" style={{ minHeight: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<style>{`
|
||||
.main-content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.main-content-area {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user