Enhance UptimeStatusIsland component with mobile responsiveness and scroll tracking

- Added state management for mobile detection and scroll position tracking in the UptimeStatusIsland component.
- Implemented responsive design adjustments for heartbeat display based on screen size.
- Introduced visual indicators for scroll position on mobile to improve user experience.
- Updated styles for heartbeat history container to accommodate mobile layout and scrolling behavior.
This commit is contained in:
2025-06-11 22:55:58 +02:00
parent 2e8a55307d
commit a67e19a2f8

View File

@@ -96,6 +96,9 @@ export default function UptimeStatusIsland() {
const [userZone, setUserZone] = useState(null);
const [userLocale, setUserLocale] = useState(null);
const [secondsToNextUpdate, setSecondsToNextUpdate] = useState(null);
const [isMobile, setIsMobile] = useState(false);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const heartbeatContainerRefs = useRef({});
useEffect(() => {
setUserZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
@@ -260,6 +263,19 @@ export default function UptimeStatusIsland() {
}
}
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 640);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Track scroll position for left shadow on mobile heartbeat bar
const handleHeartbeatScroll = (monitorId, e) => {
if (!isMobile) return;
setShowLeftShadow(e.target.scrollLeft > 0);
};
return (
<div>
<div style={{ width: '100%', margin: '0 auto', maxWidth: 1600, padding: '24px 0 8px 0' }}>
@@ -305,21 +321,17 @@ export default function UptimeStatusIsland() {
<ul className="space-y-6">
{group.monitorList.map((monitor) => (
<li key={monitor.id} className="flex flex-col gap-2 bg-gray-50 dark:bg-slate-900 rounded-xl px-5 py-4 shadow border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-5 items-center w-full gap-2">
<div className="flex items-center min-w-0 col-span-2">
<span className={`inline-block w-3 h-3 rounded-full ${getStatusColor(monitor.validCert)} mr-2`}></span>
<span className="font-semibold text-lg text-gray-800 dark:text-gray-100 truncate">{monitor.name}</span>
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.certExpiryDaysRemaining !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium text-white ${getCertBg(monitor.certExpiryDaysRemaining)}`}
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}>
{getCertText(monitor.certExpiryDaysRemaining)}
</span>
{isMobile ? (
<>
<div className="flex items-center min-w-0 mb-2">
<span className={`inline-block w-3 h-3 rounded-full ${getStatusColor(monitor.validCert)} mr-2`}></span>
<span className="font-semibold text-lg text-gray-800 dark:text-gray-100 truncate">{monitor.name}</span>
</div>
<div className="flex flex-row items-center justify-between w-full gap-3">
{monitor.certExpiryDaysRemaining !== undefined && (
<span
ref={getBadgeRef(monitor.id, 'cert')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getCertBg(monitor.certExpiryDaysRemaining)}`}
className={`px-2 py-1 rounded-full text-xs font-medium flex items-center justify-center ${getCertBg(monitor.certExpiryDaysRemaining)}`}
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}
onClick={() => toggleBadge(monitor.id, 'cert')}
style={{ cursor: 'pointer', position: 'relative' }}
@@ -333,19 +345,11 @@ export default function UptimeStatusIsland() {
</span>
)}
</span>
</>
)}
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.avgPing !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}
title={`Avg. response: ${monitor.avgPing} ms`}>
Avg: {monitor.avgPing} ms
</span>
)}
{monitor.avgPing !== undefined && (
<span
ref={getBadgeRef(monitor.id, 'avgPing')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getAvgPingBg(monitor.avgPing)}`}
className={`px-2 py-1 rounded-full text-xs font-medium flex items-center justify-center ${getAvgPingBg(monitor.avgPing)}`}
onClick={() => toggleBadge(monitor.id, 'avgPing')}
style={{ cursor: 'pointer', position: 'relative' }}
>
@@ -356,18 +360,11 @@ export default function UptimeStatusIsland() {
</span>
)}
</span>
</>
)}
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.uptime24h !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
24h: {monitor.uptime24h.toFixed(1)}%
</span>
)}
{monitor.uptime24h !== undefined && (
<span
ref={getBadgeRef(monitor.id, 'uptime')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getUptime24hBg(monitor.uptime24h)}`}
className={`px-2 py-1 rounded-full text-xs font-medium flex items-center justify-center ${getUptime24hBg(monitor.uptime24h)}`}
onClick={() => toggleBadge(monitor.id, 'uptime')}
style={{ cursor: 'pointer', position: 'relative' }}
>
@@ -378,17 +375,122 @@ export default function UptimeStatusIsland() {
</span>
)}
</span>
</>
)}
)}
</div>
</>
) : (
<div className="grid grid-cols-5 items-center w-full gap-2">
<div className="flex items-center min-w-0 col-span-2">
<span className={`inline-block w-3 h-3 rounded-full ${getStatusColor(monitor.validCert)} mr-2`}></span>
<span className="font-semibold text-lg text-gray-800 dark:text-gray-100 truncate">{monitor.name}</span>
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.certExpiryDaysRemaining !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium text-white ${getCertBg(monitor.certExpiryDaysRemaining)}`}
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}>
{getCertText(monitor.certExpiryDaysRemaining)}
</span>
<span
ref={getBadgeRef(monitor.id, 'cert')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getCertBg(monitor.certExpiryDaysRemaining)}`}
title={monitor.certExpiryDaysRemaining < 0 ? 'Certificate expired!' : `Certificate expires in ${monitor.certExpiryDaysRemaining} days`}
onClick={() => toggleBadge(monitor.id, 'cert')}
style={{ cursor: 'pointer', position: 'relative' }}
>
<FiAward className="w-4 h-4" />
{openBadge[monitor.id]?.cert && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
{monitor.certExpiryDaysRemaining < 0
? 'Expired!'
: `Expires in ${monitor.certExpiryDaysRemaining} days`}
</span>
)}
</span>
</>
)}
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.avgPing !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getAvgPingBg(monitor.avgPing)}`}
title={`Avg. response: ${monitor.avgPing} ms`}>
Avg: {monitor.avgPing} ms
</span>
<span
ref={getBadgeRef(monitor.id, 'avgPing')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getAvgPingBg(monitor.avgPing)}`}
onClick={() => toggleBadge(monitor.id, 'avgPing')}
style={{ cursor: 'pointer', position: 'relative' }}
>
<FiActivity className="w-4 h-4" />
{openBadge[monitor.id]?.avgPing && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
{monitor.avgPing} ms
</span>
)}
</span>
</>
)}
</div>
<div className="flex flex-col items-center col-span-1 relative">
{monitor.uptime24h !== undefined && (
<>
<span className={`hidden sm:inline px-2 py-0 rounded-full text-xs font-medium ${getUptime24hBg(monitor.uptime24h)}`}>
24h: {monitor.uptime24h.toFixed(1)}%
</span>
<span
ref={getBadgeRef(monitor.id, 'uptime')}
className={`sm:hidden px-2 py-0 rounded-full text-xs font-medium flex items-center justify-center ${getUptime24hBg(monitor.uptime24h)}`}
onClick={() => toggleBadge(monitor.id, 'uptime')}
style={{ cursor: 'pointer', position: 'relative' }}
>
<FiPercent className="w-4 h-4" />
{openBadge[monitor.id]?.uptime && (
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 rounded-lg shadow-lg px-3 py-2 text-xs whitespace-nowrap border border-gray-200 dark:border-gray-700">
{monitor.uptime24h.toFixed(1)}%
</span>
)}
</span>
</>
)}
</div>
</div>
</div>
)}
{monitor.heartbeatHistory && monitor.heartbeatHistory.length > 0 && (
<div className="flex items-center w-full">
<div className="grid grid-cols-40 gap-x-0.5 bg-gray-200 dark:bg-slate-700 rounded px-2 py-1 w-full overflow-x-auto">
{Array.from({ length: 40 }).map((_, i) => {
<div
className={`heartbeat-history-container ${isMobile ? 'mobile' : 'desktop'} bg-gray-200 dark:bg-slate-700 rounded px-2 py-1 w-full`}
style={{
overflowX: isMobile ? 'auto' : 'visible',
WebkitOverflowScrolling: 'touch',
width: '100%',
maxWidth: '100vw',
minWidth: 0,
position: 'relative',
}}
ref={el => { if (el && isMobile) heartbeatContainerRefs.current[monitor.id] = el; }}
onScroll={e => handleHeartbeatScroll(monitor.id, e)}
>
{/* Left shadow overlay for mobile, only if scrolled */}
{isMobile && showLeftShadow && (
<div className="heartbeat-left-shadow" />
)}
<div
className={isMobile ? 'grid-cols-10' : 'grid-cols-40'}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${isMobile ? 10 : 40}, 1fr)`,
width: '100%',
minWidth: 0,
gap: '2px',
}}
>
{(() => {
const hbArr = monitor.heartbeatHistory ?? [];
const hb = hbArr.slice(-40)[i];
return (
const slice = isMobile ? hbArr.slice(-10) : hbArr.slice(-40);
// Show most recent first (left), so reverse the slice
const ordered = slice;
return ordered.map((hb, i) => (
<Tippy
key={i}
content={<HeartbeatPopup hb={hb} userZone={userZone} monitor={monitor} />}
@@ -402,8 +504,8 @@ export default function UptimeStatusIsland() {
style={{ cursor: 'pointer' }}
></span>
</Tippy>
);
})}
));
})()}
</div>
</div>
)}
@@ -421,6 +523,28 @@ export default function UptimeStatusIsland() {
display: grid;
grid-template-columns: repeat(40, minmax(0, 1fr));
}
.grid-cols-10 {
display: grid;
grid-template-columns: repeat(10, 1fr);
}
.heartbeat-history-container.mobile {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-width: 0;
width: 100%;
position: relative;
max-width: 100vw;
}
.heartbeat-left-shadow {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 24px;
pointer-events: none;
z-index: 10;
background: linear-gradient(to right, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.00) 100%);
}
`}</style>
</div>
);