feat: add pwa capabilities

This commit is contained in:
Nicola Zambello 2022-02-19 18:11:55 +01:00
parent 59dfad5615
commit 0c057ccd55
19 changed files with 422 additions and 10 deletions

210
app/entry.worker.tsx Normal file
View file

@ -0,0 +1,210 @@
/// <reference lib="WebWorker" />
import { json } from "@remix-run/server-runtime";
export type {};
declare let self: ServiceWorkerGlobalScope;
let STATIC_ASSETS = ["/build/", "/icons/"];
let ASSET_CACHE = "asset-cache";
let DATA_CACHE = "data-cache";
let DOCUMENT_CACHE = "document-cache";
function debug(...messages: any[]) {
if (process.env.NODE_ENV === "development") {
console.debug(...messages);
}
}
async function handleInstall(event: ExtendableEvent) {
debug("Service worker installed");
}
async function handleActivate(event: ExtendableEvent) {
debug("Service worker activated");
}
async function handleMessage(event: ExtendableMessageEvent) {
let cachePromises: Map<string, Promise<void>> = new Map();
if (event.data.type === "REMIX_NAVIGATION") {
let { isMount, location, matches, manifest } = event.data;
let documentUrl = location.pathname + location.search + location.hash;
let [dataCache, documentCache, existingDocument] = await Promise.all([
caches.open(DATA_CACHE),
caches.open(DOCUMENT_CACHE),
caches.match(documentUrl),
]);
if (!existingDocument || !isMount) {
debug("Caching document for", documentUrl);
cachePromises.set(
documentUrl,
documentCache.add(documentUrl).catch((error) => {
debug(`Failed to cache document for ${documentUrl}:`, error);
})
);
}
if (isMount) {
for (let match of matches) {
if (manifest.routes[match.id].hasLoader) {
let params = new URLSearchParams(location.search);
params.set("_data", match.id);
let search = params.toString();
search = search ? `?${search}` : "";
let url = location.pathname + search + location.hash;
if (!cachePromises.has(url)) {
debug("Caching data for", url);
cachePromises.set(
url,
dataCache.add(url).catch((error) => {
debug(`Failed to cache data for ${url}:`, error);
})
);
}
}
}
}
}
await Promise.all(cachePromises.values());
}
async function handleFetch(event: FetchEvent): Promise<Response> {
let url = new URL(event.request.url);
if (isAssetRequest(event.request)) {
let cached = await caches.match(event.request, {
cacheName: ASSET_CACHE,
ignoreVary: true,
ignoreSearch: true,
});
if (cached) {
debug("Serving asset from cache", url.pathname);
return cached;
}
debug("Serving asset from network", url.pathname);
let response = await fetch(event.request);
if (response.status === 200) {
let cache = await caches.open(ASSET_CACHE);
await cache.put(event.request, response.clone());
}
return response;
}
if (isLoaderRequest(event.request)) {
try {
debug("Serving data from network", url.pathname + url.search);
let response = await fetch(event.request.clone());
let cache = await caches.open(DATA_CACHE);
await cache.put(event.request, response.clone());
return response;
} catch (error) {
debug(
"Serving data from network failed, falling back to cache",
url.pathname + url.search
);
let response = await caches.match(event.request);
if (response) {
response.headers.set("X-Remix-Worker", "yes");
return response;
}
return json(
{ message: "Network Error" },
{
status: 500,
headers: { "X-Remix-Catch": "yes", "X-Remix-Worker": "yes" },
}
);
}
}
if (isDocumentGetRequest(event.request)) {
try {
debug("Serving document from network", url.pathname);
let response = await fetch(event.request);
let cache = await caches.open(DOCUMENT_CACHE);
await cache.put(event.request, response.clone());
return response;
} catch (error) {
debug(
"Serving document from network failed, falling back to cache",
url.pathname
);
let response = await caches.match(event.request);
if (response) {
return response;
}
throw error;
}
}
return fetch(event.request.clone());
}
function isMethod(request: Request, methods: string[]) {
return methods.includes(request.method.toLowerCase());
}
function isAssetRequest(request: Request) {
return (
isMethod(request, ["get"]) &&
STATIC_ASSETS.some((publicPath) => request.url.startsWith(publicPath))
);
}
function isLoaderRequest(request: Request) {
let url = new URL(request.url);
return isMethod(request, ["get"]) && url.searchParams.get("_data");
}
function isDocumentGetRequest(request: Request) {
return isMethod(request, ["get"]) && request.mode === "navigate";
}
self.addEventListener("install", (event) => {
event.waitUntil(handleInstall(event).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(handleActivate(event).then(() => self.clients.claim()));
});
self.addEventListener("message", (event) => {
event.waitUntil(handleMessage(event));
});
self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
let result = {} as
| { error: unknown; response: undefined }
| { error: undefined; response: Response };
try {
result.response = await handleFetch(event);
} catch (error) {
result.error = error;
}
return appHandleFetch(event, result);
})()
);
});
async function appHandleFetch(
event: FetchEvent,
{
error,
response,
}:
| { error: unknown; response: undefined }
| { error: undefined; response: Response }
): Promise<Response> {
return response;
}

View file

@ -1,3 +1,5 @@
import { useEffect } from "react";
import type { ReactNode } from "react";
import { import {
LinksFunction, LinksFunction,
LoaderFunction, LoaderFunction,
@ -5,7 +7,17 @@ import {
useLoaderData, useLoaderData,
} from "remix"; } from "remix";
import type { User, Team } from "@prisma/client"; import type { User, Team } from "@prisma/client";
import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix"; import {
Links,
LiveReload,
Outlet,
useCatch,
Meta,
Scripts,
ScrollRestoration,
useLocation,
useMatches,
} from "remix";
import { getUser } from "./utils/session.server"; import { getUser } from "./utils/session.server";
import styles from "./tailwind.css"; import styles from "./tailwind.css";
@ -44,29 +56,94 @@ export const loader: LoaderFunction = async ({ request }) => {
return data; return data;
}; };
let isMount = true;
function Document({ function Document({
children, children,
title = `Explit`, title = `Explit`,
}: { }: {
children: React.ReactNode; children: ReactNode;
title?: string; title?: string;
}) { }) {
const data = useLoaderData<LoaderData>(); const data = useLoaderData<LoaderData>();
let location = useLocation();
let matches = useMatches();
useEffect(() => {
let mounted = isMount;
isMount = false;
if ("serviceWorker" in navigator) {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller?.postMessage({
type: "REMIX_NAVIGATION",
isMount: mounted,
location,
matches,
manifest: window.__remixManifest,
});
} else {
let listener = async () => {
await navigator.serviceWorker.ready;
navigator.serviceWorker.controller?.postMessage({
type: "REMIX_NAVIGATION",
isMount: mounted,
location,
matches,
manifest: window.__remixManifest,
});
};
navigator.serviceWorker.addEventListener("controllerchange", listener);
return () => {
navigator.serviceWorker.removeEventListener(
"controllerchange",
listener
);
};
}
}
}, [location]);
return ( return (
<html lang="en" data-theme={data?.user?.theme ?? "dark"}> <html lang="en" data-theme={data?.user?.theme ?? "dark"}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1" />
name="viewport" <meta name="theme-color" content="#793ef9" />
content="width=device-width, initial-scale=1"
></meta>
<Meta /> <Meta />
<title>{title}</title> <title>{title}</title>
<link rel="manifest" href="/resources/manifest.json" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/icons/apple-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/icons/android-chrome-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/icons/favicon-16x16.png"
/>
<Links /> <Links />
</head> </head>
<body className="bg-base-300 m-0 min-h-screen p-3"> <body className="bg-base-300 m-0 min-h-screen p-3">
{children} {children}
<ScrollRestoration />
<Scripts /> <Scripts />
{process.env.NODE_ENV === "development" ? <LiveReload /> : null} {process.env.NODE_ENV === "development" ? <LiveReload /> : null}
</body> </body>

View file

@ -66,8 +66,6 @@ export const loader: LoaderFunction = async ({ request }) => {
...userData, ...userData,
dueAmount: avgPerUser - userData.spent, dueAmount: avgPerUser - userData.spent,
})); }));
console.log("totalExpenses", totalExpenses);
console.log("expensesByUser", expensesByUser);
const data: LoaderData = { const data: LoaderData = {
lastExpenses, lastExpenses,
@ -128,7 +126,7 @@ export default function ExpensesIndexRoute() {
<h2 className="card-title">Who needs to pay who</h2> <h2 className="card-title">Who needs to pay who</h2>
<div className="w-full shadow stats grid-cols-2 grid-flow-row-dense"> <div className="w-full shadow stats grid-cols-2 grid-flow-row-dense">
{data.teamCounts?.map((user) => ( {data.teamCounts?.map((user) => (
<div className="stat col-span-1"> <div className="stat col-span-1" key={user.id}>
<div className="stat-figure text-info"> <div className="stat-figure text-info">
<div className="rounded-full shrink-0 w-4 sm:w-10 h-4 sm:h-10 inline-flex justify-center items-center bg-white text-3xl"> <div className="rounded-full shrink-0 w-4 sm:w-10 h-4 sm:h-10 inline-flex justify-center items-center bg-white text-3xl">
{user.icon ?? user.username[0]} {user.icon ?? user.username[0]}

View file

@ -0,0 +1,57 @@
import { json } from "remix";
import type { LoaderFunction } from "remix";
export let loader: LoaderFunction = () => {
return json(
{
short_name: "Explit",
name: "Explit | Track and split shared expenses with friends and family.",
start_url: "/",
display: "standalone",
background_color: "#22252d",
theme_color: "#793ef9",
icons: [
{
src: "/icons/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
density: "0.75",
},
{
src: "/icons/android-icon-48x48.png",
sizes: "48x48",
type: "image/png",
density: "1.0",
},
{
src: "/icons/mstile-70x70.png",
sizes: "70x70",
type: "image/png",
density: "1.5",
},
{
src: "/icons/mstile-144x144.png",
sizes: "144x144",
type: "image/png",
density: "3.0",
},
{
src: "/icons/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
density: "4.0",
},
{
src: "/icons/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
{
headers: {
"Cache-Control": "public, max-age=600",
},
}
);
};

View file

@ -8,7 +8,9 @@
"scripts": { "scripts": {
"build": "npm run build:css && remix build", "build": "npm run build:css && remix build",
"build:css": "tailwindcss -o ./app/tailwind.css", "build:css": "tailwindcss -o ./app/tailwind.css",
"dev": "concurrently \"npm run dev:css\" \"remix dev\"", "build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"",
"dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:css": "tailwindcss -o ./app/tailwind.css --watch", "dev:css": "tailwindcss -o ./app/tailwind.css --watch",
"postinstall": "remix setup node", "postinstall": "remix setup node",
"prepare": "husky install", "prepare": "husky install",

9
public/browserconfig.xml Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,40 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1170 6985 c-401 -68 -748 -301 -968 -650 -70 -113 -139 -284 -179
-448 -16 -67 -18 -215 -21 -2337 -2 -1607 0 -2288 8 -2345 83 -607 559 -1092
1169 -1191 128 -21 4514 -21 4642 0 601 98 1067 564 1165 1165 21 128 21 4514
0 4642 -75 459 -370 853 -786 1048 -97 45 -201 81 -310 107 -71 17 -199 19
-2360 20 -1815 2 -2300 -1 -2360 -11z m3487 -1616 c185 -25 332 -99 469 -234
125 -124 190 -241 231 -413 15 -66 18 -122 18 -387 -1 -342 -5 -377 -67 -499
-39 -78 -133 -175 -208 -215 -115 -62 -180 -71 -491 -71 l-279 0 -2 -847 -3
-848 -23 -50 c-52 -112 -155 -179 -277 -179 -95 0 -130 17 -324 163 -93 70
-170 121 -183 121 -15 0 -75 -52 -192 -168 -202 -199 -230 -216 -351 -216
-115 0 -139 15 -346 220 -96 96 -182 174 -191 174 -8 0 -90 -57 -183 -126
-202 -152 -235 -169 -330 -168 -120 1 -218 63 -272 172 l-28 57 -3 1335 c-3
1391 -1 1508 38 1649 71 256 246 431 501 500 41 12 109 25 150 30 113 13 2251
13 2346 0z"/>
<path d="M2261 5105 c-151 -33 -258 -110 -316 -229 -68 -137 -65 -65 -65
-1592 0 -1060 3 -1383 12 -1392 7 -7 21 -12 31 -12 11 0 96 57 188 126 94 70
190 134 219 145 66 25 145 24 217 -2 51 -19 78 -42 232 -195 111 -112 181
-174 194 -174 13 0 82 61 191 168 189 185 227 211 330 219 99 8 140 -11 337
-157 96 -71 184 -130 195 -130 12 0 27 6 33 13 8 10 11 451 13 1563 l3 1549
22 55 c13 30 23 56 23 58 0 1 -404 2 -897 1 -725 0 -910 -3 -962 -14z m1244
-839 c64 -27 91 -103 60 -170 -20 -42 -982 -1002 -1018 -1015 -67 -26 -137 3
-164 66 -33 81 -50 61 499 611 277 277 512 507 523 512 30 13 62 12 100 -4z
m-875 -99 c51 -27 90 -90 90 -145 0 -129 -143 -213 -255 -149 -113 64 -111
233 5 294 55 29 105 29 160 0z m855 -710 c142 -81 92 -299 -73 -314 -132 -12
-225 135 -159 252 29 51 88 85 147 85 28 0 60 -9 85 -23z"/>
<path d="M4465 5102 c-52 -24 -111 -90 -124 -139 -7 -25 -11 -235 -11 -601 l0
-564 283 4 c271 3 284 4 335 27 69 31 134 103 156 174 15 49 17 93 14 364 -4
291 -5 311 -27 370 -80 220 -275 367 -501 379 -69 4 -92 1 -125 -14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

19
public/site.webmanifest Normal file
View file

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}