Init
This commit is contained in:
commit
1031aed4fd
21 changed files with 2103 additions and 0 deletions
101
README.md
Normal file
101
README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Base Noter (Dockerized Next.js + Prisma)
|
||||
|
||||
This repository now contains a two-container stack:
|
||||
|
||||
- `frontend/` – a Next.js 14 web UI that reimplements the original single-page experience
|
||||
- `backend/` – an Express API with Prisma + SQLite that provides authentication, base/category/defense CRUD, statistics, and image uploads
|
||||
|
||||
Everything runs via Docker so you can deploy directly to Dokploy (or any Docker-based host).
|
||||
|
||||
## Quick start (local Docker Compose)
|
||||
|
||||
The Dokploy-oriented compose files assume production domains. For a local smoke test you can temporarily override the variables at runtime:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=dev-secret docker compose -f docker-compose.backend.yml up --build -d
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:4000 docker compose -f docker-compose.frontend.yml up --build -d
|
||||
```
|
||||
|
||||
Then visit:
|
||||
|
||||
- Frontend UI: http://localhost:3000
|
||||
- Backend API (optional): http://localhost:4000
|
||||
|
||||
Default environment variables ship in `backend/.env.example`. Copy it (e.g. to `backend/.env`) and update `JWT_SECRET`, `FRONTEND_ORIGIN`, and `COOKIE_SECURE` before deploying for real.
|
||||
|
||||
**Persistent data**
|
||||
|
||||
The compose file provisions two named volumes:
|
||||
|
||||
- `backend-data` – stores the Prisma SQLite database (`/app/dev.db`)
|
||||
- `backend-uploads` – stores uploaded base layout images (`/app/uploads`)
|
||||
|
||||
## Backend details (`backend/`)
|
||||
|
||||
- Express 4 REST API
|
||||
- Authentication via HTTP-only JWT cookie (`/auth/signup`, `/auth/login`, `/auth/logout`, `/auth/me`)
|
||||
- CRUD-ish endpoints for army categories, bases (with upload-or-URL image support), defenses, and aggregated statistics (`/defenses`)
|
||||
- Prisma schema lives in `prisma/schema.prisma`
|
||||
- On container boot the schema is synced with `npx prisma db push`
|
||||
- Dockerfile exposes port `4000`
|
||||
|
||||
Environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Purpose |
|
||||
|-------------------|----------------------------------------------|
|
||||
| `DATABASE_URL` | Prisma connection string (default SQLite) |
|
||||
| `JWT_SECRET` | Secret for signing JWT auth cookies |
|
||||
| `FRONTEND_ORIGIN` | CORS allowlist (e.g. `http://localhost:3100`) |
|
||||
| `COOKIE_SECURE` | Set to `true` when serving over HTTPS |
|
||||
|
||||
## Frontend details (`frontend/`)
|
||||
|
||||
- Next.js 14 App Router, React 18
|
||||
- UI mirrors the earlier PHP page (auth tabs, dashboard, forms, base/category detail views)
|
||||
- Global styling lives in `app/globals.css`
|
||||
- All API calls are routed to `NEXT_PUBLIC_BACKEND_URL` (defaults to `http://localhost:4100` and configured in Docker compose)
|
||||
- Dockerfile builds the production bundle and runs `next start`
|
||||
|
||||
## Deploying with Dokploy
|
||||
|
||||
Use the dedicated compose manifests so each service can be registered as its own Dokploy app with the correct domain routing:
|
||||
|
||||
- `docker-compose.backend.yml` – Express + Prisma API at `backend.basetracker.lona-development.org`
|
||||
- `docker-compose.frontend.yml` – Next.js UI at `basetracker.lona-development.org`
|
||||
|
||||
Steps:
|
||||
|
||||
1. Push this repo to Git (commit the Dockerfiles and compose manifests).
|
||||
2. In Dokploy create two Compose apps, each pointing to the respective file above.
|
||||
3. Provide secrets for `JWT_SECRET` (backend) and any overrides you need; TLS-terminated installs should leave `COOKIE_SECURE` as `true`.
|
||||
4. Dokploy's Traefik proxy will use the embedded labels to route the domains listed earlier. Adjust `entrypoints`/`certresolver` if your installation uses different names.
|
||||
5. Configure persistent volumes for `/app/dev.db` and `/app/uploads`, or switch Prisma to an external database before deployment.
|
||||
|
||||
## Development without Docker
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
yarn install # or npm install
|
||||
npx prisma db push
|
||||
npm run dev
|
||||
|
||||
# Frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Set `NEXT_PUBLIC_BACKEND_URL=http://localhost:4100` for the frontend.
|
||||
|
||||
## Image handling
|
||||
|
||||
Uploaded base images are saved to `/app/uploads` inside the backend container and served at `http://<backend>/uploads/<filename>`. You can switch to an object store by swapping the Multer storage in `src/server.js`.
|
||||
|
||||
## Testing
|
||||
|
||||
Automated tests are not yet included. Manual smoke testing is recommended:
|
||||
|
||||
1. Sign up and log in
|
||||
2. Add army categories and bases (with/without image uploads)
|
||||
3. Log defenses and confirm the dashboard, base detail, and category detail stats update
|
||||
|
||||
Feel free to extend the stack with migrations, tests, or alternative storage as your deployment requires.
|
||||
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.env
|
||||
uploads
|
||||
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DATABASE_URL="file:./dev.db"
|
||||
JWT_SECRET="change-me"
|
||||
FRONTEND_ORIGIN="http://localhost:3100"
|
||||
COOKIE_SECURE="false"
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
# Ensure Prisma engines can find the expected OpenSSL libraries on Alpine
|
||||
RUN apk add --no-cache openssl1.1-compat
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm install
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY src ./src
|
||||
RUN mkdir -p uploads
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4000
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push && node src/server.js"]
|
||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "base-noter-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "node src/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.17.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^5.17.0"
|
||||
}
|
||||
}
|
||||
53
backend/prisma/schema.prisma
Normal file
53
backend/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
armyCategories ArmyCategory[]
|
||||
bases Base[]
|
||||
}
|
||||
|
||||
model ArmyCategory {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
defenses Defense[]
|
||||
|
||||
@@unique([userId, name])
|
||||
}
|
||||
|
||||
model Base {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
url String?
|
||||
imageUrl String?
|
||||
imagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
defenses Defense[]
|
||||
}
|
||||
|
||||
model Defense {
|
||||
id String @id @default(cuid())
|
||||
stars Int
|
||||
percent Int
|
||||
trophies Int
|
||||
createdAt DateTime @default(now())
|
||||
base Base @relation(fields: [baseId], references: [id])
|
||||
baseId String
|
||||
armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id])
|
||||
armyCategoryId String
|
||||
}
|
||||
487
backend/src/server.js
Normal file
487
backend/src/server.js
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import multer from 'multer';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const app = express();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const uploadDir = path.join(__dirname, '..', 'uploads');
|
||||
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
||||
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100';
|
||||
const port = process.env.PORT || 4000;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
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({
|
||||
origin: frontendOrigin,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
|
||||
function setAuthCookie(res, token) {
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function clearAuthCookie(res) {
|
||||
res.clearCookie('token');
|
||||
}
|
||||
|
||||
async function getUserFromToken(req) {
|
||||
const token = req.cookies.token;
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: {
|
||||
armyCategories: true,
|
||||
bases: {
|
||||
include: {
|
||||
defenses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return user;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
const user = await getUserFromToken(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
}
|
||||
|
||||
app.post('/auth/signup', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
if (username.length < 3) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Username already in use' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: username.toLowerCase(),
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' });
|
||||
setAuthCookie(res, token);
|
||||
|
||||
return res.status(201).json({ user: sanitizeUser(user) });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' });
|
||||
setAuthCookie(res, token);
|
||||
|
||||
return res.json({ user: sanitizeUser(user) });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/logout', (_req, res) => {
|
||||
clearAuthCookie(res);
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
|
||||
app.get('/auth/me', async (req, res) => {
|
||||
const user = await getUserFromToken(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
return res.json({ user: sanitizeUser(user) });
|
||||
});
|
||||
|
||||
app.get('/army-categories', requireAuth, async (req, res) => {
|
||||
const categories = await prisma.armyCategory.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
res.json({ categories: categories.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
createdAt: category.createdAt,
|
||||
})) });
|
||||
});
|
||||
|
||||
app.post('/army-categories', requireAuth, async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Category name is required' });
|
||||
}
|
||||
try {
|
||||
const category = await prisma.armyCategory.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
return res.status(201).json({ category: {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
createdAt: category.createdAt,
|
||||
} });
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
return res.status(400).json({ error: 'Category name already exists' });
|
||||
}
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/bases', requireAuth, async (req, res) => {
|
||||
const bases = await prisma.base.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json({
|
||||
bases: bases.map((base) => ({
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
description: base.description || '',
|
||||
url: base.url || '',
|
||||
imageUrl: buildImageUrl(base),
|
||||
createdAt: base.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => {
|
||||
try {
|
||||
const { title, description, url, imageMode, imageUrl } = req.body;
|
||||
if (!title || !title.trim()) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
if (url && !isValidUrl(url)) {
|
||||
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
||||
}
|
||||
|
||||
let storedImageUrl = null;
|
||||
let storedImagePath = null;
|
||||
if (imageMode === 'url') {
|
||||
if (!imageUrl || !isValidUrl(imageUrl)) {
|
||||
return res.status(400).json({ error: 'Image URL must be valid' });
|
||||
}
|
||||
storedImageUrl = imageUrl;
|
||||
} else if (req.file) {
|
||||
storedImagePath = req.file.filename;
|
||||
}
|
||||
|
||||
const base = await prisma.base.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
url: url?.trim() || null,
|
||||
imageUrl: storedImageUrl,
|
||||
imagePath: storedImagePath,
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
base: {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
description: base.description || '',
|
||||
url: base.url || '',
|
||||
imageUrl: buildImageUrl(base),
|
||||
createdAt: base.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { baseId } = req.params;
|
||||
const { stars, percent, trophies, armyCategoryId } = req.body || {};
|
||||
|
||||
const parsedStars = Number(stars);
|
||||
const parsedPercent = Number(percent);
|
||||
const parsedTrophies = Number(trophies ?? 0);
|
||||
|
||||
if (!armyCategoryId) {
|
||||
return res.status(400).json({ error: 'Army category is required' });
|
||||
}
|
||||
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
|
||||
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
||||
}
|
||||
if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) {
|
||||
return res.status(400).json({ error: 'Percent must be between 0 and 100' });
|
||||
}
|
||||
if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) {
|
||||
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
||||
}
|
||||
|
||||
const [base, category] = await Promise.all([
|
||||
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
||||
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
||||
]);
|
||||
|
||||
if (!base) {
|
||||
return res.status(404).json({ error: 'Base not found' });
|
||||
}
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Army category not found' });
|
||||
}
|
||||
|
||||
await prisma.defense.create({
|
||||
data: {
|
||||
baseId: base.id,
|
||||
armyCategoryId: category.id,
|
||||
stars: parsedStars,
|
||||
percent: parsedPercent,
|
||||
trophies: parsedTrophies,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Defense logged' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/defenses', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
include: {
|
||||
armyCategories: true,
|
||||
bases: {
|
||||
include: {
|
||||
defenses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const categoryLookup = new Map(
|
||||
user.armyCategories.map((category) => [category.id, category.name])
|
||||
);
|
||||
|
||||
const defenses = [];
|
||||
const baseBuckets = new Map();
|
||||
const categoryBuckets = new Map();
|
||||
|
||||
user.bases.forEach((base) => {
|
||||
baseBuckets.set(base.id, {
|
||||
title: base.title,
|
||||
items: [],
|
||||
categories: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
user.armyCategories.forEach((category) => {
|
||||
categoryBuckets.set(category.id, {
|
||||
name: category.name,
|
||||
items: [],
|
||||
bases: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
user.bases.forEach((base) => {
|
||||
base.defenses.forEach((defense) => {
|
||||
defenses.push({
|
||||
id: defense.id,
|
||||
stars: defense.stars,
|
||||
percent: defense.percent,
|
||||
trophies: defense.trophies,
|
||||
armyCategoryId: defense.armyCategoryId,
|
||||
createdAt: defense.createdAt,
|
||||
baseId: base.id,
|
||||
baseTitle: base.title,
|
||||
});
|
||||
|
||||
const baseBucket = baseBuckets.get(base.id);
|
||||
baseBucket.items.push(defense);
|
||||
if (!baseBucket.categories.has(defense.armyCategoryId)) {
|
||||
baseBucket.categories.set(defense.armyCategoryId, []);
|
||||
}
|
||||
baseBucket.categories.get(defense.armyCategoryId).push(defense);
|
||||
|
||||
if (!categoryBuckets.has(defense.armyCategoryId)) {
|
||||
categoryBuckets.set(defense.armyCategoryId, {
|
||||
name: categoryLookup.get(defense.armyCategoryId) || 'Unknown Army',
|
||||
items: [],
|
||||
bases: new Map(),
|
||||
});
|
||||
}
|
||||
const categoryBucket = categoryBuckets.get(defense.armyCategoryId);
|
||||
categoryBucket.items.push({ ...defense, baseId: base.id, baseTitle: base.title });
|
||||
if (!categoryBucket.bases.has(base.id)) {
|
||||
categoryBucket.bases.set(base.id, []);
|
||||
}
|
||||
categoryBucket.bases.get(base.id).push(defense);
|
||||
});
|
||||
});
|
||||
|
||||
defenses.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
const bases = Array.from(baseBuckets.entries()).map(([baseId, bucket]) => {
|
||||
const summary = summarizeDefenses(bucket.items);
|
||||
const categories = Array.from(bucket.categories.entries()).map(([categoryId, items]) => ({
|
||||
categoryId,
|
||||
name: categoryLookup.get(categoryId) || 'Unknown Army',
|
||||
...summarizeDefenses(items),
|
||||
}));
|
||||
categories.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
return {
|
||||
baseId,
|
||||
title: bucket.title,
|
||||
...summary,
|
||||
categories,
|
||||
};
|
||||
});
|
||||
bases.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
const categories = Array.from(categoryBuckets.entries()).map(([categoryId, bucket]) => {
|
||||
const summary = summarizeDefenses(bucket.items);
|
||||
const baseSummaries = Array.from(bucket.bases.entries()).map(([baseId, items]) => ({
|
||||
baseId,
|
||||
title: baseBuckets.get(baseId)?.title || 'Unknown Base',
|
||||
...summarizeDefenses(items),
|
||||
}));
|
||||
baseSummaries.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title));
|
||||
return {
|
||||
categoryId,
|
||||
name: bucket.name,
|
||||
...summary,
|
||||
bases: baseSummaries,
|
||||
};
|
||||
});
|
||||
categories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({
|
||||
overall: summarizeDefenses(defenses),
|
||||
categories,
|
||||
bases,
|
||||
defenses,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
function summarizeDefenses(defenses) {
|
||||
if (!defenses.length) {
|
||||
return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 };
|
||||
}
|
||||
const totalStars = defenses.reduce((sum, defense) => sum + Number(defense.stars || 0), 0);
|
||||
const totalPercent = defenses.reduce((sum, defense) => sum + Number(defense.percent || 0), 0);
|
||||
const totalTrophies = defenses.reduce((sum, defense) => sum + Number(defense.trophies || 0), 0);
|
||||
const count = defenses.length;
|
||||
return {
|
||||
count,
|
||||
averageStars: Number((totalStars / count).toFixed(2)),
|
||||
averagePercent: Number((totalPercent / count).toFixed(2)),
|
||||
averageTrophies: Number((totalTrophies / count).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildImageUrl(base) {
|
||||
if (base.imageUrl) return base.imageUrl;
|
||||
if (base.imagePath) return `/uploads/${base.imagePath}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isValidUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(port, host, () => {
|
||||
console.log(`Backend listening on http://${host}:${port}`);
|
||||
});
|
||||
1
data/.gitignore
vendored
Normal file
1
data/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
app.sqlite
|
||||
3
data/db.json
Normal file
3
data/db.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"users": []
|
||||
}
|
||||
37
data/db.json.bak
Normal file
37
data/db.json.bak
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "ab1e6880-4c93-45b3-9185-5a8c98b32f3c",
|
||||
"username": "Hymmel",
|
||||
"passwordHash": "8043c7e474925b02315bf8816f9cc8b48b6ebed3f4dea9092b752fc83eb5660f125029c319aff93fc244481e30fa6b3dbeaf1c0ae874e4eef39af1367eb3a796",
|
||||
"salt": "5a0ed82ae26b6a42249ec48ae73ffd48",
|
||||
"armyCategories": [
|
||||
{
|
||||
"id": "01cbe19b-80ba-4622-af9c-e3a70fb2526d",
|
||||
"name": "RR RC SYeti"
|
||||
}
|
||||
],
|
||||
"bases": [
|
||||
{
|
||||
"id": "32a32a9e-b1a9-4a19-8016-cc03c28246cf",
|
||||
"title": "Test",
|
||||
"description": "TEST",
|
||||
"url": "https://link.clashofclans.com/en?action=OpenLayout&id=TH17%3AHV%3AAAAAWQAAAAHT5nPIKWgxertULWQqhZR-",
|
||||
"imageUrl": "https://cdn.discordapp.com/attachments/1332052152482267298/1425365252475846666/IMG_6415.png?ex=68e75256&is=68e600d6&hm=3b6d925b6a2716fd7583e1d75e5a72ee7f70e17e61180baac49c8de5ef2a2bc9&",
|
||||
"defenses": [
|
||||
{
|
||||
"id": "6960859b-428b-49a6-83dd-def368dd633a",
|
||||
"stars": 2,
|
||||
"percent": 78,
|
||||
"trophies": 15,
|
||||
"armyCategoryId": "01cbe19b-80ba-4622-af9c-e3a70fb2526d",
|
||||
"createdAt": "2025-10-08T12:51:29.657Z"
|
||||
}
|
||||
],
|
||||
"createdAt": "2025-10-08T12:51:09.996Z"
|
||||
}
|
||||
],
|
||||
"createdAt": "2025-10-08T12:48:07.366Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
docker-compose.backend.yml
Normal file
26
docker-compose.backend.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
environment:
|
||||
DATABASE_URL: file:./dev.db
|
||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy secrets}
|
||||
FRONTEND_ORIGIN: https://basetracker.lona-development.org
|
||||
COOKIE_SECURE: "true"
|
||||
volumes:
|
||||
- backend-data:/app/dev.db
|
||||
- backend-uploads:/app/uploads
|
||||
ports:
|
||||
- "4000:4000"
|
||||
expose:
|
||||
- "4000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.backend.rule=Host(`backend.basetracker.lona-development.org`)
|
||||
- traefik.http.routers.backend.entrypoints=websecure
|
||||
- traefik.http.routers.backend.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.backend.loadbalancer.server.port=4000
|
||||
|
||||
volumes:
|
||||
backend-data:
|
||||
backend-uploads:
|
||||
16
docker-compose.frontend.yml
Normal file
16
docker-compose.frontend.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
environment:
|
||||
NEXT_PUBLIC_BACKEND_URL: https://backend.basetracker.lona-development.org
|
||||
ports:
|
||||
- "3000:3000"
|
||||
expose:
|
||||
- "3000"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`basetracker.lona-development.org`)
|
||||
- traefik.http.routers.frontend.entrypoints=websecure
|
||||
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=3000
|
||||
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
Dockerfile
|
||||
.env
|
||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY next.config.js ./
|
||||
COPY next-env.d.ts ./
|
||||
COPY app ./app
|
||||
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
393
frontend/app/globals.css
Normal file
393
frontend/app/globals.css
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 2rem 1rem;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(960px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card h1,
|
||||
.card h2,
|
||||
.card h3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.image-mode {
|
||||
display: inline-flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-mode label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.form.compact label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-inner-spin-button,
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #38bdf8, #0369a1);
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 20px rgba(56, 189, 248, 0.25);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button.ghost.accent {
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
button.ghost.accent:hover {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
|
||||
button.ghost.accent.active {
|
||||
background: rgba(56, 189, 248, 0.24);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
min-height: 1.1rem;
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
#user-username {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.top-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.view-section {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.forms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list.compact {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list-item.clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.list-item.clickable:hover {
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.defense-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#defense-log-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.defense-meta {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#base-detail-view,
|
||||
#category-detail-view {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-created {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail-links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-links a {
|
||||
color: #38bdf8;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
margin-top: 1rem;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.detail-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.styled-number {
|
||||
appearance: textfield;
|
||||
padding-right: 2.75rem;
|
||||
background-image: linear-gradient(-135deg, rgba(56, 189, 248, 0) 50%, rgba(56, 189, 248, 0.8) 50%),
|
||||
linear-gradient(45deg, rgba(56, 189, 248, 0) 50%, rgba(56, 189, 248, 0.8) 50%);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 12px 12px;
|
||||
background-position: calc(100% - 0.85rem) 0.9rem, calc(100% - 0.85rem) calc(100% - 0.9rem);
|
||||
}
|
||||
|
||||
.styled-number:focus {
|
||||
border-color: rgba(56, 189, 248, 0.8);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
15
frontend/app/layout.tsx
Normal file
15
frontend/app/layout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Base Noter',
|
||||
description: 'Track how each Clash of Clans base defends against every army',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
833
frontend/app/page.tsx
Normal file
833
frontend/app/page.tsx
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
|
||||
const API = {
|
||||
signup: `${API_BASE}/auth/signup`,
|
||||
login: `${API_BASE}/auth/login`,
|
||||
logout: `${API_BASE}/auth/logout`,
|
||||
me: `${API_BASE}/auth/me`,
|
||||
categories: `${API_BASE}/army-categories`,
|
||||
bases: `${API_BASE}/bases`,
|
||||
addDefense: (baseId: string) => `${API_BASE}/bases/${baseId}/defenses`,
|
||||
defenses: `${API_BASE}/defenses`,
|
||||
};
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ArmyCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type BaseItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type DefenseItem = {
|
||||
id: string;
|
||||
stars: number;
|
||||
percent: number;
|
||||
trophies: number;
|
||||
armyCategoryId: string;
|
||||
createdAt: string;
|
||||
baseId: string;
|
||||
baseTitle: string;
|
||||
categoryName?: string;
|
||||
};
|
||||
|
||||
type Summary = {
|
||||
count: number;
|
||||
averageStars: number;
|
||||
averagePercent: number;
|
||||
averageTrophies: number;
|
||||
};
|
||||
|
||||
type BaseSummary = Summary & {
|
||||
baseId: string;
|
||||
title: string;
|
||||
categories: Array<Summary & { categoryId: string; name: string }>;
|
||||
};
|
||||
|
||||
type CategorySummary = Summary & {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
bases: Array<Summary & { baseId: string; title: string }>;
|
||||
};
|
||||
|
||||
type Summaries = {
|
||||
overall: Summary;
|
||||
categories: CategorySummary[];
|
||||
bases: BaseSummary[];
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
login: string;
|
||||
signup: string;
|
||||
category: string;
|
||||
base: string;
|
||||
defense: string;
|
||||
};
|
||||
|
||||
async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
|
||||
const isForm = options?.isForm ?? false;
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
credentials: 'include',
|
||||
};
|
||||
|
||||
if (isForm && body instanceof FormData) {
|
||||
fetchOptions.body = body;
|
||||
} else if (body !== undefined) {
|
||||
fetchOptions.headers = { 'Content-Type': 'application/json' };
|
||||
fetchOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const data = contentType.includes('application/json') ? await response.json() : {};
|
||||
if (!response.ok) {
|
||||
const message = (data && data.error) || 'Something went wrong';
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const initialErrors: ErrorState = {
|
||||
login: '',
|
||||
signup: '',
|
||||
category: '',
|
||||
base: '',
|
||||
defense: '',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [authTab, setAuthTab] = useState<'login' | 'signup'>('login');
|
||||
const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail'>('dashboard');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [categories, setCategories] = useState<ArmyCategory[]>([]);
|
||||
const [bases, setBases] = useState<BaseItem[]>([]);
|
||||
const [defenses, setDefenses] = useState<DefenseItem[]>([]);
|
||||
const [summaries, setSummaries] = useState<Summaries | null>(null);
|
||||
const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<ErrorState>(initialErrors);
|
||||
const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await request('GET', API.me);
|
||||
setUser(data.user);
|
||||
await refreshData();
|
||||
setView('dashboard');
|
||||
} catch (_err) {
|
||||
setUser(null);
|
||||
setView('dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function refreshData() {
|
||||
if (!user && !document.cookie.includes('token')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [basesRes, categoriesRes, defensesRes] = await Promise.all([
|
||||
request('GET', API.bases),
|
||||
request('GET', API.categories),
|
||||
request('GET', API.defenses),
|
||||
]);
|
||||
setBases(basesRes.bases || []);
|
||||
setCategories(categoriesRes.categories || []);
|
||||
setDefenses(defensesRes.defenses || []);
|
||||
setSummaries({
|
||||
overall: defensesRes.overall,
|
||||
categories: defensesRes.categories,
|
||||
bases: defensesRes.bases,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const baseSummaryMap = useMemo(() => {
|
||||
const map = new Map<string, BaseSummary>();
|
||||
summaries?.bases.forEach((baseSummary) => map.set(baseSummary.baseId, baseSummary));
|
||||
return map;
|
||||
}, [summaries]);
|
||||
|
||||
const categorySummaryMap = useMemo(() => {
|
||||
const map = new Map<string, CategorySummary>();
|
||||
summaries?.categories.forEach((categorySummary) => map.set(categorySummary.categoryId, categorySummary));
|
||||
return map;
|
||||
}, [summaries]);
|
||||
|
||||
async function handleLogin(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
setErrors((prev) => ({ ...prev, login: '' }));
|
||||
const data = await request('POST', API.login, payload);
|
||||
setUser(data.user);
|
||||
await refreshData();
|
||||
setView('dashboard');
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, login: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
if ((payload.password as string)?.length < 6) {
|
||||
throw new Error('Use at least 6 characters for your password.');
|
||||
}
|
||||
setErrors((prev) => ({ ...prev, signup: '' }));
|
||||
const data = await request('POST', API.signup, payload);
|
||||
setUser(data.user);
|
||||
await refreshData();
|
||||
setView('dashboard');
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, signup: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCategorySubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
setErrors((prev) => ({ ...prev, category: '' }));
|
||||
await request('POST', API.categories, payload);
|
||||
event.currentTarget.reset();
|
||||
await refreshData();
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, category: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBaseSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formData.set('imageMode', imageMode);
|
||||
if (imageMode === 'upload') {
|
||||
formData.delete('imageUrl');
|
||||
} else {
|
||||
formData.delete('imageFile');
|
||||
}
|
||||
try {
|
||||
setErrors((prev) => ({ ...prev, base: '' }));
|
||||
await request('POST', API.bases, formData, { isForm: true });
|
||||
event.currentTarget.reset();
|
||||
setImageMode('upload');
|
||||
await refreshData();
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, base: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDefenseSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
const baseId = payload.baseId as string;
|
||||
setErrors((prev) => ({ ...prev, defense: '' }));
|
||||
await request('POST', API.addDefense(baseId), payload);
|
||||
event.currentTarget.reset();
|
||||
await refreshData();
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, defense: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await request('POST', API.logout);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setBases([]);
|
||||
setCategories([]);
|
||||
setDefenses([]);
|
||||
setSummaries(null);
|
||||
setSelectedBaseId(null);
|
||||
setSelectedCategoryId(null);
|
||||
setView('dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
function openBaseDetail(baseId: string) {
|
||||
setSelectedBaseId(baseId);
|
||||
setView('baseDetail');
|
||||
}
|
||||
|
||||
function openCategoryDetail(categoryId: string) {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setView('categoryDetail');
|
||||
}
|
||||
|
||||
const baseDetail = selectedBaseId ? baseSummaryMap.get(selectedBaseId) : null;
|
||||
const baseDetailMeta = selectedBaseId ? bases.find((base) => base.id === selectedBaseId) : null;
|
||||
const categoryDetail = selectedCategoryId ? categorySummaryMap.get(selectedCategoryId) : null;
|
||||
|
||||
const categoryNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
categories.forEach((category) => map.set(category.id, category.name));
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
function formatTrophies(value: number) {
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value} trophies`;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="container">
|
||||
<section className="card">
|
||||
<h1>Loading Base Noter...</h1>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="container">
|
||||
<section id="auth-screen" className="card">
|
||||
<h1>Base Noter</h1>
|
||||
<p className="subtitle">Track how each base defends against every army.</p>
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab-button ${authTab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => setAuthTab('login')}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${authTab === 'signup' ? 'active' : ''}`}
|
||||
onClick={() => setAuthTab('signup')}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-wrapper">
|
||||
{authTab === 'login' ? (
|
||||
<form className="form auth-form active" onSubmit={handleLogin}>
|
||||
<label>
|
||||
Username
|
||||
<input name="username" type="text" required autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input name="password" type="password" required />
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Log In
|
||||
</button>
|
||||
<p className="form-error" data-for="login">
|
||||
{errors.login}
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<form className="form auth-form active" onSubmit={handleSignup}>
|
||||
<label>
|
||||
Username
|
||||
<input name="username" type="text" minLength={3} required autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input name="password" type="password" minLength={6} required autoComplete="new-password" />
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Create Account
|
||||
</button>
|
||||
<p className="form-error" data-for="signup">
|
||||
{errors.signup}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<section id="app-screen">
|
||||
<header className="top-bar">
|
||||
<div>
|
||||
<h1>Base Noter</h1>
|
||||
<span id="user-username">{user.username}</span>
|
||||
</div>
|
||||
<div className="top-bar-actions">
|
||||
<button
|
||||
id="dashboard-button"
|
||||
className={`ghost ${view === 'dashboard' ? 'hidden' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setView('dashboard')}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
id="new-button"
|
||||
className={`ghost accent small ${view === 'forms' ? 'active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setView('forms')}
|
||||
>
|
||||
New Entry
|
||||
</button>
|
||||
<button id="logout-button" className="ghost" type="button" onClick={handleLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="dashboard-view" className={`view-section ${view !== 'dashboard' ? 'hidden' : ''}`}>
|
||||
<section className="summary-grid">
|
||||
<div className="card">
|
||||
<h2>Overall Average</h2>
|
||||
<div className="stat" id="overall-summary">
|
||||
{summaries && summaries.overall.count ? (
|
||||
<>
|
||||
<strong>{summaries.overall.averageStars}★</strong> average •{' '}
|
||||
<strong>{summaries.overall.averagePercent}%</strong> destruction
|
||||
<br />
|
||||
<span className="badge">{formatTrophies(summaries.overall.averageTrophies)}</span>{' '}
|
||||
<span className="badge">{summaries.overall.count} attacks</span>
|
||||
</>
|
||||
) : (
|
||||
'No defenses logged yet.'
|
||||
)}
|
||||
</div>
|
||||
<div className="subsection">
|
||||
<h3>Base Averages</h3>
|
||||
<ul id="base-summary" className="list compact">
|
||||
{summaries && summaries.bases.length ? (
|
||||
summaries.bases.map((base) => (
|
||||
<li
|
||||
key={base.baseId}
|
||||
className="list-item clickable"
|
||||
onClick={() => openBaseDetail(base.baseId)}
|
||||
>
|
||||
<div className="defense-header">
|
||||
<strong>{base.title}</strong>
|
||||
<span className="badge">{base.count} defenses</span>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{base.averageStars}★ avg</span>
|
||||
<span>{base.averagePercent}% avg</span>
|
||||
<span>{formatTrophies(base.averageTrophies)} avg</span>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>
|
||||
{bases.length
|
||||
? 'Bases have defenses pending tracking.'
|
||||
: 'Add a base to start collecting its averages.'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Category Averages</h2>
|
||||
<ul id="category-summary" className="list">
|
||||
{summaries && summaries.categories.length ? (
|
||||
summaries.categories.map((category) => (
|
||||
<li
|
||||
key={category.categoryId}
|
||||
className="list-item clickable"
|
||||
onClick={() => openCategoryDetail(category.categoryId)}
|
||||
>
|
||||
<div className="defense-header">
|
||||
<strong>{category.name}</strong>
|
||||
<span className="badge">{category.count} attacks</span>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{category.averageStars}★ avg</span>
|
||||
<span>{category.averagePercent}% avg</span>
|
||||
<span>{formatTrophies(category.averageTrophies)} avg</span>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>Create an army category to start tracking.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card" id="defense-log-card">
|
||||
<h2>Defense Log</h2>
|
||||
<p className="subtitle">Newest entries appear on top.</p>
|
||||
<ul id="defense-list" className="list">
|
||||
{defenses.length ? (
|
||||
defenses.map((defense) => {
|
||||
const date = new Date(defense.createdAt);
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
<div>
|
||||
<strong>{defense.baseTitle}</strong>
|
||||
<span className="badge">{categoryName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{date.toLocaleString()}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li>No defenses recorded yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="forms-view" className={`forms-grid view-section ${view !== 'forms' ? 'hidden' : ''}`}>
|
||||
<div className="card">
|
||||
<h2>New Army Category</h2>
|
||||
<form id="category-form" className="form compact" onSubmit={handleCategorySubmit}>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Add Category
|
||||
</button>
|
||||
<p className="form-error" data-for="category">
|
||||
{errors.category}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>New Base</h2>
|
||||
<form id="base-form" className="form compact" onSubmit={handleBaseSubmit}>
|
||||
<label>
|
||||
Title
|
||||
<input type="text" name="title" required />
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea name="description" rows={2}></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Planning Link
|
||||
<input type="url" name="url" placeholder="https://" />
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-label">Image Source</span>
|
||||
<div className="image-mode">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="imageMode"
|
||||
value="upload"
|
||||
checked={imageMode === 'upload'}
|
||||
onChange={() => setImageMode('upload')}
|
||||
/>
|
||||
Upload
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="imageMode"
|
||||
value="url"
|
||||
checked={imageMode === 'url'}
|
||||
onChange={() => setImageMode('url')}
|
||||
/>
|
||||
URL
|
||||
</label>
|
||||
</div>
|
||||
{imageMode === 'upload' ? (
|
||||
<input type="file" name="imageFile" accept="image/*" />
|
||||
) : (
|
||||
<input type="url" name="imageUrl" placeholder="https://" required />
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="primary">
|
||||
Add Base
|
||||
</button>
|
||||
<p className="form-error" data-for="base">
|
||||
{errors.base}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Log Defense</h2>
|
||||
<form id="defense-form" className="form compact" onSubmit={handleDefenseSubmit}>
|
||||
<label>
|
||||
Base
|
||||
<select name="baseId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
{bases.length ? 'Select a base' : 'Add a base first'}
|
||||
</option>
|
||||
{bases.map((base) => (
|
||||
<option key={base.id} value={base.id}>
|
||||
{base.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Army Category
|
||||
<select name="armyCategoryId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
{categories.length ? 'Select an army category' : 'Add an army category first'}
|
||||
</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Stars
|
||||
<input type="number" name="stars" min={0} max={3} step={1} required className="styled-number" />
|
||||
</label>
|
||||
<label>
|
||||
Destruction %
|
||||
<input type="number" name="percent" min={0} max={100} step={1} required className="styled-number" />
|
||||
</label>
|
||||
<label>
|
||||
Trophies ±
|
||||
<input
|
||||
type="number"
|
||||
name="trophies"
|
||||
min={-200}
|
||||
max={200}
|
||||
step={1}
|
||||
defaultValue={0}
|
||||
required
|
||||
className="styled-number"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Record Defense
|
||||
</button>
|
||||
<p className="form-error" data-for="defense">
|
||||
{errors.defense}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}>
|
||||
<div className="card">
|
||||
<div className="detail-header">
|
||||
<button className="ghost" type="button" onClick={() => setView('dashboard')}>
|
||||
Back
|
||||
</button>
|
||||
<span id="base-detail-created" className="detail-created">
|
||||
{baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="base-detail-title">{baseDetailMeta?.title}</h2>
|
||||
<p id="base-detail-description" className={baseDetailMeta?.description ? '' : 'muted'}>
|
||||
{baseDetailMeta?.description || 'No description yet.'}
|
||||
</p>
|
||||
<div id="base-detail-links" className={`detail-links ${baseDetailMeta?.url ? '' : 'hidden'}`}>
|
||||
{baseDetailMeta?.url && (
|
||||
<a href={baseDetailMeta.url} target="_blank" rel="noopener noreferrer">
|
||||
Open planning link
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
id="base-detail-image-wrapper"
|
||||
className={`detail-image ${baseDetailMeta?.imageUrl ? '' : 'hidden'}`}
|
||||
>
|
||||
{baseDetailMeta?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={baseDetailMeta.imageUrl} alt={`Preview of ${baseDetailMeta.title}`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Base Averages</h3>
|
||||
<div id="base-detail-stats" className="stat">
|
||||
{baseDetail && baseDetail.count ? (
|
||||
<>
|
||||
<strong>{baseDetail.averageStars}★</strong> average •{' '}
|
||||
<strong>{baseDetail.averagePercent}%</strong> destruction
|
||||
<br />
|
||||
<span className="badge">{formatTrophies(baseDetail.averageTrophies)} avg</span>{' '}
|
||||
<span className="badge">{baseDetail.count} defenses</span>
|
||||
</>
|
||||
) : (
|
||||
'No defenses logged yet.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Army Categories vs This Base</h3>
|
||||
<ul id="base-detail-categories" className="list">
|
||||
{baseDetail && baseDetail.categories.length ? (
|
||||
baseDetail.categories.map((category) => (
|
||||
<li
|
||||
key={category.categoryId}
|
||||
className="list-item clickable"
|
||||
onClick={() => openCategoryDetail(category.categoryId)}
|
||||
>
|
||||
<div className="defense-header">
|
||||
<strong>{category.name}</strong>
|
||||
<span className="badge">{category.count} attacks</span>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{category.averageStars}★ avg</span>
|
||||
<span>{category.averagePercent}% avg</span>
|
||||
<span>{formatTrophies(category.averageTrophies)} avg</span>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>No army categories have attacked this base yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Defenses</h3>
|
||||
<ul id="base-detail-defenses" className="list">
|
||||
{defenses.filter((defense) => defense.baseId === selectedBaseId).length ? (
|
||||
defenses
|
||||
.filter((defense) => defense.baseId === selectedBaseId)
|
||||
.map((defense) => {
|
||||
const date = new Date(defense.createdAt);
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
<strong>{categoryName}</strong>
|
||||
<div>
|
||||
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{date.toLocaleString()}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li>No defenses recorded for this base yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="category-detail-view" className={`view-section ${view !== 'categoryDetail' ? 'hidden' : ''}`}>
|
||||
<div className="card">
|
||||
<div className="detail-header">
|
||||
<button className="ghost" type="button" onClick={() => setView('dashboard')}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<h2 id="category-detail-title">{categoryDetail?.name}</h2>
|
||||
<p id="category-detail-description" className="muted">
|
||||
Average performance of this army across your bases.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Category Averages</h3>
|
||||
<div id="category-detail-stats" className="stat">
|
||||
{categoryDetail && categoryDetail.count ? (
|
||||
<>
|
||||
<strong>{categoryDetail.averageStars}★</strong> average •{' '}
|
||||
<strong>{categoryDetail.averagePercent}%</strong> destruction
|
||||
<br />
|
||||
<span className="badge">{formatTrophies(categoryDetail.averageTrophies)} avg</span>{' '}
|
||||
<span className="badge">{categoryDetail.count} attacks</span>
|
||||
</>
|
||||
) : (
|
||||
'No defenses logged yet.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Performance by Base</h3>
|
||||
<ul id="category-detail-bases" className="list">
|
||||
{categoryDetail && categoryDetail.bases.length ? (
|
||||
categoryDetail.bases.map((base) => (
|
||||
<li
|
||||
key={base.baseId}
|
||||
className="list-item clickable"
|
||||
onClick={() => openBaseDetail(base.baseId)}
|
||||
>
|
||||
<div className="defense-header">
|
||||
<strong>{base.title}</strong>
|
||||
<span className="badge">{base.count} defenses</span>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{base.averageStars}★ avg</span>
|
||||
<span>{base.averagePercent}% avg</span>
|
||||
<span>{formatTrophies(base.averageTrophies)} avg</span>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>This army has not attacked any bases yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Defenses</h3>
|
||||
<ul id="category-detail-defenses" className="list">
|
||||
{defenses.filter((defense) => defense.armyCategoryId === selectedCategoryId).length ? (
|
||||
defenses
|
||||
.filter((defense) => defense.armyCategoryId === selectedCategoryId)
|
||||
.map((defense) => {
|
||||
const date = new Date(defense.createdAt);
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
<strong>{defense.baseTitle}</strong>
|
||||
<div>
|
||||
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="defense-meta">
|
||||
<span>{date.toLocaleString()}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li>No logged defenses for this army yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
15
frontend/next.config.js
Normal file
15
frontend/next.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '4100',
|
||||
pathname: '/uploads/**'
|
||||
}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "base-noter-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.12.7",
|
||||
"@types/react": "18.2.66",
|
||||
"@types/react-dom": "18.2.22",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue