246 lines
6.4 KiB
TypeScript
246 lines
6.4 KiB
TypeScript
// 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; |