a
This commit is contained in:
parent
87c1e377a3
commit
afcaa0b217
10 changed files with 415 additions and 33 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.17.0",
|
"@prisma/client": "^5.17.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.583.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import jwt from 'jsonwebtoken';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -18,6 +20,28 @@ const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const uploadDir = path.join(__dirname, '..', 'uploads');
|
const uploadDir = path.join(__dirname, '..', 'uploads');
|
||||||
const fsPromises = fs.promises;
|
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 jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
||||||
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
|
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
|
||||||
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
|
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
|
||||||
|
|
@ -57,26 +81,34 @@ const corsOptions = {
|
||||||
const port = process.env.PORT || 4000;
|
const port = process.env.PORT || 4000;
|
||||||
const host = process.env.HOST || '0.0.0.0';
|
const host = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
if (!fs.existsSync(uploadDir)) {
|
let upload;
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
|
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.use(cors(corsOptions));
|
||||||
app.options('*', cors(corsOptions));
|
app.options('*', cors(corsOptions));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', express.static(uploadDir));
|
if (!useS3Storage) {
|
||||||
|
app.use('/uploads', express.static(uploadDir));
|
||||||
|
}
|
||||||
|
|
||||||
function setAuthCookie(res, token) {
|
function setAuthCookie(res, token) {
|
||||||
res.cookie('token', 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) => {
|
app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => {
|
||||||
|
let uploadedFile = null;
|
||||||
try {
|
try {
|
||||||
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
||||||
if (!title || !title.trim()) {
|
if (!title || !title.trim()) {
|
||||||
|
if (!useS3Storage && req.file?.filename) {
|
||||||
|
await deleteImageFile(req.file.filename);
|
||||||
|
}
|
||||||
return res.status(400).json({ error: 'Title is required' });
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
}
|
}
|
||||||
if (url && !isValidUrl(url)) {
|
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' });
|
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;
|
let storedImagePath = null;
|
||||||
if (imageMode === 'url') {
|
if (imageMode === 'url') {
|
||||||
if (!imageUrl || !isValidUrl(imageUrl)) {
|
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' });
|
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||||
}
|
}
|
||||||
storedImageUrl = imageUrl.trim();
|
storedImageUrl = imageUrl.trim();
|
||||||
} else if (req.file) {
|
} 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({
|
const base = await prisma.base.create({
|
||||||
|
|
@ -328,7 +372,9 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
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) => {
|
app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => {
|
||||||
let newUploadFilename = null;
|
let uploadedFile = null;
|
||||||
try {
|
try {
|
||||||
const { baseId } = req.params;
|
const { baseId } = req.params;
|
||||||
const {
|
const {
|
||||||
|
|
@ -350,14 +396,14 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!title || !title.trim()) {
|
if (!title || !title.trim()) {
|
||||||
if (req.file) {
|
if (!useS3Storage && req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Title is required' });
|
return res.status(400).json({ error: 'Title is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url && !isValidUrl(url)) {
|
if (url && !isValidUrl(url)) {
|
||||||
if (req.file) {
|
if (!useS3Storage && req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
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 } });
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
if (!base) {
|
if (!base) {
|
||||||
if (req.file) {
|
if (!useS3Storage && req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(404).json({ error: 'Base not found' });
|
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') {
|
} else if (imageMode === 'url') {
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
if (!isValidUrl(imageUrl)) {
|
if (!isValidUrl(imageUrl)) {
|
||||||
if (req.file) {
|
if (!useS3Storage && req.file?.filename) {
|
||||||
await deleteImageFile(req.file.filename);
|
await deleteImageFile(req.file.filename);
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
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();
|
storedImageUrl = imageUrl.trim();
|
||||||
}
|
}
|
||||||
} else if (req.file) {
|
} else if (req.file) {
|
||||||
newUploadFilename = req.file.filename;
|
uploadedFile = await persistUploadedFile(req.file);
|
||||||
previousImagePathToDelete = base.imagePath;
|
previousImagePathToDelete = base.imagePath;
|
||||||
imagePath = req.file.filename;
|
imagePath = uploadedFile?.path || null;
|
||||||
storedImageUrl = null;
|
storedImageUrl = uploadedFile?.publicUrl || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedBase = await prisma.base.update({
|
const updatedBase = await prisma.base.update({
|
||||||
|
|
@ -430,10 +476,10 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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);
|
await deleteImageFile(req.file.filename);
|
||||||
} else if (newUploadFilename) {
|
|
||||||
await deleteImageFile(newUploadFilename);
|
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
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) {
|
async function deleteImageFile(imagePath) {
|
||||||
if (!imagePath) return;
|
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);
|
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
|
||||||
try {
|
try {
|
||||||
await fsPromises.unlink(filePath);
|
await fsPromises.unlink(filePath);
|
||||||
|
|
@ -899,7 +1038,7 @@ function sanitizeUser(user) {
|
||||||
|
|
||||||
function buildImageUrl(base) {
|
function buildImageUrl(base) {
|
||||||
if (base.imageUrl) return base.imageUrl;
|
if (base.imageUrl) return base.imageUrl;
|
||||||
if (base.imagePath) return `/uploads/${base.imagePath}`;
|
if (base.imagePath) return buildStoredImageUrl(base.imagePath);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,11 @@ select {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='file'] {
|
||||||
|
width: fit-content;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: vertical;
|
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 type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { ServiceWorkerProvider } from './service-worker-provider';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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',
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" dir="auto">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<ServiceWorkerProvider />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</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)}`,
|
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 = {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -242,7 +253,11 @@ export default function Page() {
|
||||||
request('GET', API.categories),
|
request('GET', API.categories),
|
||||||
request('GET', API.defenses),
|
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 || []);
|
setCategories(categoriesRes.categories || []);
|
||||||
setDefenses(defensesRes.defenses || []);
|
setDefenses(defensesRes.defenses || []);
|
||||||
setSummaries({
|
setSummaries({
|
||||||
|
|
@ -555,7 +570,14 @@ export default function Page() {
|
||||||
setProfileError('');
|
setProfileError('');
|
||||||
setProfileSelectedBase(null);
|
setProfileSelectedBase(null);
|
||||||
const data: ProfileDetail = await request('GET', API.profileDetail(username));
|
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');
|
setView('dashboard');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setProfileDetail(null);
|
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