Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Move mail, compose, contacts, and accounts off mocks onto REST + WS. Add client, auth store, IDB-backed query cache, offline queue, and sync bar; hybrid Zustand for UI-only state. Settings still local until backend has preferences API.
95 lines
2.6 KiB
TypeScript
95 lines
2.6 KiB
TypeScript
"use client"
|
|
|
|
import { useNetworkStatus } from "@/lib/api/use-network-status"
|
|
import { useEffect, useState, useCallback } from "react"
|
|
import { Icon } from "@iconify/react"
|
|
import { getPendingCount, flush } from "@/lib/api/offline-queue"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type SyncState = "idle" | "offline" | "syncing"
|
|
|
|
export function SyncStatusBar() {
|
|
const { isOnline } = useNetworkStatus()
|
|
const [syncState, setSyncState] = useState<SyncState>("idle")
|
|
const [pendingCount, setPendingCount] = useState(0)
|
|
|
|
const refreshCount = useCallback(async () => {
|
|
const count = await getPendingCount()
|
|
setPendingCount(count)
|
|
return count
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isOnline) {
|
|
setSyncState("offline")
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
const syncOnReconnect = async () => {
|
|
const count = await refreshCount()
|
|
if (cancelled) return
|
|
|
|
if (count > 0) {
|
|
setSyncState("syncing")
|
|
await flush()
|
|
if (cancelled) return
|
|
await refreshCount()
|
|
setSyncState("idle")
|
|
} else {
|
|
setSyncState("idle")
|
|
}
|
|
}
|
|
|
|
syncOnReconnect()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isOnline, refreshCount])
|
|
|
|
useEffect(() => {
|
|
if (syncState !== "offline") return
|
|
const interval = setInterval(() => refreshCount(), 2000)
|
|
return () => clearInterval(interval)
|
|
}, [syncState, refreshCount])
|
|
|
|
const visible = syncState !== "idle"
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden transition-all duration-300 ease-in-out",
|
|
visible ? "max-h-8 opacity-100" : "max-h-0 opacity-0"
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-8 items-center justify-center gap-2 px-3 text-xs font-medium",
|
|
syncState === "offline" &&
|
|
"bg-amber-50 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200",
|
|
syncState === "syncing" &&
|
|
"bg-blue-50 text-blue-800 dark:bg-blue-950/50 dark:text-blue-200"
|
|
)}
|
|
>
|
|
{syncState === "offline" && (
|
|
<>
|
|
<Icon icon="mdi:wifi-off" className="size-3.5" />
|
|
<span>Offline — changes will sync when reconnected</span>
|
|
{pendingCount > 0 && (
|
|
<span className="rounded-full bg-amber-200/60 px-1.5 py-0.5 text-[10px] dark:bg-amber-800/40">
|
|
{pendingCount} pending
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{syncState === "syncing" && (
|
|
<>
|
|
<Icon icon="mdi:sync" className="size-3.5 animate-spin" />
|
|
<span>Syncing {pendingCount} changes…</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|