✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -10,5 +10,5 @@ module.exports = {
|
||||
rootDir: __dirname,
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js'],
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js', 'service-worker.js'],
|
||||
};
|
||||
|
||||
2
src/frontend/apps/impress/.gitignore
vendored
2
src/frontend/apps/impress/.gitignore
vendored
@@ -33,3 +33,5 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
service-worker.js
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
next-env.d.ts
|
||||
service-worker.js
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
29
src/frontend/apps/impress/public/assets/img-not-found.svg
Normal file
29
src/frontend/apps/impress/public/assets/img-not-found.svg
Normal 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="<Transparent Rectangle>"
|
||||
class="cls-1"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 796 B |
232
src/frontend/apps/impress/src/core/service-worker.ts
Normal file
232
src/frontend/apps/impress/src/core/service-worker.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
45
src/frontend/apps/impress/src/pages/offline/index.tsx
Normal file
45
src/frontend/apps/impress/src/pages/offline/index.tsx
Normal 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
Reference in New Issue
Block a user