mobile view
This commit is contained in:
@@ -14,10 +14,13 @@ import {
|
||||
CssBaseline,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import TableViewIcon from '@mui/icons-material/TableView';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
@@ -41,26 +44,108 @@ export default function AdminLayout({
|
||||
username,
|
||||
resources,
|
||||
}: AdminLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const activeResourceName = location.pathname.split('/')[1] || null;
|
||||
|
||||
// AUTO-TOGGLE LOGIC
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsCollapsed(false); // Mobile drawer is never "mini"
|
||||
setMobileOpen(false); // Close on navigation
|
||||
} else {
|
||||
if (location.pathname === '/' || location.pathname === '') {
|
||||
setIsCollapsed(false);
|
||||
} else {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
}
|
||||
}, [location.pathname, isMobile]);
|
||||
|
||||
const currentWidth = isCollapsed ? collapsedWidth : drawerWidth;
|
||||
const currentWidth = isMobile ? drawerWidth : (isCollapsed ? collapsedWidth : drawerWidth);
|
||||
|
||||
const handleToggle = () => {
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', p: 1 }}>
|
||||
<IconButton onClick={handleSidebarToggle}>
|
||||
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{isMobile && <Toolbar />}
|
||||
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Tooltip title={(isCollapsed && !isMobile) ? "Dashboard" : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={location.pathname === '/'}
|
||||
onClick={() => navigate('/')}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<DashboardIcon color={location.pathname === '/' ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && <ListItemText primary="Dashboard" />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List sx={{ flexGrow: 1 }}>
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<Tooltip title={(isCollapsed && !isMobile) ? res.pluralLabel : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={activeResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<TableViewIcon color={activeResourceName === res.name ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && <ListItemText primary={res.pluralLabel} />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
@@ -69,7 +154,7 @@ export default function AdminLayout({
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
backdropFilter: 'blur(8px)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)', // Adjust based on theme in real app
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
color: 'text.primary',
|
||||
boxShadow: 'none',
|
||||
borderBottom: '1px solid',
|
||||
@@ -77,10 +162,21 @@ export default function AdminLayout({
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mr: 2, fontWeight: 500 }}>
|
||||
<Typography variant="body1" sx={{ mr: 2, fontWeight: 500, display: { xs: 'none', sm: 'block' } }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<Tooltip title="Logout">
|
||||
@@ -90,14 +186,33 @@ export default function AdminLayout({
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: currentWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
boxSizing: 'border-box',
|
||||
// TRANSITION
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
@@ -112,73 +227,19 @@ export default function AdminLayout({
|
||||
}),
|
||||
},
|
||||
}}
|
||||
open
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', p: 1 }}>
|
||||
<IconButton onClick={handleToggle}>
|
||||
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Tooltip title={isCollapsed ? "Dashboard" : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={location.pathname === '/'}
|
||||
onClick={() => navigate('/')}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: isCollapsed ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: isCollapsed ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<DashboardIcon color={location.pathname === '/' ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{!isCollapsed && <ListItemText primary="Dashboard" />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List sx={{ flexGrow: 1 }}>
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<Tooltip title={isCollapsed ? res.pluralLabel : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={activeResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: isCollapsed ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: isCollapsed ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<TableViewIcon color={activeResourceName === res.name ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{!isCollapsed && <ListItemText primary={res.pluralLabel} />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
p: { xs: 2, md: 3 },
|
||||
width: { xs: '100%', md: `calc(100% - ${currentWidth}px)` },
|
||||
transition: (theme) => theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
IconButton,
|
||||
Link,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Grid,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
@@ -16,6 +25,7 @@ import {
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
|
||||
@@ -36,6 +46,8 @@ export default function EnhancedTable({
|
||||
onCreate,
|
||||
onNavigateToResource,
|
||||
}: EnhancedTableProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: GridColDef[] = React.useMemo(() => {
|
||||
@@ -45,87 +57,7 @@ export default function EnhancedTable({
|
||||
headerName: field.label,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const value = params.value;
|
||||
|
||||
// 0. Link to View if it's the Primary Key (if it's displayed)
|
||||
const isPk = key === config.primaryKey;
|
||||
|
||||
// 1. Custom Formatter
|
||||
if (field.formatter) {
|
||||
return field.formatter(value);
|
||||
}
|
||||
|
||||
// 2. Relational Link
|
||||
if (field.relation && value) {
|
||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||
const displayValue =
|
||||
typeof value === "object"
|
||||
? (
|
||||
(field?.displayField && (value as Record<string, any>)[field.displayField]) ||
|
||||
(value as any).id ||
|
||||
(value as any)._id ||
|
||||
(value as any).pk
|
||||
)
|
||||
: value;
|
||||
if (relationId) {
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToResource?.(field.relation!, String(relationId));
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Nested Object / Array Display
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
if (field.displayField) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'object' ? item[field.displayField!] : item))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
return `${value.length} items`;
|
||||
}
|
||||
|
||||
if (field.type === 'object' && value) {
|
||||
if (field.displayField && value[field.displayField]) {
|
||||
return value[field.displayField];
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// 4. Default renderings
|
||||
if (field.type === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return (value) ? new Date(value).toLocaleString() : '';
|
||||
}
|
||||
|
||||
if (isPk) {
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rowId = params.row[config.primaryKey];
|
||||
navigate(`/${config.name}/${rowId}`);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||
};
|
||||
return col;
|
||||
});
|
||||
@@ -157,10 +89,37 @@ export default function EnhancedTable({
|
||||
return cols;
|
||||
}, [config, onDelete, navigate, onNavigateToResource]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate} size="small">
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
{data.map((row) => (
|
||||
<Grid item xs={12} key={row[config.primaryKey] || Math.random()}>
|
||||
<MobileCardRow
|
||||
row={row}
|
||||
config={config}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigateToResource}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||
<Typography variant="h5">{config.pluralLabel}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||
Add {config.label}
|
||||
</Button>
|
||||
@@ -185,7 +144,107 @@ export default function EnhancedTable({
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const id = row[config.primaryKey];
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
#{id}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={handleClick}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/${id}`); }}>View</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={1}>
|
||||
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
|
||||
<Grid item xs={6} key={key}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||
</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
|
||||
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
||||
const value = params.value;
|
||||
const isPk = fieldKey === config.primaryKey;
|
||||
|
||||
if (field.formatter) return field.formatter(value);
|
||||
|
||||
if (field.relation && value) {
|
||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||
const displayValue = typeof value === "object" ?
|
||||
((field?.displayField && (value as Record<string, any>)[field.displayField]) || (value as any).id || (value as any)._id || (value as any).pk) : value;
|
||||
|
||||
if (relationId) {
|
||||
return (
|
||||
<Link component="button" variant="body2" sx={{ fontWeight: 'inherit', textAlign: 'left' }} onClick={(e) => { e.stopPropagation(); onNavigate?.(field.relation!, String(relationId)); }}>
|
||||
{displayValue}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
if (field.displayField) {
|
||||
return value.map((item) => (typeof item === 'object' ? item[field.displayField!] : item)).filter(Boolean).join(', ');
|
||||
}
|
||||
return `${value.length} items`;
|
||||
}
|
||||
|
||||
if (field.type === 'object' && value) {
|
||||
if (field.displayField && value[field.displayField]) return value[field.displayField];
|
||||
return isMobile ? 'Object' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||
|
||||
if (isPk && !isMobile) {
|
||||
return (
|
||||
<Link component="button" variant="body2" sx={{ fontWeight: 'inherit' }} onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user