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 {
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
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": {
|
"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
|
|
@ -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"
|
||||||
|
}
|
||||||