(frontend) add offline mode support

Add a offline mode support from a service worker.
The service worker will cache the assets and the
visited pages to be able to work offline.
Created a fallback page for when the user is offline
and tries to access a page that is not cached.
This commit is contained in:
Anthony LC
2024-06-11 14:23:15 +02:00
committed by Anthony LC
parent 5ba35dbc1d
commit 9f6dac53d4
11 changed files with 948 additions and 41 deletions

View File

@@ -18,6 +18,7 @@ and this project adheres to
- (frontend) add user to a document (#52)
- (frontend) invite user to a document (#52)
- (frontend) manage members (update role / list / remove) (#81)
- ✨(frontend) offline mode (#88)
## Changed

View File

@@ -10,5 +10,5 @@ module.exports = {
rootDir: __dirname,
},
},
ignorePatterns: ['node_modules', '.eslintrc.js'],
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
};

View File

@@ -33,3 +33,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
service-worker.js

View File

@@ -1 +1,2 @@
next-env.d.ts
service-worker.js

View File

@@ -1,3 +1,5 @@
const { InjectManifest } = require('workbox-webpack-plugin');
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
@@ -9,7 +11,7 @@ const nextConfig = {
// Enables the styled-components SWC transform
styledComponents: true,
},
webpack(config) {
webpack(config, { isServer, dev }) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
@@ -31,6 +33,23 @@ const nextConfig = {
},
);
if (!isServer && !dev) {
config.plugins.push(
new InjectManifest({
swSrc: './src/core/service-worker.ts',
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
if (asset.name.match(/.*(static).*/)) {
return true;
}
return false;
},
],
}),
);
}
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;

View File

@@ -60,6 +60,7 @@
"stylelint": "16.6.1",
"stylelint-config-standard": "36.0.0",
"stylelint-prettier": "5.0.0",
"typescript": "*"
"typescript": "*",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
fill="currentColor"
viewBox="0 0 32 32"
id="icon"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<title>no-image</title>
<path
d="M30,3.4141,28.5859,2,2,28.5859,3.4141,30l2-2H26a2.0027,2.0027,0,0,0,2-2V5.4141ZM26,26H7.4141l7.7929-7.793,2.3788,2.3787a2,2,0,0,0,2.8284,0L22,19l4,3.9973Zm0-5.8318-2.5858-2.5859a2,2,0,0,0-2.8284,0L19,19.1682l-2.377-2.3771L26,7.4141Z"
/>
<path
d="M6,22V19l5-4.9966,1.3733,1.3733,1.4159-1.416-1.375-1.375a2,2,0,0,0-2.8284,0L6,16.1716V6H22V4H6A2.002,2.002,0,0,0,4,6V22Z"
/>
<rect
id="_Transparent_Rectangle_"
data-name="&lt;Transparent Rectangle&gt;"
class="cls-1"
width="32"
height="32"
/>
</svg>

After

Width:  |  Height:  |  Size: 796 B

View File

@@ -0,0 +1,232 @@
/// <reference lib="webworker" />
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { setCacheNameDetails } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
import { googleFontsCache, warmStrategyCache } from 'workbox-recipes';
import {
registerRoute,
setCatchHandler,
setDefaultHandler,
} from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
NetworkFirstOptions,
StrategyOptions,
} from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope & {
__WB_DISABLE_DEV_LOGS: boolean;
};
self.__WB_DISABLE_DEV_LOGS = true;
import pkg from '@/../package.json';
setCacheNameDetails({
prefix: pkg.name,
suffix: `v${pkg.version}`,
});
const getCacheNameVersion = (cacheName: string) =>
`${pkg.name}-${cacheName}-v${pkg.version}`;
/**
* In development, use NetworkFirst strategy, in production use CacheFirst strategy
* We will be able to test the application in development without having to clear the cache.
* @param url
* @param options
* @returns strategy
*/
const getStrategy = (
options?: NetworkFirstOptions | StrategyOptions,
): NetworkFirst | CacheFirst => {
const devDomains = [
'http://localhost:3000',
'https://impress.127.0.0.1.nip.io',
'https://impress-staging.beta.numerique.gouv.fr',
];
return devDomains.some((devDomain) =>
self.location.origin.includes(devDomain),
)
? new NetworkFirst(options)
: new CacheFirst(options);
};
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
self.addEventListener('install', function (event) {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', function (event) {
const cacheAllow = `v${pkg.version}`;
event.waitUntil(
// Delete old caches
caches
.keys()
.then((keys) => {
return Promise.all(
keys.map((key) => {
if (!key.includes(cacheAllow)) {
return caches.delete(key);
}
}),
);
})
.then(void self.clients.claim()),
);
});
/**
* Precache resources
*/
const FALLBACK = {
offline: '/offline/',
docs: '/docs/[id]/',
images: '/assets/img-not-found.svg',
};
const precacheResources = [
'/',
'/index.html',
'/404/',
'/accessibility/',
'/legal-notice/',
'/personal-data-cookies/',
FALLBACK.offline,
FALLBACK.images,
FALLBACK.docs,
];
const precacheStrategy = getStrategy({
cacheName: getCacheNameVersion('precache'),
plugins: [new CacheableResponsePlugin({ statuses: [0, 200, 404] })],
});
warmStrategyCache({ urls: precacheResources, strategy: precacheStrategy });
/**
* Handle requests that fail
*/
setCatchHandler(async ({ request, url, event }) => {
const devDomain = 'http://localhost:8071';
switch (true) {
case url.href.includes(`${self.location.origin}/api/`) ||
url.href.includes(`${devDomain}/api/`):
return new Response(
JSON.stringify({ error: 'Network is unavailable.' }),
{
status: 502,
statusText: 'Network is unavailable.',
headers: {
'Content-Type': 'application/json',
},
},
);
case request.destination === 'document':
if (url.pathname.match(/^\/docs\/.*\//)) {
return precacheStrategy.handle({ event, request: FALLBACK.docs });
}
return precacheStrategy.handle({ event, request: FALLBACK.offline });
case request.destination === 'image':
return precacheStrategy.handle({ event, request: FALLBACK.images });
default:
return Response.error();
}
});
/**
* Cache API requests
*/
registerRoute(
({ url }) => {
const devDomain = 'http://localhost:8071';
return (
url.href.includes(`${self.location.origin}/api/`) ||
url.href.includes(`${devDomain}/api/`)
);
},
new NetworkFirst({
cacheName: getCacheNameVersion('api'),
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
}),
);
const DAYS_EXP = 5;
/**
* Cache stategy static files images (images / svg)
*/
registerRoute(
({ request }) => request.destination === 'image',
getStrategy({
cacheName: getCacheNameVersion('images'),
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
}),
],
}),
);
/**
* Cache stategy static files fonts
*/
googleFontsCache();
registerRoute(
({ request }) => request.destination === 'font',
getStrategy({
cacheName: getCacheNameVersion('fonts'),
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * 30, // 30 days
}),
],
}),
);
/**
* Cache stategy static files (css, js, workers)
*/
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'worker',
getStrategy({
cacheName: getCacheNameVersion('static'),
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
}),
],
}),
);
/**
* Cache all other files
*/
setDefaultHandler(
getStrategy({
cacheName: getCacheNameVersion('default'),
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
}),
],
}),
);

View File

@@ -1,5 +1,6 @@
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AppProvider } from '@/core/';
@@ -15,6 +16,14 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
const { t } = useTranslation();
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').catch((err) => {
console.error('Service worker registration failed:', err);
});
}
}, []);
return (
<>
<Head>

View File

@@ -0,0 +1,45 @@
import { Button } from '@openfun/cunningham-react';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import Icon404 from '@/assets/icons/icon-404.svg';
import { Box, StyledLink, Text } from '@/components';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
padding-left: 2rem;
padding-right: 2rem;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box $align="center" $margin="auto" $height="70vh" $gap="2rem">
<Icon404 aria-label="Image 404" role="img" />
<Text $size="h2" $weight="700" $theme="greyscale" $variation="900">
{t('Offline ?!')}
</Text>
<Text as="p" $textAlign="center" $maxWidth="400px" $size="m">
{t("Can't load this page, please check your internet connection.")}
</Text>
<Box $margin={{ top: 'large' }}>
<StyledLink href="/">
<StyledButton>{t('Back to home page')}</StyledButton>
</StyledLink>
</Box>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <MainLayout>{page}</MainLayout>;
};
export default Page;

File diff suppressed because it is too large Load Diff