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

View File

@@ -2,7 +2,6 @@ const CACHE_NAME = "stripstream-cache-v1";
const OFFLINE_PAGE = "/offline.html";
const STATIC_ASSETS = [
"/",
"/offline.html",
"/manifest.json",
"/favicon.svg",
@@ -12,12 +11,7 @@ const STATIC_ASSETS = [
// Installation du service worker
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
});
// 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) => {
// Pour les requêtes API, on utilise "Network First" avec un timeout
if (event.request.url.includes("/api/")) {
// event.respondWith(
// Promise.race([
// fetch(event.request.clone())
// .then((response) => {
// // Ne mettre en cache que les réponses réussies
// if (response.ok) {
// const responseToCache = response.clone();
// caches.open(CACHE_NAME).then((cache) => {
// cache.put(event.request, responseToCache);
// });
// }
// return response;
// })
// .catch(() => {
// // En cas d'erreur réseau, essayer le cache
// return caches.match(event.request).then((cachedResponse) => {
// if (cachedResponse) {
// return cachedResponse;
// }
// // Si pas de cache, renvoyer une erreur appropriée
// return new Response(JSON.stringify({ error: "Hors ligne" }), {
// status: 503,
// headers: { "Content-Type": "application/json" },
// });
// });
// }),
// // Timeout après 5 secondes
// new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)).catch(
// () => {
// return caches.match(event.request).then((cachedResponse) => {
// if (cachedResponse) {
// return cachedResponse;
// }
// return new Response(JSON.stringify({ error: "Timeout" }), {
// status: 504,
// 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 });
// });
// })
// );
// Ignorer les requêtes non GET
if (event.request.method !== "GET") return;
// Ignorer les ressources webpack
if (isWebpackResource(event.request.url)) return;
// Pour les ressources statiques de Next.js et les autres requêtes
event.respondWith(
fetch(event.request)
.then((response) => {
// Mettre en cache les ressources statiques de Next.js et les pages
if (
response.ok &&
(isNextStaticResource(event.request.url) || event.request.mode === "navigate")
) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
})
.catch(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// Si c'est une navigation, renvoyer la page hors ligne
if (event.request.mode === "navigate") {
return cache.match(OFFLINE_PAGE);
}
return new Response(JSON.stringify({ error: "Hors ligne" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
})
);
});

View File

@@ -8,6 +8,8 @@ import { InstallPWA } from "../ui/InstallPWA";
import { Toaster } from "@/components/ui/toaster";
import { usePathname } from "next/navigation";
import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { registerServiceWorker } from "@/lib/registerSW";
import { NetworkStatus } from "../ui/NetworkStatus";
// Routes qui ne nécessitent pas d'authentification
const publicRoutes = ["/login", "/register"];
@@ -51,16 +53,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
useEffect(() => {
// Enregistrer le service worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then(() => {
// Succès silencieux
})
.catch((error) => {
console.error("Erreur lors de l'enregistrement du Service Worker:", error);
});
}
registerServiceWorker();
}, []);
// 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>
<InstallPWA />
<Toaster />
<NetworkStatus />
</div>
</PreferencesProvider>
</ThemeProvider>

View File

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