OfflineBanner was fixed at top:0 but the rest of the page had no idea it was visible, so on mobile (and the desktop nav on wider screens) the banner sat on top of the header content. When the banner is visible it now sets --offline-banner-h on <html>; body reserves that space via padding-top, and the desktop fixed Navbar shifts its top by the same amount. When back online the var is removed and everything snaps back.
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
/**
|
|
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
|
*
|
|
* States:
|
|
* offline + N queued → amber bar "Offline — N changes queued"
|
|
* offline + 0 queued → amber bar "Offline"
|
|
* online + N pending → blue bar "Syncing N changes…"
|
|
* online + 0 pending → hidden
|
|
*/
|
|
import React, { useState, useEffect } from 'react'
|
|
import { WifiOff, RefreshCw } from 'lucide-react'
|
|
import { mutationQueue } from '../../sync/mutationQueue'
|
|
|
|
const POLL_MS = 3_000
|
|
|
|
export default function OfflineBanner(): React.ReactElement | null {
|
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
|
const [pendingCount, setPendingCount] = useState(0)
|
|
|
|
useEffect(() => {
|
|
const onOnline = () => setIsOnline(true)
|
|
const onOffline = () => setIsOnline(false)
|
|
window.addEventListener('online', onOnline)
|
|
window.addEventListener('offline', onOffline)
|
|
return () => {
|
|
window.removeEventListener('online', onOnline)
|
|
window.removeEventListener('offline', onOffline)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function poll() {
|
|
const n = await mutationQueue.pendingCount()
|
|
if (!cancelled) setPendingCount(n)
|
|
}
|
|
poll()
|
|
const id = setInterval(poll, POLL_MS)
|
|
return () => { cancelled = true; clearInterval(id) }
|
|
}, [])
|
|
|
|
const hidden = isOnline && pendingCount === 0
|
|
|
|
// When the banner is visible, reserve space at the top of the page so it
|
|
// doesn't cover the nav/header. Uses a CSS var on <html> so we can offset
|
|
// via a global `body` rule instead of rewiring every layout.
|
|
useEffect(() => {
|
|
const root = document.documentElement
|
|
if (hidden) {
|
|
root.style.removeProperty('--offline-banner-h')
|
|
} else {
|
|
// 32px for icon+text row + the top safe-area inset that the banner adds
|
|
// in its own padding. Kept in one place so it's easy to tweak.
|
|
root.style.setProperty('--offline-banner-h', 'calc(env(safe-area-inset-top, 0px) + 32px)')
|
|
}
|
|
return () => { root.style.removeProperty('--offline-banner-h') }
|
|
}, [hidden])
|
|
|
|
if (hidden) return null
|
|
|
|
const offline = !isOnline
|
|
const bg = offline ? '#92400e' : '#1e40af'
|
|
const text = '#fff'
|
|
|
|
const label = offline
|
|
? pendingCount > 0
|
|
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
|
: 'Offline'
|
|
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
|
|
|
return (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 9999,
|
|
background: bg,
|
|
color: text,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
|
paddingBottom: '6px',
|
|
paddingLeft: '16px',
|
|
paddingRight: '16px',
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{offline
|
|
? <WifiOff size={14} />
|
|
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
|
}
|
|
{label}
|
|
</div>
|
|
)
|
|
}
|