Initial commit - frontend
This commit is contained in:
246
src/components/DeviceListSidebar.tsx
Normal file
246
src/components/DeviceListSidebar.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// src/components/DeviceListSidebar.tsx
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { List, ListItemButton, ListItemIcon, ListItemText, Typography, Box,TextField } from '@mui/material';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { parseISO, formatDistanceToNowStrict } from 'date-fns';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { DetailedDevice } from '@/types/devices';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
|
||||
|
||||
interface DeviceListSidebarProps {
|
||||
devices: DetailedDevice[];
|
||||
selectedDevice: number | null;
|
||||
onSelect: (deviceId: number) => void;
|
||||
open: boolean;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}
|
||||
|
||||
|
||||
const ONLINE_THRESHOLD_MINUTES = 1440;
|
||||
|
||||
|
||||
|
||||
|
||||
function getStatusColor(lastCheckedIn: string) {
|
||||
const lastSeen = parseISO(lastCheckedIn);
|
||||
const minutesAgo = (Date.now() - lastSeen.getTime()) / 1000 / 60;
|
||||
return minutesAgo < ONLINE_THRESHOLD_MINUTES ? 'green' : 'grey';
|
||||
}
|
||||
|
||||
function formatRelativeTime(lastCheckedIn: string) {
|
||||
try {
|
||||
return formatDistanceToNowStrict(parseISO(lastCheckedIn), { addSuffix: true });
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
const DeviceListSidebar: React.FC<DeviceListSidebarProps> = ({
|
||||
devices,
|
||||
selectedDevice,
|
||||
onSelect,
|
||||
open,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}) => {
|
||||
const [sortAsc, setSortAsc] = React.useState(true);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
|
||||
<Box
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
height: '100vh',
|
||||
width: open ? 200 : 200,
|
||||
minWidth: open ? 200 : 200,
|
||||
transition: 'width 0.3s ease',
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #ccc',
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Search devices..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<Typography sx={{ px: 2, py: 1, color: 'error.main' }}>
|
||||
No devices received (length: 0)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start', // 👈 always left-aligned
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
opacity: open ? 1 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Select a Device
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSortAsc((prev) => !prev)}
|
||||
sx={{ ml: 'auto' }} // Pushes it to the right
|
||||
>
|
||||
<Tooltip title={`Sort by Hostname (${sortAsc ? 'A-Z' : 'Z-A'})`}>
|
||||
{sortAsc ? <ArrowUpwardIcon fontSize="small" /> : <ArrowDownwardIcon fontSize="small" />}
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{open && onTogglePin && (
|
||||
<Tooltip title={pinned ? 'Unpin Sidebar' : 'Pin Sidebar'}>
|
||||
<IconButton
|
||||
size="medium"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePin?.();
|
||||
}}
|
||||
sx={(theme) => ({
|
||||
position: 'absolute',
|
||||
right: -10,
|
||||
top: 'calc(50% - 64px)', // offsets for AppBar height
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: 'background-color 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
})}
|
||||
|
||||
>
|
||||
{pinned ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
{/* Device list */}
|
||||
<List dense={!open}>
|
||||
{[...devices]
|
||||
.sort((a, b) =>
|
||||
sortAsc
|
||||
? a.hostname.localeCompare(b.hostname)
|
||||
: b.hostname.localeCompare(a.hostname)
|
||||
)
|
||||
.filter((device) =>
|
||||
device.hostname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.map((device) => (
|
||||
|
||||
<ListItemButton
|
||||
key={device.deviceId}
|
||||
selected={device.deviceId === selectedDevice}
|
||||
onClick={() => onSelect(device.deviceId)}
|
||||
sx={{
|
||||
alignItems: 'flex-start',
|
||||
minHeight: 56,
|
||||
px: open ? 2 : 1.5,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: open ? 1.5 : 1,
|
||||
mt: 0.5,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<FiberManualRecordIcon sx={{ color: getStatusColor(device.lastCheckedIn), fontSize: 12 }} />
|
||||
</ListItemIcon>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
minWidth: 0, // 🚨 ensures text truncation works
|
||||
}}
|
||||
>
|
||||
{/* Hostname: Always visible */}
|
||||
<Typography
|
||||
noWrap
|
||||
title={device.hostname}
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
{device.hostname}
|
||||
</Typography>
|
||||
|
||||
{/* Last seen: Only visible when expanded */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
opacity: open ? 1 : 0,
|
||||
maxHeight: open ? 16 : 0,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'opacity 0.2s ease, max-height 0.2s ease',
|
||||
}}
|
||||
>
|
||||
🕒 {formatRelativeTime(device.lastCheckedIn)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
export default DeviceListSidebar;
|
||||
Reference in New Issue
Block a user