feat: offline pages mode

This commit is contained in:
Julien Froidefond
2025-02-21 09:31:23 +01:00
parent b62b44eab9
commit 8c13021bfb
8 changed files with 136 additions and 116 deletions

View File

@@ -34,6 +34,11 @@
color: #94a3b8; color: #94a3b8;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
}
button { button {
background-color: #4f46e5; background-color: #4f46e5;
color: white; color: white;
@@ -47,6 +52,12 @@
button:hover { button:hover {
background-color: #4338ca; background-color: #4338ca;
} }
button.secondary {
background-color: #475569;
}
button.secondary:hover {
background-color: #334155;
}
</style> </style>
</head> </head>
<body> <body>
@@ -56,7 +67,10 @@
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de
StripStream peuvent ne pas être disponibles en mode hors ligne. StripStream peuvent ne pas être disponibles en mode hors ligne.
</p> </p>
<button onclick="window.location.reload()">Réessayer</button> <div class="buttons">
<button class="secondary" onclick="window.history.back()">Retour</button>
<button onclick="window.location.reload()">Réessayer</button>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,6 @@ const CACHE_NAME = "stripstream-cache-v1";
const OFFLINE_PAGE = "/offline.html"; const OFFLINE_PAGE = "/offline.html";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/",
"/offline.html", "/offline.html",
"/manifest.json", "/manifest.json",
"/favicon.svg", "/favicon.svg",
@@ -12,12 +11,7 @@ const STATIC_ASSETS = [
// Installation du service worker // Installation du service worker
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
}); });
// Activation et nettoyage des anciens caches // Activation et nettoyage des anciens caches
@@ -29,79 +23,64 @@ self.addEventListener("activate", (event) => {
); );
}) })
); );
self.clients.claim();
}); });
// Stratégie de cache différente selon le type de requête // Fonction pour vérifier si c'est une ressource webpack
const isWebpackResource = (url) => {
return (
url.includes("/_next/webpack") ||
url.includes("webpack-hmr") ||
url.includes("webpack.js") ||
url.includes("webpack-runtime") ||
url.includes("hot-update")
);
};
// Fonction pour vérifier si c'est une ressource statique de Next.js
const isNextStaticResource = (url) => {
return url.includes("/_next/static") && !isWebpackResource(url);
};
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
// Pour les requêtes API, on utilise "Network First" avec un timeout // Ignorer les requêtes non GET
if (event.request.url.includes("/api/")) { if (event.request.method !== "GET") return;
// event.respondWith(
// Promise.race([ // Ignorer les ressources webpack
// fetch(event.request.clone()) if (isWebpackResource(event.request.url)) return;
// .then((response) => {
// // Ne mettre en cache que les réponses réussies // Pour les ressources statiques de Next.js et les autres requêtes
// if (response.ok) { event.respondWith(
// const responseToCache = response.clone(); fetch(event.request)
// caches.open(CACHE_NAME).then((cache) => { .then((response) => {
// cache.put(event.request, responseToCache); // Mettre en cache les ressources statiques de Next.js et les pages
// }); if (
// } response.ok &&
// return response; (isNextStaticResource(event.request.url) || event.request.mode === "navigate")
// }) ) {
// .catch(() => { const responseToCache = response.clone();
// // En cas d'erreur réseau, essayer le cache caches.open(CACHE_NAME).then((cache) => {
// return caches.match(event.request).then((cachedResponse) => { cache.put(event.request, responseToCache);
// if (cachedResponse) { });
// return cachedResponse; }
// } return response;
// // Si pas de cache, renvoyer une erreur appropriée })
// return new Response(JSON.stringify({ error: "Hors ligne" }), { .catch(async () => {
// status: 503, const cache = await caches.open(CACHE_NAME);
// headers: { "Content-Type": "application/json" }, const cachedResponse = await cache.match(event.request);
// });
// }); if (cachedResponse) {
// }), return cachedResponse;
// // Timeout après 5 secondes }
// new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)).catch(
// () => { // Si c'est une navigation, renvoyer la page hors ligne
// return caches.match(event.request).then((cachedResponse) => { if (event.request.mode === "navigate") {
// if (cachedResponse) { return cache.match(OFFLINE_PAGE);
// return cachedResponse; }
// }
// return new Response(JSON.stringify({ error: "Timeout" }), { return new Response(JSON.stringify({ error: "Hors ligne" }), {
// status: 504, status: 503,
// headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
// }); });
// }); })
// } );
// ),
// ])
// );
} else {
// Pour les autres ressources, on garde la stratégie "Cache First"
// event.respondWith(
// caches.match(event.request).then((cachedResponse) => {
// if (cachedResponse) {
// return cachedResponse;
// }
// return fetch(event.request)
// .then((response) => {
// // Mettre en cache la nouvelle réponse
// const responseToCache = response.clone();
// caches.open(CACHE_NAME).then((cache) => {
// cache.put(event.request, responseToCache);
// });
// return response;
// })
// .catch(() => {
// // Si la requête échoue et que c'est une page, renvoyer la page hors ligne
// if (event.request.mode === "navigate") {
// return caches.match(OFFLINE_PAGE);
// }
// return new Response("Hors ligne", { status: 503 });
// });
// })
// );
}
}); });

View File

@@ -8,6 +8,8 @@ import { InstallPWA } from "../ui/InstallPWA";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { PreferencesProvider } from "@/contexts/PreferencesContext"; import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus";
// Routes qui ne nécessitent pas d'authentification // Routes qui ne nécessitent pas d'authentification
const publicRoutes = ["/login", "/register"]; const publicRoutes = ["/login", "/register"];
@@ -51,16 +53,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
useEffect(() => { useEffect(() => {
// Enregistrer le service worker // Enregistrer le service worker
if ("serviceWorker" in navigator) { registerServiceWorker();
navigator.serviceWorker
.register("/sw.js")
.then(() => {
// Succès silencieux
})
.catch((error) => {
console.error("Erreur lors de l'enregistrement du Service Worker:", error);
});
}
}, []); }, []);
// Ne pas afficher le header et la sidebar sur les routes publiques // Ne pas afficher le header et la sidebar sur les routes publiques
@@ -75,6 +68,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
<main className={`${!isPublicRoute ? "container pt-4 md:pt-8" : ""}`}>{children}</main> <main className={`${!isPublicRoute ? "container pt-4 md:pt-8" : ""}`}>{children}</main>
<InstallPWA /> <InstallPWA />
<Toaster /> <Toaster />
<NetworkStatus />
</div> </div>
</PreferencesProvider> </PreferencesProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -106,7 +106,7 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
return ( return (
<button <button
ref={(node) => { ref={(node) => {
thumbnailRef.current = node; // thumbnailRef.current = node;
if (typeof ref === "function") { if (typeof ref === "function") {
ref(node); ref(node);
} else if (ref) { } else if (ref) {

View File

@@ -0,0 +1,17 @@
"use client";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { WifiOff } from "lucide-react";
export function NetworkStatus() {
const isOnline = useNetworkStatus();
if (isOnline) return null;
return (
<div className="fixed bottom-4 left-4 z-[100] flex items-center gap-2 rounded-lg bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow-lg">
<WifiOff className="h-4 w-4" />
<span>Hors ligne</span>
</div>
);
}

View File

@@ -1,25 +0,0 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,29 @@
"use client";
import { useState, useEffect } from "react";
export const useNetworkStatus = () => {
const [isOnline, setIsOnline] = useState<boolean>(true);
useEffect(() => {
// Fonction pour mettre à jour l'état
const updateOnlineStatus = () => {
setIsOnline(navigator.onLine);
};
// État initial
setIsOnline(navigator.onLine);
// Écouter les changements d'état de la connexion
window.addEventListener("online", updateOnlineStatus);
window.addEventListener("offline", updateOnlineStatus);
// Nettoyage
return () => {
window.removeEventListener("online", updateOnlineStatus);
window.removeEventListener("offline", updateOnlineStatus);
};
}, []);
return isOnline;
};

12
src/lib/registerSW.ts Normal file
View File

@@ -0,0 +1,12 @@
export const registerServiceWorker = async () => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registered with scope:", registration.scope);
} catch (error) {
console.error("Service Worker registration failed:", error);
}
};