L7 Offline & PWA: Sync-Seite, Offline-Ansicht, Sync-Badge, Installationshinweis, Logout-Schutz
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { configureOffline, startSyncLoop } from "@/lib/offline/outbox";
|
||||
import { SyncBadge } from "./sync-badge";
|
||||
|
||||
const SW_URL = "/sw.js";
|
||||
const SW_DEV_FLAG = "craftvia.sw";
|
||||
const UPDATE_CHECK_MS = 30 * 60_000;
|
||||
|
||||
/** In development the SW is opt-in (localStorage craftvia.sw = "1"), otherwise HMR chunks would be cached. */
|
||||
function swEnabled(): boolean {
|
||||
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false;
|
||||
if (process.env.NODE_ENV === "production") return true;
|
||||
try {
|
||||
return localStorage.getItem(SW_DEV_FLAG) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function OfflineRuntimeClient({ tenantId, userId, maxDays }: { tenantId: string; userId: string; maxDays: number }) {
|
||||
const t = useTranslations("offline.update");
|
||||
const [waiting, setWaiting] = useState<ServiceWorker | null>(null);
|
||||
const reloading = useRef(false);
|
||||
|
||||
// outbox context + sync loop
|
||||
useEffect(() => {
|
||||
let stop: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
configureOffline({ tenantId, userId }, { maxDays }).then(() => {
|
||||
if (!cancelled) stop = startSyncLoop();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [tenantId, userId, maxDays]);
|
||||
|
||||
// service worker registration + update flow
|
||||
useEffect(() => {
|
||||
if (!swEnabled()) return;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const onControllerChange = () => {
|
||||
if (!reloading.current) return;
|
||||
reloading.current = false;
|
||||
window.location.reload();
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
|
||||
navigator.serviceWorker
|
||||
.register(SW_URL, { scope: "/", updateViaCache: "none" })
|
||||
.then((reg) => {
|
||||
if (reg.waiting && navigator.serviceWorker.controller) setWaiting(reg.waiting);
|
||||
reg.addEventListener("updatefound", () => {
|
||||
const installing = reg.installing;
|
||||
installing?.addEventListener("statechange", () => {
|
||||
if (installing.state === "installed" && navigator.serviceWorker.controller) setWaiting(installing);
|
||||
});
|
||||
});
|
||||
// refresh the cached offline page with the current build and the signed-in session
|
||||
(reg.active ?? reg.installing ?? reg.waiting)?.postMessage({ type: "PRECACHE_OFFLINE" });
|
||||
timer = setInterval(() => void reg.update().catch(() => undefined), UPDATE_CHECK_MS);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SyncBadge />
|
||||
{waiting && (
|
||||
<div role="status" aria-live="polite" className="fixed inset-x-3 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-40 mx-auto flex max-w-xl items-center gap-3 rounded-xl border bg-card p-3 shadow-card">
|
||||
<RefreshCw className="size-5 shrink-0 text-primary" aria-hidden />
|
||||
<p className="flex-1 text-[14px] font-semibold">{t("available")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-12 items-center rounded-xl bg-cta px-4 text-[15px] font-semibold text-cta-foreground"
|
||||
onClick={() => {
|
||||
reloading.current = true;
|
||||
waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
}}
|
||||
>
|
||||
{t("reload")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user