a
This commit is contained in:
parent
87c1e377a3
commit
afcaa0b217
10 changed files with 415 additions and 33 deletions
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@aws-sdk/client-s3": "^3.583.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import jwt from 'jsonwebtoken';
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
|
@ -18,6 +20,28 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
const uploadDir = path.join(__dirname, '..', 'uploads');
|
||||
const fsPromises = fs.promises;
|
||||
const assetBaseUrl = (process.env.ASSET_BASE_URL || '').replace(/\/$/, '');
|
||||
const s3Bucket = process.env.S3_BUCKET || '';
|
||||
const s3Region = process.env.S3_REGION || 'us-east-1';
|
||||
const s3Endpoint = process.env.S3_ENDPOINT || undefined;
|
||||
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
|
||||
const s3SecretKey = process.env.S3_SECRET_KEY || '';
|
||||
const s3ForcePathStyle = parseBoolean(process.env.S3_FORCE_PATH_STYLE ?? 'true');
|
||||
const useS3Storage = Boolean(s3Bucket);
|
||||
const s3Client = useS3Storage
|
||||
? new S3Client({
|
||||
region: s3Region,
|
||||
endpoint: s3Endpoint,
|
||||
forcePathStyle: s3ForcePathStyle,
|
||||
credentials:
|
||||
s3AccessKey && s3SecretKey
|
||||
? {
|
||||
accessKeyId: s3AccessKey,
|
||||
secretAccessKey: s3SecretKey,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
: null;
|
||||
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
||||
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
|
||||
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
|
||||
|
|
@ -57,26 +81,34 @@ const corsOptions = {
|
|||
const port = process.env.PORT || 4000;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
let upload;
|
||||
|
||||
if (useS3Storage) {
|
||||
upload = multer({ storage: multer.memoryStorage() });
|
||||
} else {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const ext = path.extname(file.originalname) || '.png';
|
||||
cb(null, `${unique}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
upload = multer({ storage: diskStorage });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const ext = path.extname(file.originalname) || '.png';
|
||||
cb(null, `${unique}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({ storage });
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.options('*', cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
if (!useS3Storage) {
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
}
|
||||
|
||||
function setAuthCookie(res, token) {
|
||||
res.cookie('token', token, {
|
||||
|
|
@ -283,12 +315,19 @@ app.get('/bases', requireAuth, async (req, res) => {
|
|||
});
|
||||
|
||||
app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => {
|
||||
let uploadedFile = null;
|
||||
try {
|
||||
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
||||
if (!title || !title.trim()) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
if (url && !isValidUrl(url)) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
||||
}
|
||||
|
||||
|
|
@ -296,11 +335,16 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
|||
let storedImagePath = null;
|
||||
if (imageMode === 'url') {
|
||||
if (!imageUrl || !isValidUrl(imageUrl)) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||
}
|
||||
storedImageUrl = imageUrl.trim();
|
||||
} else if (req.file) {
|
||||
storedImagePath = req.file.filename;
|
||||
uploadedFile = await persistUploadedFile(req.file);
|
||||
storedImagePath = uploadedFile?.path || null;
|
||||
storedImageUrl = uploadedFile?.publicUrl || null;
|
||||
}
|
||||
|
||||
const base = await prisma.base.create({
|
||||
|
|
@ -328,7 +372,9 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
|||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (req.file) {
|
||||
if (uploadedFile?.path) {
|
||||
await deleteImageFile(uploadedFile.path);
|
||||
} else if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
|
|
@ -336,7 +382,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
|||
});
|
||||
|
||||
app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => {
|
||||
let newUploadFilename = null;
|
||||
let uploadedFile = null;
|
||||
try {
|
||||
const { baseId } = req.params;
|
||||
const {
|
||||
|
|
@ -350,14 +396,14 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
|||
} = req.body;
|
||||
|
||||
if (!title || !title.trim()) {
|
||||
if (req.file) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
if (url && !isValidUrl(url)) {
|
||||
if (req.file) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
||||
|
|
@ -365,7 +411,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
|||
|
||||
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||
if (!base) {
|
||||
if (req.file) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(404).json({ error: 'Base not found' });
|
||||
|
|
@ -385,7 +431,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
|||
} else if (imageMode === 'url') {
|
||||
if (imageUrl) {
|
||||
if (!isValidUrl(imageUrl)) {
|
||||
if (req.file) {
|
||||
if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
}
|
||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||
|
|
@ -395,10 +441,10 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
|||
storedImageUrl = imageUrl.trim();
|
||||
}
|
||||
} else if (req.file) {
|
||||
newUploadFilename = req.file.filename;
|
||||
uploadedFile = await persistUploadedFile(req.file);
|
||||
previousImagePathToDelete = base.imagePath;
|
||||
imagePath = req.file.filename;
|
||||
storedImageUrl = null;
|
||||
imagePath = uploadedFile?.path || null;
|
||||
storedImageUrl = uploadedFile?.publicUrl || null;
|
||||
}
|
||||
|
||||
const updatedBase = await prisma.base.update({
|
||||
|
|
@ -430,10 +476,10 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
|||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (req.file) {
|
||||
if (uploadedFile?.path) {
|
||||
await deleteImageFile(uploadedFile.path);
|
||||
} else if (!useS3Storage && req.file?.filename) {
|
||||
await deleteImageFile(req.file.filename);
|
||||
} else if (newUploadFilename) {
|
||||
await deleteImageFile(newUploadFilename);
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
|
@ -863,8 +909,101 @@ function summarizeDefenses(defenses) {
|
|||
};
|
||||
}
|
||||
|
||||
function buildStoredImageUrl(imagePath) {
|
||||
if (!imagePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (useS3Storage) {
|
||||
const base = assetBaseUrl || deriveS3EndpointBase();
|
||||
if (!base) {
|
||||
return '';
|
||||
}
|
||||
return `${base.replace(/\/$/, '')}/${imagePath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
return `/uploads/${imagePath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
function deriveS3EndpointBase() {
|
||||
if (!s3Endpoint) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmedEndpoint = s3Endpoint.replace(/\/$/, '');
|
||||
if (s3ForcePathStyle || !trimmedEndpoint) {
|
||||
return `${trimmedEndpoint}/${s3Bucket}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpointUrl = new URL(trimmedEndpoint);
|
||||
const portPart = endpointUrl.port ? `:${endpointUrl.port}` : '';
|
||||
return `${endpointUrl.protocol}//${s3Bucket}.${endpointUrl.hostname}${portPart}`;
|
||||
} catch (_error) {
|
||||
return `${trimmedEndpoint}/${s3Bucket}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildStorageKey(originalName = '') {
|
||||
const ext = path.extname(originalName || '').trim().slice(0, 10) || '.png';
|
||||
return `bases/${Date.now()}-${randomUUID()}${ext}`;
|
||||
}
|
||||
|
||||
async function persistUploadedFile(file) {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (useS3Storage) {
|
||||
const key = buildStorageKey(file.originalname);
|
||||
try {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: s3Bucket,
|
||||
Key: key,
|
||||
Body: file.buffer,
|
||||
ContentType: file.mimetype || 'application/octet-stream',
|
||||
Metadata: {
|
||||
originalname: file.originalname || 'uploaded-image',
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image to S3', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
path: key,
|
||||
publicUrl: buildStoredImageUrl(key),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: file.filename,
|
||||
publicUrl: buildStoredImageUrl(file.filename),
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteImageFile(imagePath) {
|
||||
if (!imagePath) return;
|
||||
|
||||
if (useS3Storage) {
|
||||
try {
|
||||
await s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: s3Bucket,
|
||||
Key: imagePath,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.$metadata?.httpStatusCode !== 404) {
|
||||
console.error(`Failed to delete image from S3 with key ${imagePath}`, error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
|
||||
try {
|
||||
await fsPromises.unlink(filePath);
|
||||
|
|
@ -899,7 +1038,7 @@ function sanitizeUser(user) {
|
|||
|
||||
function buildImageUrl(base) {
|
||||
if (base.imageUrl) return base.imageUrl;
|
||||
if (base.imagePath) return `/uploads/${base.imagePath}`;
|
||||
if (base.imagePath) return buildStoredImageUrl(base.imagePath);
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,11 @@ select {
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
input[type='file'] {
|
||||
width: fit-content;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
|
|
|||
31
frontend/app/icon-192.tsx
Normal file
31
frontend/app/icon-192.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const contentType = 'image/png';
|
||||
export const size = {
|
||||
width: 192,
|
||||
height: 192,
|
||||
};
|
||||
|
||||
export default function Icon192() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#f8fafc',
|
||||
fontSize: 88,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -4,
|
||||
fontFamily: '"Inter", "Segoe UI", "Helvetica Neue", sans-serif',
|
||||
}}
|
||||
>
|
||||
BT
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
31
frontend/app/icon.tsx
Normal file
31
frontend/app/icon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const contentType = 'image/png';
|
||||
export const size = {
|
||||
width: 512,
|
||||
height: 512,
|
||||
};
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#f8fafc',
|
||||
fontSize: 220,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -8,
|
||||
fontFamily: '"Inter", "Segoe UI", "Helvetica Neue", sans-serif',
|
||||
}}
|
||||
>
|
||||
BT
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,29 @@
|
|||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { ServiceWorkerProvider } from './service-worker-provider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Base Noter',
|
||||
metadataBase: new URL('https://basetracker.lona-development.org'),
|
||||
title: {
|
||||
default: 'Base Tracker',
|
||||
template: '%s | Base Tracker',
|
||||
},
|
||||
applicationName: 'Base Tracker',
|
||||
description: 'Track how each Clash of Clans base defends against every army',
|
||||
manifest: '/manifest.webmanifest',
|
||||
themeColor: '#0f172a',
|
||||
icons: {
|
||||
icon: '/icon.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" dir="auto">
|
||||
<body>
|
||||
<ServiceWorkerProvider />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
31
frontend/app/manifest.ts
Normal file
31
frontend/app/manifest.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Base Tracker',
|
||||
short_name: 'Base Tracker',
|
||||
description: 'Track how each Clash of Clans base defends against every army',
|
||||
lang: 'en',
|
||||
dir: 'auto',
|
||||
start_url: 'https://basetracker.lona-development.org/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
display_override: ['window-controls-overlay', 'fullscreen'],
|
||||
background_color: '#0f172a',
|
||||
theme_color: '#0f172a',
|
||||
orientation: 'any',
|
||||
id: 'basetracker.lona',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
@ -41,6 +41,17 @@ const API = {
|
|||
profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`,
|
||||
};
|
||||
|
||||
function resolveImageUrl(url: string | null | undefined) {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new URL(url, `${API_BASE}/`).toString();
|
||||
} catch (_error) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
|
|
@ -242,7 +253,11 @@ export default function Page() {
|
|||
request('GET', API.categories),
|
||||
request('GET', API.defenses),
|
||||
]);
|
||||
setBases(basesRes.bases || []);
|
||||
const normalizedBases = ((basesRes.bases || []) as BaseItem[]).map((base) => ({
|
||||
...base,
|
||||
imageUrl: resolveImageUrl(base.imageUrl),
|
||||
}));
|
||||
setBases(normalizedBases);
|
||||
setCategories(categoriesRes.categories || []);
|
||||
setDefenses(defensesRes.defenses || []);
|
||||
setSummaries({
|
||||
|
|
@ -555,7 +570,14 @@ export default function Page() {
|
|||
setProfileError('');
|
||||
setProfileSelectedBase(null);
|
||||
const data: ProfileDetail = await request('GET', API.profileDetail(username));
|
||||
setProfileDetail(data);
|
||||
const normalizedDetail: ProfileDetail = {
|
||||
...data,
|
||||
bases: data.bases.map((base) => ({
|
||||
...base,
|
||||
imageUrl: resolveImageUrl(base.imageUrl),
|
||||
})),
|
||||
};
|
||||
setProfileDetail(normalizedDetail);
|
||||
setView('dashboard');
|
||||
} catch (error: any) {
|
||||
setProfileDetail(null);
|
||||
|
|
|
|||
41
frontend/app/service-worker-provider.tsx
Normal file
41
frontend/app/service-worker-provider.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const SW_PATH = '/sw.js';
|
||||
|
||||
export function ServiceWorkerProvider() {
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registerServiceWorker = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(SW_PATH, { scope: '/' });
|
||||
|
||||
if (registration.waiting) {
|
||||
registration.waiting.postMessage('skipWaiting');
|
||||
}
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!document.hidden) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Service worker registration failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
registerServiceWorker();
|
||||
} else {
|
||||
navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => {
|
||||
registration?.unregister();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
67
frontend/public/sw.js
Normal file
67
frontend/public/sw.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const CACHE_NAME = 'base-tracker-cache-v1';
|
||||
const APP_SHELL = ['/'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(APP_SHELL))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(async () => {
|
||||
const offlineFallback = await caches.match('/');
|
||||
return offlineFallback ?? Response.error();
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.request.url.startsWith(self.location.origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cachedResponse) => {
|
||||
const networkFetch = fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
if (
|
||||
networkResponse &&
|
||||
networkResponse.status === 200 &&
|
||||
networkResponse.type === 'basic' &&
|
||||
!networkResponse.headers.get('Cache-Control')?.includes('no-store')
|
||||
) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => cachedResponse);
|
||||
|
||||
return cachedResponse || networkFetch;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data === 'skipWaiting') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue