This commit is contained in:
Hymmel 2025-10-09 09:16:31 +02:00
commit 1031aed4fd
21 changed files with 2103 additions and 0 deletions

101
README.md Normal file
View 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
View file

@ -0,0 +1,5 @@
node_modules
npm-debug.log
Dockerfile
.env
uploads

4
backend/.env.example Normal file
View 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
View 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
View 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"
}
}

View 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
View 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
View file

@ -0,0 +1 @@
app.sqlite

3
data/db.json Normal file
View file

@ -0,0 +1,3 @@
{
"users": []
}

37
data/db.json.bak Normal file
View 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"
}
]
}

View 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:

View 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
View file

@ -0,0 +1,5 @@
node_modules
npm-debug.log
.next
Dockerfile
.env

17
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}