feat: add pwa capabilities
210
app/entry.worker.tsx
Normal 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;
|
||||
}
|
||||
|
||||
89
app/root.tsx
|
|
@ -1,3 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
LinksFunction,
|
||||
LoaderFunction,
|
||||
|
|
@ -5,7 +7,17 @@ import {
|
|||
useLoaderData,
|
||||
} from "remix";
|
||||
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 styles from "./tailwind.css";
|
||||
|
|
@ -44,29 +56,94 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||
return data;
|
||||
};
|
||||
|
||||
let isMount = true;
|
||||
|
||||
function Document({
|
||||
children,
|
||||
title = `Explit`,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
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 (
|
||||
<html lang="en" data-theme={data?.user?.theme ?? "dark"}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
></meta>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#793ef9" />
|
||||
<Meta />
|
||||
<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 />
|
||||
</head>
|
||||
<body className="bg-base-300 m-0 min-h-screen p-3">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
{process.env.NODE_ENV === "development" ? <LiveReload /> : null}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -66,8 +66,6 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||
...userData,
|
||||
dueAmount: avgPerUser - userData.spent,
|
||||
}));
|
||||
console.log("totalExpenses", totalExpenses);
|
||||
console.log("expensesByUser", expensesByUser);
|
||||
|
||||
const data: LoaderData = {
|
||||
lastExpenses,
|
||||
|
|
@ -128,7 +126,7 @@ export default function ExpensesIndexRoute() {
|
|||
<h2 className="card-title">Who needs to pay who</h2>
|
||||
<div className="w-full shadow stats grid-cols-2 grid-flow-row-dense">
|
||||
{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="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]}
|
||||
|
|
|
|||
57
app/routes/resources/manifest[.]json.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,9 @@
|
|||
"scripts": {
|
||||
"build": "npm run build:css && remix build",
|
||||
"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",
|
||||
"postinstall": "remix setup node",
|
||||
"prepare": "husky install",
|
||||
|
|
|
|||
9
public/browserconfig.xml
Normal 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>
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 732 B |
BIN
public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/icons/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/icons/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/icons/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/icons/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
40
public/icons/safari-pinned-tab.svg
Normal 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
|
|
@ -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"
|
||||
}
|
||||