init
BIN
frontend/public/icons/app-badge.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
7
frontend/public/icons/app-badge.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Signals badge">
|
||||
<rect width="96" height="96" rx="24" fill="#ffffff" />
|
||||
<circle cx="32" cy="48" r="12" fill="#08111f" />
|
||||
<circle cx="64" cy="32" r="12" fill="#08111f" />
|
||||
<circle cx="64" cy="64" r="12" fill="#08111f" />
|
||||
<path d="M32 48L64 32L64 64Z" fill="none" stroke="#08111f" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
BIN
frontend/public/icons/app-icon-192.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
8
frontend/public/icons/app-icon-192.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" role="img" aria-label="Signals">
|
||||
<rect width="192" height="192" rx="48" fill="#08111f" />
|
||||
<rect x="24" y="24" width="144" height="144" rx="36" fill="#0f172a" stroke="#38bdf8" stroke-width="6" />
|
||||
<circle cx="68" cy="96" r="18" fill="#22c55e" />
|
||||
<circle cx="124" cy="74" r="18" fill="#38bdf8" />
|
||||
<circle cx="124" cy="118" r="18" fill="#f97316" />
|
||||
<path d="M68 96L124 74L124 118Z" fill="none" stroke="#e2e8f0" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
BIN
frontend/public/icons/app-icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
8
frontend/public/icons/app-icon-512.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Signals">
|
||||
<rect width="512" height="512" rx="128" fill="#08111f" />
|
||||
<rect x="64" y="64" width="384" height="384" rx="96" fill="#0f172a" stroke="#38bdf8" stroke-width="16" />
|
||||
<circle cx="180" cy="256" r="48" fill="#22c55e" />
|
||||
<circle cx="332" cy="196" r="48" fill="#38bdf8" />
|
||||
<circle cx="332" cy="316" r="48" fill="#f97316" />
|
||||
<path d="M180 256L332 196L332 316Z" fill="none" stroke="#e2e8f0" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
frontend/public/icons/favicon-16.png
Normal file
|
After Width: | Height: | Size: 463 B |
BIN
frontend/public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1018 B |
31
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Антигол",
|
||||
"short_name": "Антигол",
|
||||
"description": "Сигналы, новые матчи и push-уведомления в установленном приложении.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f3f6fb",
|
||||
"theme_color": "#162033",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/app-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/app-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/app-badge.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "monochrome"
|
||||
}
|
||||
]
|
||||
}
|
||||
207
frontend/public/sw.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const PUSH_CONTEXT_CACHE = "push-context-v1";
|
||||
const PUSH_CONTEXT_URL = "/__push-context__";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "push-context-sync") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.waitUntil(savePushContext(event.data.payload));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const data = event.data ? event.data.json() : {};
|
||||
const title = data.title || "Новый сигнал";
|
||||
const body = data.body || "Появился новый матч в списке сигналов";
|
||||
const url = data?.data?.url || data.url || "/";
|
||||
const tag = typeof data.tag === "string" && data.tag.trim().length > 0 ? data.tag : undefined;
|
||||
const shouldRenotify = Boolean(tag && data.renotify);
|
||||
|
||||
const notificationOptions = {
|
||||
body,
|
||||
requireInteraction: false,
|
||||
icon: "/icons/app-icon-192.png",
|
||||
badge: "/icons/app-badge.png",
|
||||
data: {
|
||||
url,
|
||||
...(data?.data || {})
|
||||
},
|
||||
vibrate: [200, 100, 200],
|
||||
timestamp: Date.now(),
|
||||
...(tag ? { tag } : {}),
|
||||
...(tag ? { renotify: shouldRenotify } : {})
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, notificationOptions)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/";
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => {
|
||||
const sameOriginClients = windowClients.filter((client) => {
|
||||
try {
|
||||
return new URL(client.url).origin === self.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const preferredClient =
|
||||
sameOriginClients.find((client) => client.visibilityState === "visible" || client.focused) ||
|
||||
sameOriginClients[0];
|
||||
|
||||
if (preferredClient) {
|
||||
preferredClient.postMessage({
|
||||
type: "push-notification-click",
|
||||
url
|
||||
});
|
||||
|
||||
return preferredClient.focus();
|
||||
}
|
||||
|
||||
for (const client of windowClients) {
|
||||
if ("focus" in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
event.waitUntil(
|
||||
resubscribeAfterChange(event)
|
||||
);
|
||||
});
|
||||
|
||||
async function savePushContext(nextContext) {
|
||||
const cache = await caches.open(PUSH_CONTEXT_CACHE);
|
||||
const currentContext = await readPushContext();
|
||||
const payload = {
|
||||
...(currentContext || {}),
|
||||
...(nextContext || {})
|
||||
};
|
||||
|
||||
await cache.put(
|
||||
PUSH_CONTEXT_URL,
|
||||
new Response(JSON.stringify(payload), {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function readPushContext() {
|
||||
const cache = await caches.open(PUSH_CONTEXT_CACHE);
|
||||
const response = await cache.match(PUSH_CONTEXT_URL);
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const normalizedInput = typeof base64String === "string" ? base64String.trim() : "";
|
||||
if (!normalizedInput) {
|
||||
throw new Error("Missing VAPID public key");
|
||||
}
|
||||
|
||||
const padding = "=".repeat((4 - (normalizedInput.length % 4)) % 4);
|
||||
const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const rawData = atob(base64);
|
||||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function fetchApplicationServerKey(apiBase) {
|
||||
if (!apiBase) {
|
||||
throw new Error("Push API base is not configured in service worker context");
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/vapid-public-key`, {
|
||||
method: "GET",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load VAPID public key: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return urlBase64ToUint8Array(payload.publicKey);
|
||||
}
|
||||
|
||||
async function syncSubscriptionWithServer(context, subscription, previousEndpoint) {
|
||||
if (!context?.apiBase) {
|
||||
throw new Error("Push API base is not configured in service worker context");
|
||||
}
|
||||
|
||||
const serializedSubscription = subscription.toJSON();
|
||||
if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) {
|
||||
throw new Error("Browser returned an incomplete push subscription");
|
||||
}
|
||||
|
||||
const isAuthenticated = Boolean(context.isAuthenticated);
|
||||
const endpoint = isAuthenticated ? "/me/push-subscriptions" : "/public/push-subscriptions";
|
||||
|
||||
if (!isAuthenticated && !context.anonymousClientId) {
|
||||
throw new Error("Anonymous push client id is missing in service worker context");
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
const response = await fetch(`${context.apiBase}${endpoint}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...(isAuthenticated ? {} : { clientId: context.anonymousClientId }),
|
||||
...serializedSubscription,
|
||||
...(previousEndpoint && previousEndpoint !== serializedSubscription.endpoint ? { previousEndpoint } : {})
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to sync rotated subscription: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resubscribeAfterChange(event) {
|
||||
const context = await readPushContext();
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousEndpoint = event.oldSubscription?.endpoint;
|
||||
const subscription =
|
||||
event.newSubscription ||
|
||||
(await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: await fetchApplicationServerKey(context.apiBase)
|
||||
}));
|
||||
|
||||
await syncSubscriptionWithServer(context, subscription, previousEndpoint);
|
||||
}
|
||||