Compare commits

..

1 Commits

Author SHA1 Message Date
Your Name
a539ad43af feat: add PWA support with service worker and update prompt
- Updated package.json to include vite-plugin-pwa and workbox-window.
- Added icon SVGs for PWA: icon-512.svg and maskable-512.svg.
- Created a manifest.json for PWA configuration.
- Implemented PwaUpdatePrompt component to notify users of available updates.
- Enhanced CookieConsent and SiteAssistant components for better layout and responsiveness.
- Updated global CSS for safe-area insets and mobile-first enhancements.
- Registered service worker in usePwaRegistration hook for managing updates.
- Modified Vite configuration to integrate PWA features and caching strategies.
2026-04-06 08:12:32 +02:00
23 changed files with 4393 additions and 28 deletions

View File

@@ -4,11 +4,15 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="apple-touch-icon" href="/icons/icon-512.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description"
content="Free online tools for PDF, image, video, and text processing. Merge, split, compress, convert, watermark, protect & more — instantly." />
<meta name="application-name" content="Dociva" />
<meta name="apple-mobile-web-app-title" content="Dociva" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="theme-color" content="#2563eb" />
<meta name="google-site-verification" content="tx9YptvPfrvb115PeFBWpYpRhw_4CYHQXzpLKNXXV20" />
<meta name="msvalidate.01" content="65E1161EF971CA2810FE8EABB5F229B4" />

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,8 @@
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^4.0.18"
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.18",
"workbox-window": "^7.4.0"
}
}

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<path d="M160 128h112l80 80v176a16 16 0 0 1-16 16H160a16 16 0 0 1-16-16V144a16 16 0 0 1 16-16z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="16"/>
<path d="M272 128v64a16 16 0 0 0 16 16h64" stroke="#fff" stroke-width="16" fill="none"/>
<path d="M192 256h128M192 296h96M192 336h64" stroke="#93C5FD" stroke-width="14" stroke-linecap="round"/>
<text x="312" y="432" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="128" fill="#E0E7FF" opacity="0.6">D</text>
</svg>

After

Width:  |  Height:  |  Size: 868 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<!-- Maskable icon: safe zone is central 80% (409.6px), so content fits within ~51-461 -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
</defs>
<!-- Full bleed background -->
<rect width="512" height="512" fill="url(#bg)"/>
<!-- Content centered within safe zone -->
<path d="M176 144h96l72 72v160a14 14 0 0 1-14 14H176a14 14 0 0 1-14-14V158a14 14 0 0 1 14-14z" fill="rgba(255,255,255,0.15)" stroke="#fff" stroke-width="14"/>
<path d="M272 144v56a14 14 0 0 0 14 14h56" stroke="#fff" stroke-width="14" fill="none"/>
<path d="M204 264h112M204 300h84M204 336h56" stroke="#93C5FD" stroke-width="12" stroke-linecap="round"/>
<text x="296" y="416" font-family="Arial,Helvetica,sans-serif" font-weight="700" font-size="112" fill="#E0E7FF" opacity="0.6">D</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,57 @@
{
"name": "Dociva — Free Online File Tools",
"short_name": "Dociva",
"description": "30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required.",
"start_url": "/",
"id": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "any",
"scope": "/",
"categories": ["productivity", "utilities"],
"dir": "auto",
"lang": "en",
"prefer_related_applications": false,
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/maskable-512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Compress PDF",
"short_name": "Compress",
"url": "/compress-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
},
{
"name": "Merge PDF",
"short_name": "Merge",
"url": "/merge-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
},
{
"name": "Convert to PDF",
"short_name": "Convert",
"url": "/image-to-pdf",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
}
]
}

View File

@@ -34,6 +34,7 @@ const SeoRoutePage = lazy(() => import('@/pages/SeoRoutePage'));
const ComparisonPage = lazy(() => import('@/pages/ComparisonPage'));
const CookieConsent = lazy(() => import('@/components/layout/CookieConsent'));
const SiteAssistant = lazy(() => import('@/components/layout/SiteAssistant'));
const PwaUpdatePrompt = lazy(() => import('@/components/layout/PwaUpdatePrompt'));
// Tool components — derived from manifest using React.lazy
const ToolComponents = Object.fromEntries(
@@ -169,6 +170,7 @@ export default function App() {
<SiteAssistant />
</IdleLoad>
<CookieConsent />
<PwaUpdatePrompt />
</Suspense>
<Toaster
position={isRTL ? 'top-left' : 'top-right'}

View File

@@ -70,7 +70,7 @@ export default function CookieConsent() {
<div
role="dialog"
aria-label={t('cookie.title', 'Cookie Consent')}
className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6"
className="fixed inset-x-0 bottom-0 z-50 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:p-6 sm:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:flex sm:items-start sm:gap-4">
<div className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400 sm:mb-0">

View File

@@ -98,7 +98,7 @@ export default function Header() {
].join(' ');
return (
<header className="sticky top-0 z-50 border-b border-slate-200/70 bg-white/78 backdrop-blur-2xl dark:border-slate-700/60 dark:bg-slate-950/78">
<header className="sticky-header-safe sticky top-0 z-50 border-b border-slate-200/70 bg-white/78 backdrop-blur-2xl dark:border-slate-700/60 dark:bg-slate-950/78">
<div className="mx-auto flex h-20 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-8">
<Link to="/" className="group flex items-center gap-3">

View File

@@ -0,0 +1,47 @@
import { RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePwaRegistration } from '../../hooks/usePwaRegistration';
/**
* Renders a bottom-right toast when a new service-worker version is available.
* The user can choose to reload immediately or dismiss.
*/
export default function PwaUpdatePrompt() {
const { needRefresh, acceptUpdate, dismissUpdate } = usePwaRegistration();
const { t } = useTranslation();
if (!needRefresh) return null;
return (
<div
role="alert"
className="fixed bottom-4 right-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-2xl dark:border-slate-700 dark:bg-slate-800 sm:bottom-6 sm:right-6"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
<RefreshCw className="h-5 w-5" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{t('pwa.updateAvailable', 'Update available')}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{t('pwa.updateDescription', 'A new version is ready. Reload to get the latest features.')}
</p>
<button
onClick={acceptUpdate}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-primary-700 active:scale-[0.98]"
>
<RefreshCw className="h-3.5 w-3.5" />
{t('pwa.reload', 'Reload')}
</button>
</div>
<button
onClick={dismissUpdate}
className="rounded-lg p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -195,7 +195,7 @@ export default function SiteAssistant() {
};
return (
<div className="pointer-events-none fixed inset-x-4 bottom-4 z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
<div className="pointer-events-none fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-40 flex justify-end sm:bottom-6 sm:right-6 sm:left-auto">
<div className="pointer-events-auto w-full max-w-sm">
{open && (
<div className="mb-3 overflow-hidden rounded-[28px] border border-slate-200/80 bg-white/95 shadow-[0_20px_80px_rgba(15,23,42,0.16)] backdrop-blur dark:border-slate-700/80 dark:bg-slate-950/95">
@@ -225,7 +225,7 @@ export default function SiteAssistant() {
</p>
</div>
<div ref={scrollRef} className="max-h-[26rem] space-y-3 overflow-y-auto px-4 py-4">
<div ref={scrollRef} className="max-h-[50dvh] space-y-3 overflow-y-auto overscroll-contain px-4 py-4 sm:max-h-[26rem]">
{messages.length === 0 && (
<div className="rounded-3xl border border-sky-100 bg-sky-50/80 p-4 text-sm text-slate-700 dark:border-sky-900/50 dark:bg-slate-900 dark:text-slate-200">
<p className="font-medium text-slate-900 dark:text-slate-100">

View File

@@ -95,8 +95,8 @@ export default function DownloadButton({ result, onStartOver }: DownloadButtonPr
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-5 w-5" />
{t('common.download')} {result.filename}
<Download className="h-5 w-5 shrink-0" />
<span className="truncate">{t('common.download')} {result.filename}</span>
</a>
) : (
<button

View File

@@ -37,7 +37,7 @@ export default function ProgressBar({
return (
<div className="space-y-4">
{/* Main Progress Card */}
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<div className="rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200 sm:p-5 dark:bg-slate-800 dark:ring-slate-700">
<div className="flex items-center gap-3">
{isActive && (
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />

View File

@@ -111,7 +111,7 @@ export default function SharePanel({
</button>
{open && (
<div className="mt-3 w-full max-w-md rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur dark:border-slate-700 dark:bg-slate-900/95">
<div className="mt-3 w-full max-w-[calc(100vw-2rem)] rounded-3xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur sm:max-w-md dark:border-slate-700 dark:bg-slate-900/95">
<div className="rounded-2xl bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-4 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600 dark:text-sky-300">
{variant === 'result' ? t('share.resultLabel') : t('share.toolLabel')}

View File

@@ -86,7 +86,7 @@ export default function ToolSelectorModal({
aria-modal="true"
aria-labelledby="tool-selector-title"
>
<div className="modal-content flex w-full max-w-lg max-h-[90vh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<div className="modal-content flex w-full max-w-lg max-h-[90dvh] flex-col rounded-2xl bg-white p-6 shadow-2xl ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */}
<div className="mb-5 flex items-start justify-between">
<div>

View File

@@ -95,7 +95,7 @@ export default function ChatPdf() {
<textarea
value={question} onChange={(e) => setQuestion(e.target.value)}
placeholder={t('tools.chatPdf.questionPlaceholder')}
rows={3}
rows={2}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
/>
</div>

View File

@@ -118,7 +118,7 @@ export default function ImageConverter() {
<label className="mb-2 block text-sm font-medium text-slate-700">
Convert to:
</label>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
{formats.map((f) => (
<button
key={f.value}

View File

@@ -134,7 +134,7 @@ export default function ImageResize() {
{t('tools.imageResize.lockAspect')}
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div>
<label className="mb-1 block text-xs text-slate-500 dark:text-slate-400">
{t('tools.imageResize.width')}

View File

@@ -35,7 +35,7 @@ export default function PdfCompressor() {
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
</label>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3">
{qualityOptions.map((opt) => (
<button
key={opt.value}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
/**
* Lightweight hook that registers the Workbox service-worker generated by
* vite-plugin-pwa and exposes an "update available" flag so the UI can
* prompt the user to refresh.
*/
export function usePwaRegistration() {
const [needRefresh, setNeedRefresh] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const register = async () => {
try {
const { registerSW } = await import('virtual:pwa-register');
const updateSW = registerSW({
immediate: false,
onRegisteredSW(_swUrl: string, reg: ServiceWorkerRegistration | undefined) {
if (reg) setRegistration(reg);
},
onNeedRefresh() {
setNeedRefresh(true);
},
});
// Store updateSW so we can call it from acceptUpdate
(window as unknown as Record<string, unknown>).__pwaUpdateSW = updateSW;
} catch {
// SW registration failed (e.g. non-HTTPS in dev) — silently ignore
}
};
register();
}, []);
const acceptUpdate = () => {
const updateSW = (window as unknown as Record<string, unknown>).__pwaUpdateSW as
| ((reloadPage?: boolean) => Promise<void>)
| undefined;
if (updateSW) {
updateSW(true);
}
setNeedRefresh(false);
};
const dismissUpdate = () => setNeedRefresh(false);
return { needRefresh, acceptUpdate, dismissUpdate, registration };
}

View File

@@ -21,6 +21,7 @@
html {
scroll-behavior: smooth;
overscroll-behavior-y: contain;
}
body {
@@ -41,15 +42,15 @@
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600;
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 active:scale-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-primary-500 dark:hover:bg-primary-600;
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700;
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-sm font-semibold text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300 transition-all hover:bg-slate-50 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-600 dark:hover:bg-slate-700;
}
.btn-success {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600;
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed dark:bg-emerald-500 dark:hover:bg-emerald-600;
}
.card {
@@ -71,7 +72,7 @@
/* Upload zone styles */
.upload-zone {
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-8 text-center transition-colors cursor-pointer dark:border-slate-600 dark:bg-slate-800/50;
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 p-4 text-center transition-colors cursor-pointer sm:p-6 lg:p-8 dark:border-slate-600 dark:bg-slate-800/50;
}
.upload-zone:hover,
@@ -118,7 +119,7 @@
Hero Upload Zone — premium glassmorphism card for the homepage
────────────────────────────────────────────────────────────────────────── */
.hero-upload-zone {
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-10 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-6 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-10 lg:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%);
}
@@ -261,3 +262,51 @@
content-visibility: auto;
contain-intrinsic-size: 1px 2000px;
}
/* ──────────────────────────────────────────────────────────────────────────
Mobile-first enhancements
────────────────────────────────────────────────────────────────────────── */
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Guard hover-dependent styles for touch devices */
@media (hover: hover) {
.tool-card:hover {
--tw-translate-y: -0.25rem;
}
.hero-upload-zone:hover {
--tw-translate-y: -0.25rem;
}
.marketing-card:hover {
--tw-translate-y: -0.25rem;
}
}
/* Remove 300ms tap delay and prevent double-tap zoom on interactive elements */
a,
button,
input,
select,
textarea,
[role="button"] {
touch-action: manipulation;
}
/* Safe-area inset support for notched phones */
.sticky-header-safe {
padding-top: env(safe-area-inset-top);
}
.bottom-safe {
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -1,6 +1,7 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
function getAllowedHosts() {
@@ -20,7 +21,61 @@ function getAllowedHosts() {
}
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['favicon.svg', 'logo.svg', 'icons/*.svg'],
manifest: false, // use the static manifest.json in public/
workbox: {
globPatterns: ['**/*.{js,css,html,svg,woff2}'],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: false,
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-stylesheets',
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
{
urlPattern: /^https:\/\/.*\.(?:js|css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 7 },
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
test: {
globals: true,
environment: 'jsdom',