Compare commits
6 Commits
2c8c0b5eb0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034aa69f8d | ||
|
|
060dfb3099 | ||
|
|
ad11bce308 | ||
|
|
1ffe99285d | ||
|
|
0d33462349 | ||
|
|
b8a0b85c54 |
113
public/sw.js
113
public/sw.js
@@ -1,11 +1,11 @@
|
|||||||
// StripStream Service Worker - Version 2
|
// StripStream Service Worker - Version 2
|
||||||
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
// Architecture: SWR (Stale-While-Revalidate) for all resources
|
||||||
|
|
||||||
const VERSION = "v2";
|
const VERSION = "v2.5";
|
||||||
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
const STATIC_CACHE = `stripstream-static-${VERSION}`;
|
||||||
|
const PAGES_CACHE = `stripstream-pages-${VERSION}`; // Navigation + RSC (client-side navigation)
|
||||||
const API_CACHE = `stripstream-api-${VERSION}`;
|
const API_CACHE = `stripstream-api-${VERSION}`;
|
||||||
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
const IMAGES_CACHE = `stripstream-images-${VERSION}`;
|
||||||
const RSC_CACHE = `stripstream-rsc-${VERSION}`;
|
|
||||||
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager
|
||||||
|
|
||||||
const OFFLINE_PAGE = "/offline.html";
|
const OFFLINE_PAGE = "/offline.html";
|
||||||
@@ -129,10 +129,23 @@ async function cacheFirstStrategy(request, cacheName, options = {}) {
|
|||||||
/**
|
/**
|
||||||
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
* Stale-While-Revalidate: Serve from cache immediately, update in background
|
||||||
* Used for: API calls, images
|
* Used for: API calls, images
|
||||||
|
* Respects Cache-Control: no-cache to force network-first (for refresh buttons)
|
||||||
*/
|
*/
|
||||||
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
const cached = await cache.match(request);
|
|
||||||
|
// Check if client requested no-cache (refresh button, router.refresh(), etc.)
|
||||||
|
// 1. Check Cache-Control header
|
||||||
|
const cacheControl = request.headers.get("Cache-Control");
|
||||||
|
const noCacheHeader =
|
||||||
|
cacheControl && (cacheControl.includes("no-cache") || cacheControl.includes("no-store"));
|
||||||
|
// 2. Check request.cache mode (used by Next.js router.refresh())
|
||||||
|
const noCacheMode =
|
||||||
|
request.cache === "no-cache" || request.cache === "no-store" || request.cache === "reload";
|
||||||
|
const noCache = noCacheHeader || noCacheMode;
|
||||||
|
|
||||||
|
// If no-cache, skip cached response and go network-first
|
||||||
|
const cached = noCache ? null : await cache.match(request);
|
||||||
|
|
||||||
// Start network request (don't await)
|
// Start network request (don't await)
|
||||||
const fetchPromise = fetch(request)
|
const fetchPromise = fetch(request)
|
||||||
@@ -195,39 +208,50 @@ async function staleWhileRevalidateStrategy(request, cacheName, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network-First: Try network, fallback to cache
|
* Navigation SWR: Serve from cache immediately, update in background
|
||||||
|
* Falls back to offline page if nothing cached
|
||||||
* Used for: Page navigations
|
* Used for: Page navigations
|
||||||
*/
|
*/
|
||||||
async function networkFirstStrategy(request, cacheName) {
|
async function navigationSWRStrategy(request, cacheName) {
|
||||||
const cache = await caches.open(cacheName);
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
try {
|
// Start network request in background
|
||||||
const response = await fetch(request);
|
const fetchPromise = fetch(request)
|
||||||
if (response.ok) {
|
.then(async (response) => {
|
||||||
cache.put(request, response.clone());
|
if (response.ok) {
|
||||||
}
|
await cache.put(request, response.clone());
|
||||||
return response;
|
}
|
||||||
} catch (error) {
|
return response;
|
||||||
// Network failed - try cache
|
})
|
||||||
const cached = await cache.match(request);
|
.catch(() => null);
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to serve root page for SPA client-side routing
|
// Return cached version immediately if available
|
||||||
const rootPage = await cache.match("/");
|
if (cached) {
|
||||||
if (rootPage) {
|
return cached;
|
||||||
return rootPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: offline page
|
|
||||||
const offlinePage = await cache.match(OFFLINE_PAGE);
|
|
||||||
if (offlinePage) {
|
|
||||||
return offlinePage;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cache - wait for network
|
||||||
|
const response = await fetchPromise;
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network failed and no cache - try fallbacks
|
||||||
|
// Try to serve root page for SPA client-side routing
|
||||||
|
const rootPage = await cache.match("/");
|
||||||
|
if (rootPage) {
|
||||||
|
return rootPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: offline page (in static cache)
|
||||||
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const offlinePage = await staticCache.match(OFFLINE_PAGE);
|
||||||
|
if (offlinePage) {
|
||||||
|
return offlinePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Offline and no cached page available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -262,7 +286,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Clean up old caches, but preserve BOOKS_CACHE
|
// Clean up old caches, but preserve BOOKS_CACHE
|
||||||
const cacheNames = await caches.keys();
|
const cacheNames = await caches.keys();
|
||||||
const currentCaches = [STATIC_CACHE, API_CACHE, IMAGES_CACHE, RSC_CACHE, BOOKS_CACHE];
|
const currentCaches = [STATIC_CACHE, PAGES_CACHE, API_CACHE, IMAGES_CACHE, BOOKS_CACHE];
|
||||||
|
|
||||||
const cachesToDelete = cacheNames.filter(
|
const cachesToDelete = cacheNames.filter(
|
||||||
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
(name) => name.startsWith("stripstream-") && !currentCaches.includes(name)
|
||||||
@@ -295,20 +319,23 @@ self.addEventListener("message", async (event) => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "GET_CACHE_STATS": {
|
case "GET_CACHE_STATS": {
|
||||||
try {
|
try {
|
||||||
const [staticSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
const [staticSize, pagesSize, apiSize, imagesSize, booksSize] = await Promise.all([
|
||||||
getCacheSize(STATIC_CACHE),
|
getCacheSize(STATIC_CACHE),
|
||||||
|
getCacheSize(PAGES_CACHE),
|
||||||
getCacheSize(API_CACHE),
|
getCacheSize(API_CACHE),
|
||||||
getCacheSize(IMAGES_CACHE),
|
getCacheSize(IMAGES_CACHE),
|
||||||
getCacheSize(BOOKS_CACHE),
|
getCacheSize(BOOKS_CACHE),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const staticCache = await caches.open(STATIC_CACHE);
|
const staticCache = await caches.open(STATIC_CACHE);
|
||||||
|
const pagesCache = await caches.open(PAGES_CACHE);
|
||||||
const apiCache = await caches.open(API_CACHE);
|
const apiCache = await caches.open(API_CACHE);
|
||||||
const imagesCache = await caches.open(IMAGES_CACHE);
|
const imagesCache = await caches.open(IMAGES_CACHE);
|
||||||
const booksCache = await caches.open(BOOKS_CACHE);
|
const booksCache = await caches.open(BOOKS_CACHE);
|
||||||
|
|
||||||
const [staticKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
|
const [staticKeys, pagesKeys, apiKeys, imagesKeys, booksKeys] = await Promise.all([
|
||||||
staticCache.keys(),
|
staticCache.keys(),
|
||||||
|
pagesCache.keys(),
|
||||||
apiCache.keys(),
|
apiCache.keys(),
|
||||||
imagesCache.keys(),
|
imagesCache.keys(),
|
||||||
booksCache.keys(),
|
booksCache.keys(),
|
||||||
@@ -318,10 +345,11 @@ self.addEventListener("message", async (event) => {
|
|||||||
type: "CACHE_STATS",
|
type: "CACHE_STATS",
|
||||||
payload: {
|
payload: {
|
||||||
static: { size: staticSize, entries: staticKeys.length },
|
static: { size: staticSize, entries: staticKeys.length },
|
||||||
|
pages: { size: pagesSize, entries: pagesKeys.length },
|
||||||
api: { size: apiSize, entries: apiKeys.length },
|
api: { size: apiSize, entries: apiKeys.length },
|
||||||
images: { size: imagesSize, entries: imagesKeys.length },
|
images: { size: imagesSize, entries: imagesKeys.length },
|
||||||
books: { size: booksSize, entries: booksKeys.length },
|
books: { size: booksSize, entries: booksKeys.length },
|
||||||
total: staticSize + apiSize + imagesSize + booksSize,
|
total: staticSize + pagesSize + apiSize + imagesSize + booksSize,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -341,15 +369,15 @@ self.addEventListener("message", async (event) => {
|
|||||||
if (cacheType === "all" || cacheType === "static") {
|
if (cacheType === "all" || cacheType === "static") {
|
||||||
cachesToClear.push(STATIC_CACHE);
|
cachesToClear.push(STATIC_CACHE);
|
||||||
}
|
}
|
||||||
|
if (cacheType === "all" || cacheType === "pages") {
|
||||||
|
cachesToClear.push(PAGES_CACHE);
|
||||||
|
}
|
||||||
if (cacheType === "all" || cacheType === "api") {
|
if (cacheType === "all" || cacheType === "api") {
|
||||||
cachesToClear.push(API_CACHE);
|
cachesToClear.push(API_CACHE);
|
||||||
}
|
}
|
||||||
if (cacheType === "all" || cacheType === "images") {
|
if (cacheType === "all" || cacheType === "images") {
|
||||||
cachesToClear.push(IMAGES_CACHE);
|
cachesToClear.push(IMAGES_CACHE);
|
||||||
}
|
}
|
||||||
if (cacheType === "all" || cacheType === "rsc") {
|
|
||||||
cachesToClear.push(RSC_CACHE);
|
|
||||||
}
|
|
||||||
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
// Note: BOOKS_CACHE is not cleared by default, only explicitly
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -395,6 +423,9 @@ self.addEventListener("message", async (event) => {
|
|||||||
case "static":
|
case "static":
|
||||||
cacheName = STATIC_CACHE;
|
cacheName = STATIC_CACHE;
|
||||||
break;
|
break;
|
||||||
|
case "pages":
|
||||||
|
cacheName = PAGES_CACHE;
|
||||||
|
break;
|
||||||
case "api":
|
case "api":
|
||||||
cacheName = API_CACHE;
|
cacheName = API_CACHE;
|
||||||
break;
|
break;
|
||||||
@@ -477,10 +508,10 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 2: Next.js RSC payloads → Stale-While-Revalidate
|
// Route 2: Next.js RSC payloads (client-side navigation) → SWR in PAGES_CACHE
|
||||||
if (isNextRSCRequest(request)) {
|
if (isNextRSCRequest(request)) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
staleWhileRevalidateStrategy(request, RSC_CACHE, {
|
staleWhileRevalidateStrategy(request, PAGES_CACHE, {
|
||||||
notifyOnChange: false,
|
notifyOnChange: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -515,9 +546,9 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route 6: Navigation → Network-First with SPA fallback
|
// Route 6: Navigation → SWR (cache first, revalidate in background)
|
||||||
if (request.mode === "navigate") {
|
if (request.mode === "navigate") {
|
||||||
event.respondWith(networkFirstStrategy(request, STATIC_CACHE));
|
event.respondWith(navigationSWRStrategy(request, PAGES_CACHE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,21 +17,45 @@ interface LibraryClientWrapperProps {
|
|||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
|
export function LibraryClientWrapper({
|
||||||
|
children,
|
||||||
|
libraryId,
|
||||||
|
currentPage,
|
||||||
|
unreadOnly,
|
||||||
|
search,
|
||||||
|
pageSize,
|
||||||
|
}: LibraryClientWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
// Revalider la page côté serveur
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(currentPage),
|
||||||
|
size: String(pageSize),
|
||||||
|
...(unreadOnly && { unreadOnly: "true" }),
|
||||||
|
...(search && { search }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/komga/libraries/${libraryId}/series?${params}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh library");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
router.refresh();
|
router.refresh();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Error refreshing library" };
|
return { success: false, error: "Error refreshing library" };
|
||||||
} finally {
|
} finally {
|
||||||
// Petit délai pour laisser le temps au serveur de revalider
|
setIsRefreshing(false);
|
||||||
setTimeout(() => setIsRefreshing(false), 500);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface SeriesClientWrapperProps {
|
|||||||
|
|
||||||
export function SeriesClientWrapper({
|
export function SeriesClientWrapper({
|
||||||
children,
|
children,
|
||||||
|
seriesId,
|
||||||
|
currentPage,
|
||||||
|
unreadOnly,
|
||||||
|
pageSize,
|
||||||
}: SeriesClientWrapperProps) {
|
}: SeriesClientWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
@@ -25,14 +29,30 @@ export function SeriesClientWrapper({
|
|||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
// Revalider la page côté serveur
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(currentPage),
|
||||||
|
size: String(pageSize),
|
||||||
|
...(unreadOnly && { unreadOnly: "true" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/komga/series/${seriesId}/books?${params}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh series");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
router.refresh();
|
router.refresh();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Error refreshing series" };
|
return { success: false, error: "Error refreshing series" };
|
||||||
} finally {
|
} finally {
|
||||||
// Petit délai pour laisser le temps au serveur de revalider
|
setIsRefreshing(false);
|
||||||
setTimeout(() => setIsRefreshing(false), 500);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,10 +72,7 @@ export function SeriesClientWrapper({
|
|||||||
canRefresh={pullToRefresh.canRefresh}
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
isHiding={pullToRefresh.isHiding}
|
isHiding={pullToRefresh.isHiding}
|
||||||
/>
|
/>
|
||||||
<RefreshProvider refreshSeries={handleRefresh}>
|
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
|
||||||
{children}
|
|
||||||
</RefreshProvider>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,14 +311,15 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-12 aspect-[2/3] bg-muted/80 backdrop-blur-md rounded overflow-hidden">
|
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
||||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="48px"
|
sizes="64px"
|
||||||
priority={false}
|
priority={false}
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -20,15 +20,25 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
|||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
// Revalider la page côté serveur
|
|
||||||
|
// Fetch fresh data from network with cache bypass
|
||||||
|
const response = await fetch("/api/komga/home", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to refresh home");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Next.js revalidation to update the UI
|
||||||
router.refresh();
|
router.refresh();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
|
logger.error({ err: error }, "Erreur lors du rafraîchissement:");
|
||||||
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
|
return { success: false, error: "Erreur lors du rafraîchissement de la page d'accueil" };
|
||||||
} finally {
|
} finally {
|
||||||
// Petit délai pour laisser le temps au serveur de revalider
|
setIsRefreshing(false);
|
||||||
setTimeout(() => setIsRefreshing(false), 500);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Image,
|
Image as ImageIcon,
|
||||||
FileJson,
|
FileJson,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -27,10 +23,13 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
LayoutGrid,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
static: { size: number; entries: number };
|
static: { size: number; entries: number };
|
||||||
|
pages: { size: number; entries: number };
|
||||||
api: { size: number; entries: number };
|
api: { size: number; entries: number };
|
||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
@@ -42,7 +41,7 @@ interface CacheEntry {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheType = "static" | "api" | "images" | "books";
|
type CacheType = "static" | "pages" | "api" | "images" | "books";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
@@ -195,12 +194,20 @@ function CacheItem({
|
|||||||
export function CacheSettings() {
|
export function CacheSettings() {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { isSupported, isReady, version, getCacheStats, getCacheEntries, clearCache } =
|
const {
|
||||||
useServiceWorker();
|
isSupported,
|
||||||
|
isReady,
|
||||||
|
version,
|
||||||
|
getCacheStats,
|
||||||
|
getCacheEntries,
|
||||||
|
clearCache,
|
||||||
|
reinstallServiceWorker,
|
||||||
|
} = useServiceWorker();
|
||||||
|
|
||||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [clearingCache, setClearingCache] = useState<string | null>(null);
|
const [clearingCache, setClearingCache] = useState<string | null>(null);
|
||||||
|
const [isReinstalling, setIsReinstalling] = useState(false);
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
@@ -218,7 +225,7 @@ export function CacheSettings() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}, [loadStats]);
|
}, [loadStats]);
|
||||||
|
|
||||||
const handleClearCache = async (cacheType: "all" | "static" | "api" | "images") => {
|
const handleClearCache = async (cacheType: "all" | "static" | "pages" | "api" | "images") => {
|
||||||
setClearingCache(cacheType);
|
setClearingCache(cacheType);
|
||||||
try {
|
try {
|
||||||
const success = await clearCache(cacheType);
|
const success = await clearCache(cacheType);
|
||||||
@@ -247,6 +254,28 @@ export function CacheSettings() {
|
|||||||
[getCacheEntries]
|
[getCacheEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleReinstall = async () => {
|
||||||
|
setIsReinstalling(true);
|
||||||
|
try {
|
||||||
|
const success = await reinstallServiceWorker();
|
||||||
|
if (!success) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settings.error.title"),
|
||||||
|
description: t("settings.cache.reinstallError"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If success, the page will reload automatically
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settings.error.title"),
|
||||||
|
description: t("settings.cache.reinstallError"),
|
||||||
|
});
|
||||||
|
setIsReinstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
||||||
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
||||||
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
||||||
@@ -328,6 +357,17 @@ export function CacheSettings() {
|
|||||||
isClearing={clearingCache === "static"}
|
isClearing={clearingCache === "static"}
|
||||||
onLoadEntries={handleLoadEntries}
|
onLoadEntries={handleLoadEntries}
|
||||||
/>
|
/>
|
||||||
|
<CacheItem
|
||||||
|
icon={<LayoutGrid className="h-4 w-4" />}
|
||||||
|
label={t("settings.cache.pages")}
|
||||||
|
size={stats.pages.size}
|
||||||
|
entries={stats.pages.entries}
|
||||||
|
cacheType="pages"
|
||||||
|
description={t("settings.cache.pagesDesc")}
|
||||||
|
onClear={() => handleClearCache("pages")}
|
||||||
|
isClearing={clearingCache === "pages"}
|
||||||
|
onLoadEntries={handleLoadEntries}
|
||||||
|
/>
|
||||||
<CacheItem
|
<CacheItem
|
||||||
icon={<FileJson className="h-4 w-4" />}
|
icon={<FileJson className="h-4 w-4" />}
|
||||||
label={t("settings.cache.api")}
|
label={t("settings.cache.api")}
|
||||||
@@ -340,7 +380,7 @@ export function CacheSettings() {
|
|||||||
onLoadEntries={handleLoadEntries}
|
onLoadEntries={handleLoadEntries}
|
||||||
/>
|
/>
|
||||||
<CacheItem
|
<CacheItem
|
||||||
icon={<Image className="h-4 w-4" />}
|
icon={<ImageIcon className="h-4 w-4" />}
|
||||||
label={t("settings.cache.images")}
|
label={t("settings.cache.images")}
|
||||||
size={stats.images.size}
|
size={stats.images.size}
|
||||||
entries={stats.images.entries}
|
entries={stats.images.entries}
|
||||||
@@ -365,27 +405,55 @@ export function CacheSettings() {
|
|||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<div className="text-center py-8 space-y-4">
|
||||||
{t("settings.cache.unavailable")}
|
<p className="text-muted-foreground">{t("settings.cache.unavailable")}</p>
|
||||||
</p>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReinstall}
|
||||||
|
disabled={isReinstalling}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isReinstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("settings.cache.reinstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bouton vider tout */}
|
{/* Bouton vider tout */}
|
||||||
{stats && stats.total > 0 && (
|
{stats && stats.total > 0 && (
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
variant="destructive"
|
<Button
|
||||||
className="w-full gap-2"
|
variant="destructive"
|
||||||
onClick={() => handleClearCache("all")}
|
className="w-full gap-2"
|
||||||
disabled={clearingCache !== null}
|
onClick={() => handleClearCache("all")}
|
||||||
>
|
disabled={clearingCache !== null}
|
||||||
{clearingCache === "all" ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{clearingCache === "all" ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Trash2 className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<Trash2 className="h-4 w-4" />
|
||||||
{t("settings.cache.clearAll")}
|
)}
|
||||||
</Button>
|
{t("settings.cache.clearAll")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handleReinstall}
|
||||||
|
disabled={isReinstalling}
|
||||||
|
>
|
||||||
|
{isReinstalling ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("settings.cache.reinstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { registerServiceWorker } from "@/lib/registerSW";
|
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
static: { size: number; entries: number };
|
static: { size: number; entries: number };
|
||||||
|
pages: { size: number; entries: number };
|
||||||
api: { size: number; entries: number };
|
api: { size: number; entries: number };
|
||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
@@ -23,7 +24,7 @@ interface CacheUpdate {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheType = "all" | "static" | "api" | "images" | "rsc" | "books";
|
type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
|
||||||
|
|
||||||
interface ServiceWorkerContextValue {
|
interface ServiceWorkerContextValue {
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
@@ -38,6 +39,7 @@ interface ServiceWorkerContextValue {
|
|||||||
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
clearCache: (cacheType?: CacheType) => Promise<boolean>;
|
||||||
skipWaiting: () => void;
|
skipWaiting: () => void;
|
||||||
reloadForUpdate: () => void;
|
reloadForUpdate: () => void;
|
||||||
|
reinstallServiceWorker: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
||||||
@@ -53,76 +55,113 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Handle messages from service worker
|
// Handle messages from service worker
|
||||||
const handleMessage = useCallback((event: MessageEvent) => {
|
const handleMessage = useCallback((event: MessageEvent) => {
|
||||||
const { type, payload } = event.data || {};
|
try {
|
||||||
|
// Ignore messages without proper data structure
|
||||||
|
if (!event.data || typeof event.data !== "object") return;
|
||||||
|
|
||||||
switch (type) {
|
// Only handle messages from our service worker (check for known message types)
|
||||||
case "SW_ACTIVATED":
|
const knownTypes = [
|
||||||
setIsReady(true);
|
"SW_ACTIVATED",
|
||||||
setVersion(payload?.version || null);
|
"SW_VERSION",
|
||||||
break;
|
"CACHE_UPDATED",
|
||||||
|
"CACHE_STATS",
|
||||||
|
"CACHE_STATS_ERROR",
|
||||||
|
"CACHE_CLEARED",
|
||||||
|
"CACHE_CLEAR_ERROR",
|
||||||
|
"CACHE_ENTRIES",
|
||||||
|
"CACHE_ENTRIES_ERROR",
|
||||||
|
];
|
||||||
|
|
||||||
case "SW_VERSION":
|
const type = event.data.type;
|
||||||
setVersion(payload?.version || null);
|
if (typeof type !== "string" || !knownTypes.includes(type)) return;
|
||||||
break;
|
|
||||||
|
|
||||||
case "CACHE_UPDATED":
|
const payload = event.data.payload;
|
||||||
setCacheUpdates((prev) => {
|
|
||||||
// Avoid duplicates for the same URL within 1 second
|
|
||||||
const existing = prev.find(
|
|
||||||
(u) => u.url === payload.url && Date.now() - u.timestamp < 1000
|
|
||||||
);
|
|
||||||
if (existing) return prev;
|
|
||||||
return [...prev, { url: payload.url, timestamp: payload.timestamp }];
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "CACHE_STATS":
|
switch (type) {
|
||||||
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
case "SW_ACTIVATED":
|
||||||
if (statsResolver) {
|
setIsReady(true);
|
||||||
statsResolver(payload);
|
setVersion(payload?.version || null);
|
||||||
pendingRequests.current.delete("CACHE_STATS");
|
break;
|
||||||
|
|
||||||
|
case "SW_VERSION":
|
||||||
|
setVersion(payload?.version || null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "CACHE_UPDATED": {
|
||||||
|
const url = typeof payload?.url === "string" ? payload.url : null;
|
||||||
|
const timestamp = typeof payload?.timestamp === "number" ? payload.timestamp : Date.now();
|
||||||
|
if (url) {
|
||||||
|
setCacheUpdates((prev) => {
|
||||||
|
// Avoid duplicates for the same URL within 1 second
|
||||||
|
const existing = prev.find((u) => u.url === url && Date.now() - u.timestamp < 1000);
|
||||||
|
if (existing) return prev;
|
||||||
|
return [...prev, { url, timestamp }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case "CACHE_STATS_ERROR":
|
case "CACHE_STATS":
|
||||||
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
if (statsErrorResolver) {
|
if (statsResolver) {
|
||||||
statsErrorResolver(null);
|
statsResolver(payload);
|
||||||
pendingRequests.current.delete("CACHE_STATS");
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_CLEARED":
|
case "CACHE_STATS_ERROR":
|
||||||
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
const statsErrorResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
if (clearResolver) {
|
if (statsErrorResolver) {
|
||||||
clearResolver(true);
|
statsErrorResolver(null);
|
||||||
pendingRequests.current.delete("CACHE_CLEARED");
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_CLEAR_ERROR":
|
case "CACHE_CLEARED":
|
||||||
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
const clearResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
if (clearErrorResolver) {
|
if (clearResolver) {
|
||||||
clearErrorResolver(false);
|
clearResolver(true);
|
||||||
pendingRequests.current.delete("CACHE_CLEARED");
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_ENTRIES":
|
case "CACHE_CLEAR_ERROR":
|
||||||
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
const clearErrorResolver = pendingRequests.current.get("CACHE_CLEARED");
|
||||||
if (entriesResolver) {
|
if (clearErrorResolver) {
|
||||||
entriesResolver(payload.entries);
|
clearErrorResolver(false);
|
||||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
pendingRequests.current.delete("CACHE_CLEARED");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CACHE_ENTRIES_ERROR":
|
case "CACHE_ENTRIES": {
|
||||||
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
const entriesResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||||
if (entriesErrorResolver) {
|
if (entriesResolver) {
|
||||||
entriesErrorResolver(null);
|
entriesResolver(payload?.entries || null);
|
||||||
pendingRequests.current.delete("CACHE_ENTRIES");
|
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
case "CACHE_ENTRIES_ERROR": {
|
||||||
|
const entriesErrorResolver = pendingRequests.current.get("CACHE_ENTRIES");
|
||||||
|
if (entriesErrorResolver) {
|
||||||
|
entriesErrorResolver(null);
|
||||||
|
pendingRequests.current.delete("CACHE_ENTRIES");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Ignore unknown message types
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore message handling errors to prevent app crashes
|
||||||
|
// This can happen with malformed messages or during SW reinstall
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("[SW Context] Error handling message:", error, event.data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -263,6 +302,40 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reinstallServiceWorker = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Unregister all service workers
|
||||||
|
await unregisterServiceWorker();
|
||||||
|
setIsReady(false);
|
||||||
|
setVersion(null);
|
||||||
|
|
||||||
|
// Re-register
|
||||||
|
const registration = await registerServiceWorker({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsReady(true);
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: "GET_VERSION" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
logger.error({ err: error }, "Service worker re-registration failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registration) {
|
||||||
|
// Force update check
|
||||||
|
await registration.update();
|
||||||
|
// Reload page to ensure new SW takes control
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to reinstall service worker");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServiceWorkerContext.Provider
|
<ServiceWorkerContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -278,6 +351,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
clearCache,
|
clearCache,
|
||||||
skipWaiting,
|
skipWaiting,
|
||||||
reloadForUpdate,
|
reloadForUpdate,
|
||||||
|
reinstallServiceWorker,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"imagesQuota": "{used}% of images quota used",
|
"imagesQuota": "{used}% of images quota used",
|
||||||
"static": "Static resources",
|
"static": "Static resources",
|
||||||
"staticDesc": "Next.js scripts, styles and assets",
|
"staticDesc": "Next.js scripts, styles and assets",
|
||||||
|
"pages": "Visited pages",
|
||||||
|
"pagesDesc": "Home, libraries, series and details",
|
||||||
"api": "API data",
|
"api": "API data",
|
||||||
"apiDesc": "Series, books and library metadata",
|
"apiDesc": "Series, books and library metadata",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -157,6 +159,8 @@
|
|||||||
"clearedDesc": "Cache has been cleared successfully",
|
"clearedDesc": "Cache has been cleared successfully",
|
||||||
"clearError": "Error clearing cache",
|
"clearError": "Error clearing cache",
|
||||||
"unavailable": "Cache statistics unavailable",
|
"unavailable": "Cache statistics unavailable",
|
||||||
|
"reinstall": "Reinstall Service Worker",
|
||||||
|
"reinstallError": "Error reinstalling Service Worker",
|
||||||
"entry": "entry",
|
"entry": "entry",
|
||||||
"entries": "entries",
|
"entries": "entries",
|
||||||
"loadingEntries": "Loading entries...",
|
"loadingEntries": "Loading entries...",
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"imagesQuota": "{used}% du quota images utilisé",
|
"imagesQuota": "{used}% du quota images utilisé",
|
||||||
"static": "Ressources statiques",
|
"static": "Ressources statiques",
|
||||||
"staticDesc": "Scripts, styles et assets Next.js",
|
"staticDesc": "Scripts, styles et assets Next.js",
|
||||||
|
"pages": "Pages visitées",
|
||||||
|
"pagesDesc": "Home, bibliothèques, séries et détails",
|
||||||
"api": "Données API",
|
"api": "Données API",
|
||||||
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -157,6 +159,8 @@
|
|||||||
"clearedDesc": "Le cache a été vidé avec succès",
|
"clearedDesc": "Le cache a été vidé avec succès",
|
||||||
"clearError": "Erreur lors du vidage du cache",
|
"clearError": "Erreur lors du vidage du cache",
|
||||||
"unavailable": "Statistiques du cache non disponibles",
|
"unavailable": "Statistiques du cache non disponibles",
|
||||||
|
"reinstall": "Réinstaller le Service Worker",
|
||||||
|
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
|
||||||
"entry": "entrée",
|
"entry": "entrée",
|
||||||
"entries": "entrées",
|
"entries": "entrées",
|
||||||
"loadingEntries": "Chargement des entrées...",
|
"loadingEntries": "Chargement des entrées...",
|
||||||
|
|||||||
Reference in New Issue
Block a user