pwa
@@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
import AstroPWA from '@vite-pwa/astro'
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@@ -7,6 +8,49 @@ import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
AstroPWA({
|
||||
mode: 'production',
|
||||
base: '/',
|
||||
scope: '/',
|
||||
includeAssets: ['favicon.ico'],
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Pole Book',
|
||||
short_name: 'Pole Book',
|
||||
theme_color: '#ffffff',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/',
|
||||
globPatterns: ['**/*.{css,js,html,svg,png,ico,txt}'],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
navigateFallbackAllowlist: [/^\/$/],
|
||||
},
|
||||
experimental: {
|
||||
directoryAndTrailingSlashHandler: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
output: 'server',
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
|
||||
91
client/dev-dist/sw.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "/",
|
||||
"revision": "0.2pgnfiqdf3g"
|
||||
}], {
|
||||
"directoryIndex": "index.html"
|
||||
});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3391
client/dev-dist/workbox-54d0af47.js
Normal file
4358
client/package-lock.json
generated
@@ -6,15 +6,20 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"astro": "astro",
|
||||
"generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/logo.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@vite-pwa/astro": "^1.1.0",
|
||||
"astro": "^5.7.12",
|
||||
"marked": "^15.0.12",
|
||||
"qs": "^6.14.0",
|
||||
"tailwindcss": "^4.1.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
client/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
client/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/public/logo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
client/public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
client/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
client/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 974 B |
52
client/src/components/ReloadPrompt.astro
Normal file
@@ -0,0 +1,52 @@
|
||||
<script src="../pwa.ts"></script>
|
||||
|
||||
<div
|
||||
id="pwa-toast"
|
||||
role="alert"
|
||||
aria-labelledby="toast-message"
|
||||
>
|
||||
<div class="message">
|
||||
<span id="toast-message"></span>
|
||||
</div>
|
||||
<button id="pwa-refresh">
|
||||
Reload
|
||||
</button>
|
||||
<button id="pwa-close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#pwa-toast {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #8885;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
box-shadow: 3px 4px 5px 0 #8885;
|
||||
}
|
||||
#pwa-toast .message {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#pwa-toast button {
|
||||
border: 1px solid #8885;
|
||||
outline: none;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
#pwa-toast.show {
|
||||
visibility: visible;
|
||||
}
|
||||
button#pwa-refresh {
|
||||
display: none;
|
||||
}
|
||||
#pwa-toast.show.refresh button#pwa-refresh {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,26 @@
|
||||
---
|
||||
const { title, description } = Astro.props;
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||
|
||||
---
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={ Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
||||
<link rel="mask-icon" href="/favicon.ico" color="#FFFFFF">
|
||||
<meta name="msapplication-TileColor" content="#FFFFFF">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<title>{title}</title>
|
||||
<script src="/src/pwa.ts"></script>
|
||||
{ pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} /> }
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
{ pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} /> }
|
||||
{ pwaAssetsHead.links.map(link => (
|
||||
<link {...link} />
|
||||
)) }
|
||||
</head>
|
||||
@@ -3,6 +3,7 @@ import "../styles/global.css";
|
||||
import Head from "./Head.astro";
|
||||
import Navigation from "../components/Navigation.astro";
|
||||
import { getLanguageFromPath } from "../lib/i18n";
|
||||
import ReloadPrompt from '../components/ReloadPrompt.astro';
|
||||
|
||||
const { title = "Just a title", description = "Adescription" } = Astro.props;
|
||||
|
||||
@@ -18,6 +19,8 @@ const currentLang = getLanguageFromPath(Astro.url.pathname);
|
||||
</header>
|
||||
<main class="container max-w-4xl mx-auto p-4">
|
||||
<slot />
|
||||
<ReloadPrompt />
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
50
client/src/pwa.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const pwaToast = document.querySelector<HTMLDivElement>('#pwa-toast')!
|
||||
const pwaToastMessage = pwaToast.querySelector<HTMLDivElement>('.message #toast-message')!
|
||||
const pwaCloseBtn = pwaToast.querySelector<HTMLButtonElement>('#pwa-close')!
|
||||
const pwaRefreshBtn = pwaToast.querySelector<HTMLButtonElement>('#pwa-refresh')!
|
||||
|
||||
let refreshSW: ((reloadPage?: boolean) => Promise<void>) | undefined
|
||||
|
||||
const refreshCallback = () => refreshSW?.(true)
|
||||
|
||||
const hidePwaToast = (raf = false) => {
|
||||
if (raf) {
|
||||
requestAnimationFrame(() => hidePwaToast(false))
|
||||
return
|
||||
}
|
||||
if (pwaToast.classList.contains('refresh'))
|
||||
pwaRefreshBtn.removeEventListener('click', refreshCallback)
|
||||
|
||||
pwaToast.classList.remove('show', 'refresh')
|
||||
}
|
||||
const showPwaToast = (offline: boolean) => {
|
||||
if (!offline)
|
||||
pwaRefreshBtn.addEventListener('click', refreshCallback)
|
||||
requestAnimationFrame(() => {
|
||||
hidePwaToast(false)
|
||||
if (!offline)
|
||||
pwaToast.classList.add('refresh')
|
||||
pwaToast.classList.add('show')
|
||||
})
|
||||
}
|
||||
|
||||
pwaCloseBtn.addEventListener('click', () => hidePwaToast(true))
|
||||
|
||||
refreshSW = registerSW({
|
||||
immediate: true,
|
||||
onOfflineReady() {
|
||||
pwaToastMessage.innerHTML = 'App ready to work offline'
|
||||
showPwaToast(true)
|
||||
},
|
||||
onNeedRefresh() {
|
||||
pwaToastMessage.innerHTML = 'New content available, click on reload button to update'
|
||||
showPwaToast(false)
|
||||
},
|
||||
onRegisteredSW(swScriptUrl) {
|
||||
console.log('SW registered: ', swScriptUrl)
|
||||
}
|
||||
})
|
||||
})
|
||||
40
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'virtual:pwa-register' {
|
||||
export interface RegisterSWOptions {
|
||||
immediate?: boolean
|
||||
onNeedRefresh?: () => void
|
||||
onOfflineReady?: () => void
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
|
||||
onRegisterError?: (error: any) => void
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
declare module 'virtual:pwa-info' {
|
||||
export interface PWAInfo {
|
||||
webManifest: {
|
||||
linkTag: string
|
||||
}
|
||||
}
|
||||
|
||||
export const pwaInfo: PWAInfo | undefined
|
||||
}
|
||||
|
||||
declare module 'virtual:pwa-assets/head' {
|
||||
export interface PWAAssetsHead {
|
||||
themeColor?: {
|
||||
content: string
|
||||
}
|
||||
links: Array<{
|
||||
rel: string
|
||||
href: string
|
||||
sizes?: string
|
||||
type?: string
|
||||
[key: string]: any
|
||||
}>
|
||||
}
|
||||
|
||||
export const pwaAssetsHead: PWAAssetsHead
|
||||
}
|
||||