Add update functionality and typescript support

This commit is contained in:
Kevin Kandlbinder 2022-05-02 19:22:24 +02:00
parent 1d5261f058
commit 8ef979d36a
10 changed files with 3092 additions and 3507 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# certs
/*.pem

View file

@ -7,11 +7,14 @@
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.31",
"@types/react": "^18.0.8",
"@types/react-dom": "^18.0.3",
"i18next": "^20.2.1", "i18next": "^20.2.1",
"i18next-browser-languagedetector": "^6.1.0", "i18next-browser-languagedetector": "^6.1.0",
"i18next-http-backend": "^1.2.1", "i18next-http-backend": "^1.2.1",
"lucide-react": "^0.15.16", "lucide-react": "^0.15.16",
"node-sass": "^5.0.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
@ -19,13 +22,16 @@
"react-prerendered-component": "^1.2.4", "react-prerendered-component": "^1.2.4",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"sass": "^1.51.0",
"typescript": "^4.6.4",
"web-vitals": "^1.0.1" "web-vitals": "^1.0.1"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"buildServe": "yarn build && serve -s build --ssl-cert ./192.168.1.150.pem --ssl-key ./192.168.1.150-key.pem"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@ -44,5 +50,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"serve": "^13.0.2"
} }
} }

View file

@ -1,7 +1,7 @@
@import "../common"; @import "../common";
@mixin boxMessageColor($baseColor) { @mixin boxMessageColor($baseColor) {
$bgColor: adjust-color($baseColor, null, null, null, null, null, 30); $bgColor: adjust-color($baseColor, $lightness: 30);
background-color: $bgColor; background-color: $bgColor;
.icon > svg { .icon > svg {
@ -10,11 +10,11 @@
@media(prefers-color-scheme: dark) { @media(prefers-color-scheme: dark) {
$bgColor: adjust-color($baseColor, null, null, null, null, null, -35); $bgColor: adjust-color($baseColor, $lightness: -35);
background-color: $bgColor; background-color: $bgColor;
.icon > svg { .icon > svg {
color: adjust-color($baseColor, null, null, null, null, null, 5); color: adjust-color($baseColor, $lightness: 5);
} }
} }
} }

View file

@ -4,13 +4,15 @@ import { Link } from "react-router-dom";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as styles from "./Navigation.module.scss"; import * as styles from "./Navigation.module.scss";
import { Globe } from "lucide-react"; import { Globe, RefreshCw } from "lucide-react";
import LanguageChooser from "./LanguageChooser"; import LanguageChooser from "./LanguageChooser";
import ServiceWorkerAPI from "../services/serviceWorkers"
const Navigation = () => { const Navigation = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [langChooserActive, setLangChooserActive] = useState(false); const [langChooserActive, setLangChooserActive] = useState(false);
const updateAvailable = ServiceWorkerAPI.useUpdatePending()
return ( return (
<header className={styles.navigation}> <header className={styles.navigation}>
@ -20,6 +22,8 @@ const Navigation = () => {
<Link to={"/tools"}>{t("site.navigation.tools")}</Link> <Link to={"/tools"}>{t("site.navigation.tools")}</Link>
<Link to={"/about"}>{t("site.navigation.about")}</Link> <Link to={"/about"}>{t("site.navigation.about")}</Link>
<Link to={"#"} onClick={() => {setLangChooserActive(true)}} title="Change Language"><Globe/></Link> <Link to={"#"} onClick={() => {setLangChooserActive(true)}} title="Change Language"><Globe/></Link>
{updateAvailable && <Link to={"#"} onClick={() => {ServiceWorkerAPI.forceUpdate()}} title="Update Available"><RefreshCw /></Link>}
<LanguageChooser active={langChooserActive} onDone={() => {setLangChooserActive(false)}} /> <LanguageChooser active={langChooserActive} onDone={() => {setLangChooserActive(false)}} />
</nav> </nav>
</header> </header>

1
src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -1,113 +0,0 @@
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import {CacheableResponsePlugin} from 'workbox-cacheable-response';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import {setCacheNameDetails} from 'workbox-core';
import {BackgroundSyncPlugin} from 'workbox-background-sync';
setCacheNameDetails({
prefix: 'kevins-toolbox',
suffix: 'v1',
precache: 'precache',
runtime: 'runtime'
});
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
/*if (request.mode !== 'navigate') {
return false;
}*/ // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
} // Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
registerRoute(
({request}) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
}),
],
}),
);
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('translation.json'),
new StaleWhileRevalidate({
cacheName: 'translations',
plugins: [
new ExpirationPlugin({ maxEntries: 5 }),
],
})
);
registerRoute(
({url}) => url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts',
plugins: [
new ExpirationPlugin({maxEntries: 20}),
],
}),
);
registerRoute(
({request}) => request.destination === 'script' ||
request.destination === 'style',
new StaleWhileRevalidate({
plugins: [
new BackgroundSyncPlugin()
]
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

100
src/service-worker.ts Normal file
View file

@ -0,0 +1,100 @@
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import {CacheableResponsePlugin} from 'workbox-cacheable-response';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import {setCacheNameDetails} from 'workbox-core';
import {BackgroundSyncPlugin} from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
setCacheNameDetails({
prefix: 'kevins-toolbox',
suffix: 'v1',
precache: 'precache',
runtime: 'runtime'
});
clientsClaim();
precacheAndRoute(self.__WB_MANIFEST);
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
({ request, url }: { request: Request; url: URL }) => {
if (request.mode !== 'navigate') {
return false;
}
if (url.pathname.startsWith('/_')) {
return false;
}
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.json') && url.pathname.startsWith('/locales'),
new StaleWhileRevalidate({
cacheName: 'locales',
})
);
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
const broadcast = new BroadcastChannel('sw-updates');
broadcast.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
clientsClaim();
}
});
self.addEventListener('install', (event) => {
broadcast.postMessage({
type: 'UPDATE_AVAILABLE'
});
})
registerRoute(
({ url }) => url.origin === "https://images.unsplash.com",
new StaleWhileRevalidate({
cacheName: 'unsplashImages',
plugins: []
})
)

View file

@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
let eventListeners: Map<events, ((ev: Event) => void)[]> = new Map<events, ((ev: Event) => void)[]>();
let pendingUpdate = false;
navigator.serviceWorker.getRegistration().then((reg) => {
if(reg && reg.waiting) {
pendingUpdate = true;
let handlers = eventListeners.get(events.updateAvailable) || [];
handlers.forEach((func) => {
func({
type: events.updateAvailable
});
});
}
})
enum events {
updateAvailable
}
type Event = {
type: events
}
const broadcast = new BroadcastChannel('sw-updates');
broadcast.addEventListener('message', (event) => {
console.log(event);
if(event.data && event.data.type === "UPDATE_AVAILABLE") {
pendingUpdate = true;
let handlers = eventListeners.get(events.updateAvailable) || [];
handlers.forEach((func) => {
func({
type: events.updateAvailable
});
});
}
})
const isUpdatePending = () => {
return pendingUpdate;
}
const forceUpdate = () => {
broadcast.postMessage({
type: "SKIP_WAITING"
})
pendingUpdate = false;
window.location.reload();
}
const on = (event: events, handler: (ev: Event) => void) => {
let list = eventListeners.get(event) || [];
list.push(handler);
eventListeners.set(event, list);
}
const off = (event: events, handler: (ev: Event) => void) => {
let list = eventListeners.get(event) || [];
let index = list.indexOf(handler);
if(index === -1) return;
list.splice(index, 1);
eventListeners.set(event, list);
}
const useUpdatePending = () => {
const [updatePending, setUpdatePending] = useState(isUpdatePending)
const updateAvailable = () => {
setUpdatePending(true);
}
useEffect(() => {
on(ServiceWorkerAPI.events.updateAvailable, updateAvailable);
return () => {
off(ServiceWorkerAPI.events.updateAvailable, updateAvailable);
}
})
return updatePending
}
const ServiceWorkerAPI = {
on,
off,
forceUpdate,
isUpdatePending,
events,
useUpdatePending
}
export default ServiceWorkerAPI

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

6224
yarn.lock

File diff suppressed because it is too large Load diff