✨(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) add user to a document (#52)
|
||||||
- (frontend) invite user to a document (#52)
|
- (frontend) invite user to a document (#52)
|
||||||
- (frontend) manage members (update role / list / remove) (#81)
|
- (frontend) manage members (update role / list / remove) (#81)
|
||||||
|
- ✨(frontend) offline mode (#88)
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ module.exports = {
|
|||||||
rootDir: __dirname,
|
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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
service-worker.js
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
service-worker.js
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'export',
|
output: 'export',
|
||||||
@@ -9,7 +11,7 @@ const nextConfig = {
|
|||||||
// Enables the styled-components SWC transform
|
// Enables the styled-components SWC transform
|
||||||
styledComponents: true,
|
styledComponents: true,
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config, { isServer, dev }) {
|
||||||
// Grab the existing rule that handles SVG imports
|
// Grab the existing rule that handles SVG imports
|
||||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||||
rule.test?.test?.('.svg'),
|
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.
|
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||||
fileLoaderRule.exclude = /\.svg$/i;
|
fileLoaderRule.exclude = /\.svg$/i;
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"stylelint": "16.6.1",
|
"stylelint": "16.6.1",
|
||||||
"stylelint-config-standard": "36.0.0",
|
"stylelint-config-standard": "36.0.0",
|
||||||
"stylelint-prettier": "5.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 type { AppProps } from 'next/app';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { AppProvider } from '@/core/';
|
import { AppProvider } from '@/core/';
|
||||||
@@ -15,6 +16,14 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
const getLayout = Component.getLayout ?? ((page) => page);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js').catch((err) => {
|
||||||
|
console.error('Service worker registration failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<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