commit 1031aed4fd29e0bcc4fe3794fa9b09ea28a00dd6 Author: Hymmel Date: Thu Oct 9 09:16:31 2025 +0200 Init diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f5b8ca --- /dev/null +++ b/README.md @@ -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:///uploads/`. 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. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8e75578 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +Dockerfile +.env +uploads diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9fcab02 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL="file:./dev.db" +JWT_SECRET="change-me" +FRONTEND_ORIGIN="http://localhost:3100" +COOKIE_SECURE="false" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c91e5bf --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..639f67e --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..2c2615c --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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 +} diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..6a95c96 --- /dev/null +++ b/backend/src/server.js @@ -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}`); +}); diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..f136af4 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +app.sqlite diff --git a/data/db.json b/data/db.json new file mode 100644 index 0000000..ea1ebff --- /dev/null +++ b/data/db.json @@ -0,0 +1,3 @@ +{ + "users": [] +} diff --git a/data/db.json.bak b/data/db.json.bak new file mode 100644 index 0000000..6adf2f4 --- /dev/null +++ b/data/db.json.bak @@ -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" + } + ] +} \ No newline at end of file diff --git a/docker-compose.backend.yml b/docker-compose.backend.yml new file mode 100644 index 0000000..0e9b389 --- /dev/null +++ b/docker-compose.backend.yml @@ -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: diff --git a/docker-compose.frontend.yml b/docker-compose.frontend.yml new file mode 100644 index 0000000..d8134e9 --- /dev/null +++ b/docker-compose.frontend.yml @@ -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 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7ac5a9f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.next +Dockerfile +.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e8e32ca --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..06f494c --- /dev/null +++ b/frontend/app/globals.css @@ -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%; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..2aee197 --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..32dd66b --- /dev/null +++ b/frontend/app/page.tsx @@ -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; +}; + +type CategorySummary = Summary & { + categoryId: string; + name: string; + bases: Array; +}; + +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(null); + const [categories, setCategories] = useState([]); + const [bases, setBases] = useState([]); + const [defenses, setDefenses] = useState([]); + const [summaries, setSummaries] = useState(null); + const [selectedBaseId, setSelectedBaseId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [errors, setErrors] = useState(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(); + summaries?.bases.forEach((baseSummary) => map.set(baseSummary.baseId, baseSummary)); + return map; + }, [summaries]); + + const categorySummaryMap = useMemo(() => { + const map = new Map(); + summaries?.categories.forEach((categorySummary) => map.set(categorySummary.categoryId, categorySummary)); + return map; + }, [summaries]); + + async function handleLogin(event: FormEvent) { + 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) { + 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) { + 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) { + 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) { + 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(); + 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 ( +
+
+

Loading Base Noter...

+
+
+ ); + } + + if (!user) { + return ( +
+
+

Base Noter

+

Track how each base defends against every army.

+
+ + +
+
+ {authTab === 'login' ? ( +
+ + + +

+ {errors.login} +

+
+ ) : ( +
+ + + +

+ {errors.signup} +

+
+ )} +
+
+
+ ); + } + + return ( +
+
+
+
+

Base Noter

+ {user.username} +
+
+ + + +
+
+ +
+
+
+

Overall Average

+
+ {summaries && summaries.overall.count ? ( + <> + {summaries.overall.averageStars}★ average •{' '} + {summaries.overall.averagePercent}% destruction +
+ {formatTrophies(summaries.overall.averageTrophies)}{' '} + {summaries.overall.count} attacks + + ) : ( + 'No defenses logged yet.' + )} +
+
+

Base Averages

+
    + {summaries && summaries.bases.length ? ( + summaries.bases.map((base) => ( +
  • openBaseDetail(base.baseId)} + > +
    + {base.title} + {base.count} defenses +
    +
    + {base.averageStars}★ avg + {base.averagePercent}% avg + {formatTrophies(base.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • + {bases.length + ? 'Bases have defenses pending tracking.' + : 'Add a base to start collecting its averages.'} +
  • + )} +
+
+
+
+

Category Averages

+
    + {summaries && summaries.categories.length ? ( + summaries.categories.map((category) => ( +
  • openCategoryDetail(category.categoryId)} + > +
    + {category.name} + {category.count} attacks +
    +
    + {category.averageStars}★ avg + {category.averagePercent}% avg + {formatTrophies(category.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • Create an army category to start tracking.
  • + )} +
+
+
+ +
+

Defense Log

+

Newest entries appear on top.

+
    + {defenses.length ? ( + defenses.map((defense) => { + const date = new Date(defense.createdAt); + const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; + return ( +
  • +
    +
    + {defense.baseTitle} + {categoryName} +
    +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No defenses recorded yet.
  • + )} +
+
+
+ +
+
+

New Army Category

+
+ + +

+ {errors.category} +

+
+
+
+

New Base

+
+ + + +
+ Image Source +
+ + +
+ {imageMode === 'upload' ? ( + + ) : ( + + )} +
+ +

+ {errors.base} +

+
+
+
+

Log Defense

+
+ + + + + + +

+ {errors.defense} +

+
+
+
+ +
+
+
+ + + {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} + +
+

{baseDetailMeta?.title}

+

+ {baseDetailMeta?.description || 'No description yet.'} +

+ +
+ {baseDetailMeta?.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {`Preview + )} +
+
+
+

Base Averages

+
+ {baseDetail && baseDetail.count ? ( + <> + {baseDetail.averageStars}★ average •{' '} + {baseDetail.averagePercent}% destruction +
+ {formatTrophies(baseDetail.averageTrophies)} avg{' '} + {baseDetail.count} defenses + + ) : ( + 'No defenses logged yet.' + )} +
+
+
+

Army Categories vs This Base

+
    + {baseDetail && baseDetail.categories.length ? ( + baseDetail.categories.map((category) => ( +
  • openCategoryDetail(category.categoryId)} + > +
    + {category.name} + {category.count} attacks +
    +
    + {category.averageStars}★ avg + {category.averagePercent}% avg + {formatTrophies(category.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • No army categories have attacked this base yet.
  • + )} +
+
+
+

Defenses

+
    + {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 ( +
  • +
    + {categoryName} +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No defenses recorded for this base yet.
  • + )} +
+
+
+ +
+
+
+ +
+

{categoryDetail?.name}

+

+ Average performance of this army across your bases. +

+
+
+

Category Averages

+
+ {categoryDetail && categoryDetail.count ? ( + <> + {categoryDetail.averageStars}★ average •{' '} + {categoryDetail.averagePercent}% destruction +
+ {formatTrophies(categoryDetail.averageTrophies)} avg{' '} + {categoryDetail.count} attacks + + ) : ( + 'No defenses logged yet.' + )} +
+
+
+

Performance by Base

+
    + {categoryDetail && categoryDetail.bases.length ? ( + categoryDetail.bases.map((base) => ( +
  • openBaseDetail(base.baseId)} + > +
    + {base.title} + {base.count} defenses +
    +
    + {base.averageStars}★ avg + {base.averagePercent}% avg + {formatTrophies(base.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • This army has not attacked any bases yet.
  • + )} +
+
+
+

Defenses

+
    + {defenses.filter((defense) => defense.armyCategoryId === selectedCategoryId).length ? ( + defenses + .filter((defense) => defense.armyCategoryId === selectedCategoryId) + .map((defense) => { + const date = new Date(defense.createdAt); + return ( +
  • +
    + {defense.baseTitle} +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No logged defenses for this army yet.
  • + )} +
+
+
+
+
+ ); +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..7d28a51 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'localhost', + port: '4100', + pathname: '/uploads/**' + } + ], + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b62f93c --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8930545 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +}