mobile view

This commit is contained in:
2026-04-02 21:33:58 +05:30
parent c7095ed481
commit aa04b105d0
2 changed files with 293 additions and 173 deletions

View File

@@ -14,10 +14,13 @@ import {
CssBaseline, CssBaseline,
IconButton, IconButton,
Tooltip, Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material'; } from '@mui/material';
import TableViewIcon from '@mui/icons-material/TableView'; import TableViewIcon from '@mui/icons-material/TableView';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
@@ -41,26 +44,108 @@ export default function AdminLayout({
username, username,
resources, resources,
}: AdminLayoutProps) { }: AdminLayoutProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isCollapsed, setIsCollapsed] = React.useState(false); const [isCollapsed, setIsCollapsed] = React.useState(false);
const [mobileOpen, setMobileOpen] = React.useState(false);
const activeResourceName = location.pathname.split('/')[1] || null; const activeResourceName = location.pathname.split('/')[1] || null;
// AUTO-TOGGLE LOGIC // AUTO-TOGGLE LOGIC
React.useEffect(() => { React.useEffect(() => {
if (isMobile) {
setIsCollapsed(false); // Mobile drawer is never "mini"
setMobileOpen(false); // Close on navigation
} else {
if (location.pathname === '/' || location.pathname === '') { if (location.pathname === '/' || location.pathname === '') {
setIsCollapsed(false); setIsCollapsed(false);
} else { } else {
setIsCollapsed(true); 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); 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 ( return (
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<CssBaseline /> <CssBaseline />
@@ -69,7 +154,7 @@ export default function AdminLayout({
sx={{ sx={{
zIndex: (theme) => theme.zIndex.drawer + 1, zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: 'blur(8px)', 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', color: 'text.primary',
boxShadow: 'none', boxShadow: 'none',
borderBottom: '1px solid', borderBottom: '1px solid',
@@ -77,10 +162,21 @@ export default function AdminLayout({
}} }}
> >
<Toolbar> <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' }}> <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
Admin Panel Admin Panel
</Typography> </Typography>
<Typography variant="body1" sx={{ mr: 2, fontWeight: 500 }}> <Typography variant="body1" sx={{ mr: 2, fontWeight: 500, display: { xs: 'none', sm: 'block' } }}>
{username} {username}
</Typography> </Typography>
<Tooltip title="Logout"> <Tooltip title="Logout">
@@ -90,14 +186,33 @@ export default function AdminLayout({
</Tooltip> </Tooltip>
</Toolbar> </Toolbar>
</AppBar> </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 <Drawer
variant="permanent" variant="permanent"
sx={{ sx={{
display: { xs: 'none', md: 'block' },
width: currentWidth, width: currentWidth,
flexShrink: 0, flexShrink: 0,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
boxSizing: 'border-box', boxSizing: 'border-box',
// TRANSITION
transition: (theme) => theme.transitions.create('width', { transition: (theme) => theme.transitions.create('width', {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen, duration: theme.transitions.duration.enteringScreen,
@@ -112,73 +227,19 @@ export default function AdminLayout({
}), }),
}, },
}} }}
open
> >
<Toolbar /> {drawerContent}
<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>
</Drawer> </Drawer>
)}
</Box>
<Box <Box
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
p: 3, p: { xs: 2, md: 3 },
width: { xs: '100%', md: `calc(100% - ${currentWidth}px)` },
transition: (theme) => theme.transitions.create(['margin', 'width'], { transition: (theme) => theme.transitions.create(['margin', 'width'], {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen, duration: theme.transitions.duration.enteringScreen,

View File

@@ -6,6 +6,15 @@ import {
IconButton, IconButton,
Link, Link,
Tooltip, Tooltip,
Card,
CardContent,
CardActions,
Grid,
Menu,
MenuItem,
useMediaQuery,
useTheme,
Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
DataGrid, DataGrid,
@@ -16,6 +25,7 @@ import {
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
@@ -36,6 +46,8 @@ export default function EnhancedTable({
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
}: EnhancedTableProps) { }: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
@@ -45,87 +57,7 @@ export default function EnhancedTable({
headerName: field.label, headerName: field.label,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
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;
}
}; };
return col; return col;
}); });
@@ -157,10 +89,37 @@ export default function EnhancedTable({
return cols; return cols;
}, [config, onDelete, navigate, onNavigateToResource]); }, [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 ( return (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}> <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}> <Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label} Add {config.label}
</Button> </Button>
@@ -185,7 +144,107 @@ export default function EnhancedTable({
}, },
}} }}
pageSizeOptions={[10, 25, 50]} pageSizeOptions={[10, 25, 50]}
sx={{
border: 'none',
'& .MuiDataGrid-cell:focus': { outline: 'none' },
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
}}
/> />
</Box> </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;
}