Compare commits
11 commits
df76a1f0f2
...
a3410d2444
| Author | SHA1 | Date | |
|---|---|---|---|
| a3410d2444 | |||
| f01d77fde7 | |||
| e59f917c46 | |||
| 59c8656328 | |||
| 88403c1017 | |||
| 650acd8a72 | |||
| aac31071bf | |||
| a7d32bd4c1 | |||
| f6f5e474a6 | |||
| dcb0090196 | |||
| b466e585a1 |
6 changed files with 584 additions and 131 deletions
|
|
@ -39,6 +39,18 @@ model Base {
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId String
|
userId String
|
||||||
defenses Defense[]
|
defenses Defense[]
|
||||||
|
trophyResets TrophyReset[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrophyReset {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
date DateTime
|
||||||
|
trophiesAtStart Int
|
||||||
|
trophiesLost Int
|
||||||
|
numberOfDefenses Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
base Base @relation(fields: [baseId], references: [id])
|
||||||
|
baseId String
|
||||||
}
|
}
|
||||||
|
|
||||||
model Defense {
|
model Defense {
|
||||||
|
|
@ -49,6 +61,6 @@ model Defense {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
base Base @relation(fields: [baseId], references: [id])
|
base Base @relation(fields: [baseId], references: [id])
|
||||||
baseId String
|
baseId String
|
||||||
armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id])
|
armyCategory ArmyCategory? @relation(fields: [armyCategoryId], references: [id])
|
||||||
armyCategoryId String
|
armyCategoryId String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,11 @@ app.get('/bases', requireAuth, async (req, res) => {
|
||||||
const bases = await prisma.base.findMany({
|
const bases = await prisma.base.findMany({
|
||||||
where: { userId: req.user.id },
|
where: { userId: req.user.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
trophyResets: {
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
bases: bases.map((base) => ({
|
bases: bases.map((base) => ({
|
||||||
|
|
@ -281,6 +286,14 @@ app.get('/bases', requireAuth, async (req, res) => {
|
||||||
imageUrl: buildImageUrl(base),
|
imageUrl: buildImageUrl(base),
|
||||||
isPrivate: base.isPrivate,
|
isPrivate: base.isPrivate,
|
||||||
createdAt: base.createdAt,
|
createdAt: base.createdAt,
|
||||||
|
trophyResets: base.trophyResets.map((reset) => ({
|
||||||
|
id: reset.id,
|
||||||
|
date: reset.date,
|
||||||
|
trophiesAtStart: reset.trophiesAtStart,
|
||||||
|
trophiesLost: reset.trophiesLost,
|
||||||
|
numberOfDefenses: reset.numberOfDefenses,
|
||||||
|
createdAt: reset.createdAt,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -480,6 +493,51 @@ app.delete('/bases/:baseId', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/bases/:baseId/trophy-resets', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { baseId } = req.params;
|
||||||
|
const { date, trophiesAtStart, trophiesLost, numberOfDefenses } = req.body || {};
|
||||||
|
|
||||||
|
const parsedDate = new Date(date);
|
||||||
|
const parsedTrophiesAtStart = Number(trophiesAtStart);
|
||||||
|
const parsedTrophiesLost = Number(trophiesLost);
|
||||||
|
const parsedNumberOfDefenses = Number(numberOfDefenses);
|
||||||
|
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
return res.status(400).json({ error: 'Invalid date' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedTrophiesAtStart) || parsedTrophiesAtStart < 0) {
|
||||||
|
return res.status(400).json({ error: 'Trophies at start must be a positive number' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedTrophiesLost)) {
|
||||||
|
return res.status(400).json({ error: 'Trophies lost must be a number' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedNumberOfDefenses) || parsedNumberOfDefenses < 0) {
|
||||||
|
return res.status(400).json({ error: 'Number of defenses must be a positive number' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
|
if (!base) {
|
||||||
|
return res.status(404).json({ error: 'Base not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.trophyReset.create({
|
||||||
|
data: {
|
||||||
|
baseId: base.id,
|
||||||
|
date: parsedDate,
|
||||||
|
trophiesAtStart: parsedTrophiesAtStart,
|
||||||
|
trophiesLost: parsedTrophiesLost,
|
||||||
|
numberOfDefenses: parsedNumberOfDefenses,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ message: 'Trophy reset logged' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { baseId } = req.params;
|
const { baseId } = req.params;
|
||||||
|
|
@ -489,9 +547,6 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||||
const parsedPercent = Number(percent);
|
const parsedPercent = Number(percent);
|
||||||
const parsedTrophies = Number(trophies ?? 0);
|
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) {
|
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
|
||||||
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
||||||
}
|
}
|
||||||
|
|
@ -502,22 +557,25 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [base, category] = await Promise.all([
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
|
||||||
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return res.status(404).json({ error: 'Base not found' });
|
return res.status(404).json({ error: 'Base not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let category = null;
|
||||||
|
if (armyCategoryId) {
|
||||||
|
category = await prisma.armyCategory.findFirst({
|
||||||
|
where: { id: armyCategoryId, userId: req.user.id },
|
||||||
|
});
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return res.status(404).json({ error: 'Army category not found' });
|
return res.status(404).json({ error: 'Army category not found' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.defense.create({
|
await prisma.defense.create({
|
||||||
data: {
|
data: {
|
||||||
baseId: base.id,
|
baseId: base.id,
|
||||||
armyCategoryId: category.id,
|
armyCategoryId: category ? category.id : null,
|
||||||
stars: parsedStars,
|
stars: parsedStars,
|
||||||
percent: parsedPercent,
|
percent: parsedPercent,
|
||||||
trophies: parsedTrophies,
|
trophies: parsedTrophies,
|
||||||
|
|
@ -531,6 +589,81 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/trophy-resets/:resetId', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resetId } = req.params;
|
||||||
|
const { date, trophiesAtStart, trophiesLost, numberOfDefenses, baseId } = req.body || {};
|
||||||
|
|
||||||
|
const parsedDate = new Date(date);
|
||||||
|
const parsedTrophiesAtStart = Number(trophiesAtStart);
|
||||||
|
const parsedTrophiesLost = Number(trophiesLost);
|
||||||
|
const parsedNumberOfDefenses = Number(numberOfDefenses);
|
||||||
|
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
return res.status(400).json({ error: 'Invalid date' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedTrophiesAtStart) || parsedTrophiesAtStart < 0) {
|
||||||
|
return res.status(400).json({ error: 'Trophies at start must be a positive number' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedTrophiesLost)) {
|
||||||
|
return res.status(400).json({ error: 'Trophies lost must be a number' });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(parsedNumberOfDefenses) || parsedNumberOfDefenses < 0) {
|
||||||
|
return res.status(400).json({ error: 'Number of defenses must be a positive number' });
|
||||||
|
}
|
||||||
|
if (!baseId) {
|
||||||
|
return res.status(400).json({ error: 'Base is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = await prisma.trophyReset.findFirst({
|
||||||
|
where: { id: resetId, base: { userId: req.user.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reset) {
|
||||||
|
return res.status(404).json({ error: 'Trophy reset not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
|
if (!base) {
|
||||||
|
return res.status(404).json({ error: 'Base not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.trophyReset.update({
|
||||||
|
where: { id: reset.id },
|
||||||
|
data: {
|
||||||
|
date: parsedDate,
|
||||||
|
trophiesAtStart: parsedTrophiesAtStart,
|
||||||
|
trophiesLost: parsedTrophiesLost,
|
||||||
|
numberOfDefenses: parsedNumberOfDefenses,
|
||||||
|
baseId: base.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ message: 'Trophy reset updated' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/trophy-resets/:resetId', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { resetId } = req.params;
|
||||||
|
const result = await prisma.trophyReset.deleteMany({
|
||||||
|
where: { id: resetId, base: { userId: req.user.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.count) {
|
||||||
|
return res.status(404).json({ error: 'Trophy reset not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ message: 'Trophy reset deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { defenseId } = req.params;
|
const { defenseId } = req.params;
|
||||||
|
|
@ -539,9 +672,6 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
||||||
if (!baseId) {
|
if (!baseId) {
|
||||||
return res.status(400).json({ error: 'Base is required' });
|
return res.status(400).json({ error: 'Base is required' });
|
||||||
}
|
}
|
||||||
if (!armyCategoryId) {
|
|
||||||
return res.status(400).json({ error: 'Army category is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedStars = Number(stars);
|
const parsedStars = Number(stars);
|
||||||
const parsedPercent = Number(percent);
|
const parsedPercent = Number(percent);
|
||||||
|
|
@ -557,29 +687,34 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [defense, base, category] = await Promise.all([
|
const defense = await prisma.defense.findFirst({
|
||||||
prisma.defense.findFirst({
|
|
||||||
where: { id: defenseId, base: { userId: req.user.id } },
|
where: { id: defenseId, base: { userId: req.user.id } },
|
||||||
}),
|
});
|
||||||
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
|
||||||
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!defense) {
|
if (!defense) {
|
||||||
return res.status(404).json({ error: 'Defense not found' });
|
return res.status(404).json({ error: 'Defense not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return res.status(404).json({ error: 'Base not found' });
|
return res.status(404).json({ error: 'Base not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let category = null;
|
||||||
|
if (armyCategoryId) {
|
||||||
|
category = await prisma.armyCategory.findFirst({
|
||||||
|
where: { id: armyCategoryId, userId: req.user.id },
|
||||||
|
});
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return res.status(404).json({ error: 'Army category not found' });
|
return res.status(404).json({ error: 'Army category not found' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.defense.update({
|
await prisma.defense.update({
|
||||||
where: { id: defense.id },
|
where: { id: defense.id },
|
||||||
data: {
|
data: {
|
||||||
baseId: base.id,
|
baseId: base.id,
|
||||||
armyCategoryId: category.id,
|
armyCategoryId: armyCategoryId ? category.id : null,
|
||||||
stars: parsedStars,
|
stars: parsedStars,
|
||||||
percent: parsedPercent,
|
percent: parsedPercent,
|
||||||
trophies: parsedTrophies,
|
trophies: parsedTrophies,
|
||||||
|
|
@ -628,10 +763,16 @@ app.get('/defenses', requireAuth, async (req, res) => {
|
||||||
const categoryLookup = new Map(
|
const categoryLookup = new Map(
|
||||||
user.armyCategories.map((category) => [category.id, category.name])
|
user.armyCategories.map((category) => [category.id, category.name])
|
||||||
);
|
);
|
||||||
|
categoryLookup.set(null, '(No category)');
|
||||||
|
|
||||||
const defenses = [];
|
const defenses = [];
|
||||||
const baseBuckets = new Map();
|
const baseBuckets = new Map();
|
||||||
const categoryBuckets = new Map();
|
const categoryBuckets = new Map();
|
||||||
|
categoryBuckets.set(null, {
|
||||||
|
name: '(No category)',
|
||||||
|
items: [],
|
||||||
|
bases: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
user.bases.forEach((base) => {
|
user.bases.forEach((base) => {
|
||||||
baseBuckets.set(base.id, {
|
baseBuckets.set(base.id, {
|
||||||
|
|
@ -813,6 +954,9 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trophyResets: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -824,7 +968,15 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
||||||
trophies: defense.trophies,
|
trophies: defense.trophies,
|
||||||
createdAt: defense.createdAt,
|
createdAt: defense.createdAt,
|
||||||
armyCategoryId: defense.armyCategoryId,
|
armyCategoryId: defense.armyCategoryId,
|
||||||
armyCategoryName: defense.armyCategory?.name || 'Unknown Army',
|
armyCategoryName: defense.armyCategory?.name || '(No category)',
|
||||||
|
}));
|
||||||
|
const trophyResets = base.trophyResets.map((reset) => ({
|
||||||
|
id: reset.id,
|
||||||
|
date: reset.date,
|
||||||
|
trophiesAtStart: reset.trophiesAtStart,
|
||||||
|
trophiesLost: reset.trophiesLost,
|
||||||
|
numberOfDefenses: reset.numberOfDefenses,
|
||||||
|
createdAt: reset.createdAt,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
id: base.id,
|
id: base.id,
|
||||||
|
|
@ -836,6 +988,7 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
||||||
createdAt: base.createdAt,
|
createdAt: base.createdAt,
|
||||||
summary: summarizeDefenses(defenses),
|
summary: summarizeDefenses(defenses),
|
||||||
defenses,
|
defenses,
|
||||||
|
trophyResets,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,10 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in Dokploy secrets}
|
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in Dokploy secrets}
|
||||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy secrets}
|
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy secrets}
|
||||||
FRONTEND_ORIGIN: https://basetracker.lona-development.org
|
FRONTEND_ORIGINS: https://basetracker.lona-development.org,https://dev.basetracker.lona-development.org
|
||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- backend-uploads:/app/uploads
|
- backend-uploads:/app/uploads
|
||||||
ports:
|
|
||||||
- "4000:4000"
|
|
||||||
expose:
|
|
||||||
- "4000"
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.backend.rule=Host(`backend.basetracker.lona-development.org`)
|
- traefik.http.routers.backend.rule=Host(`backend.basetracker.lona-development.org`)
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,6 @@ services:
|
||||||
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
|
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
|
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-https://backend.basetracker.lona-development.org}
|
||||||
ports:
|
|
||||||
- "3100:3000"
|
|
||||||
expose:
|
|
||||||
- "31000"
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.frontend.rule=Host(`basetracker.lona-development.org`)
|
- traefik.http.routers.frontend.rule=Host(`basetracker.lona-development.org`)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
const defaultApiBase =
|
const defaultApiBase =
|
||||||
process.env.NODE_ENV === 'production'
|
process.env.NODE_ENV === 'production'
|
||||||
? 'https://backend.basetracker.lona-development.org'
|
? 'https://backend.basetracker.lona-development.org'
|
||||||
: 'http://localhost:4100';
|
: 'https://backend.dev.basetracker.lona-development.org';
|
||||||
|
|
||||||
// Normalize backend URL so production always uses HTTPS while keeping local HTTP.
|
// Normalize backend URL so production always uses HTTPS while keeping local HTTP.
|
||||||
function normalizeApiBase(url: string) {
|
function normalizeApiBase(url: string) {
|
||||||
|
|
@ -35,6 +35,9 @@ const API = {
|
||||||
deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`,
|
deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`,
|
||||||
updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
||||||
deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
||||||
|
addTrophyReset: (baseId: string) => `${API_BASE}/bases/${baseId}/trophy-resets`,
|
||||||
|
updateTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
|
||||||
|
deleteTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
|
||||||
updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
||||||
deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
||||||
profiles: `${API_BASE}/profiles`,
|
profiles: `${API_BASE}/profiles`,
|
||||||
|
|
@ -64,6 +67,15 @@ type ArmyCategory = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TrophyReset = {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
trophiesAtStart: number;
|
||||||
|
trophiesLost: number;
|
||||||
|
numberOfDefenses: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseItem = {
|
type BaseItem = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -72,6 +84,7 @@ type BaseItem = {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
trophyResets: TrophyReset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type DefenseItem = {
|
type DefenseItem = {
|
||||||
|
|
@ -114,6 +127,7 @@ type ProfileBase = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
summary: Summary;
|
summary: Summary;
|
||||||
defenses: ProfileDefense[];
|
defenses: ProfileDefense[];
|
||||||
|
trophyResets: TrophyReset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProfileCategorySummary = Summary & {
|
type ProfileCategorySummary = Summary & {
|
||||||
|
|
@ -165,6 +179,7 @@ type ErrorState = {
|
||||||
category: string;
|
category: string;
|
||||||
base: string;
|
base: string;
|
||||||
defense: string;
|
defense: string;
|
||||||
|
trophyReset: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
|
async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
|
||||||
|
|
@ -197,6 +212,7 @@ const initialErrors: ErrorState = {
|
||||||
category: '',
|
category: '',
|
||||||
base: '',
|
base: '',
|
||||||
defense: '',
|
defense: '',
|
||||||
|
trophyReset: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|
@ -216,6 +232,7 @@ export default function Page() {
|
||||||
const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep');
|
const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep');
|
||||||
const [editingBaseId, setEditingBaseId] = useState<string | null>(null);
|
const [editingBaseId, setEditingBaseId] = useState<string | null>(null);
|
||||||
const [editingDefenseId, setEditingDefenseId] = useState<string | null>(null);
|
const [editingDefenseId, setEditingDefenseId] = useState<string | null>(null);
|
||||||
|
const [editingTrophyResetId, setEditingTrophyResetId] = useState<string | null>(null);
|
||||||
const [profileSearchTerm, setProfileSearchTerm] = useState('');
|
const [profileSearchTerm, setProfileSearchTerm] = useState('');
|
||||||
const [profileResults, setProfileResults] = useState<ProfileSummaryItem[]>([]);
|
const [profileResults, setProfileResults] = useState<ProfileSummaryItem[]>([]);
|
||||||
const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null);
|
const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null);
|
||||||
|
|
@ -292,6 +309,12 @@ export default function Page() {
|
||||||
return defenses.find((defense) => defense.id === editingDefenseId) ?? null;
|
return defenses.find((defense) => defense.id === editingDefenseId) ?? null;
|
||||||
}, [editingDefenseId, defenses]);
|
}, [editingDefenseId, defenses]);
|
||||||
|
|
||||||
|
const trophyResetBeingEdited = useMemo(() => {
|
||||||
|
if (!editingTrophyResetId) return null;
|
||||||
|
const allResets = bases.flatMap((base) => base.trophyResets);
|
||||||
|
return allResets.find((reset) => reset.id === editingTrophyResetId) ?? null;
|
||||||
|
}, [editingTrophyResetId, bases]);
|
||||||
|
|
||||||
const profileSelectedBaseCategories = useMemo(() => {
|
const profileSelectedBaseCategories = useMemo(() => {
|
||||||
if (!profileSelectedBase) {
|
if (!profileSelectedBase) {
|
||||||
return [] as ProfileCategorySummary[];
|
return [] as ProfileCategorySummary[];
|
||||||
|
|
@ -300,7 +323,7 @@ export default function Page() {
|
||||||
profileSelectedBase.defenses.forEach((defense) => {
|
profileSelectedBase.defenses.forEach((defense) => {
|
||||||
const key = defense.armyCategoryId || defense.armyCategoryName;
|
const key = defense.armyCategoryId || defense.armyCategoryName;
|
||||||
if (!buckets.has(key)) {
|
if (!buckets.has(key)) {
|
||||||
buckets.set(key, { name: defense.armyCategoryName || 'Unknown Army', items: [] });
|
buckets.set(key, { name: defense.armyCategoryName || '(No category)', items: [] });
|
||||||
}
|
}
|
||||||
buckets.get(key)!.items.push(defense);
|
buckets.get(key)!.items.push(defense);
|
||||||
});
|
});
|
||||||
|
|
@ -502,6 +525,15 @@ export default function Page() {
|
||||||
setEditingDefenseId(null);
|
setEditingDefenseId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEditingTrophyReset(resetId: string) {
|
||||||
|
setEditingTrophyResetId(resetId);
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingTrophyReset() {
|
||||||
|
setEditingTrophyResetId(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDefenseEditSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleDefenseEditSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editingDefenseId || !defenseBeingEdited) {
|
if (!editingDefenseId || !defenseBeingEdited) {
|
||||||
|
|
@ -538,6 +570,42 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTrophyResetEditSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingTrophyResetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const payload = Object.fromEntries(formData.entries());
|
||||||
|
try {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: '' }));
|
||||||
|
await request('PUT', API.updateTrophyReset(editingTrophyResetId), payload);
|
||||||
|
setEditingTrophyResetId(null);
|
||||||
|
await refreshData();
|
||||||
|
await refreshOwnProfileDetail();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTrophyReset(resetId: string) {
|
||||||
|
const confirmDelete = window.confirm('Delete this reset?');
|
||||||
|
if (!confirmDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request('DELETE', API.deleteTrophyReset(resetId));
|
||||||
|
if (editingTrophyResetId === resetId) {
|
||||||
|
setEditingTrophyResetId(null);
|
||||||
|
}
|
||||||
|
await refreshData();
|
||||||
|
await refreshOwnProfileDetail();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleProfileSearch(event: FormEvent<HTMLFormElement>) {
|
async function handleProfileSearch(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setProfileError('');
|
setProfileError('');
|
||||||
|
|
@ -615,6 +683,26 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTrophyResetSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const payload = Object.fromEntries(formData.entries());
|
||||||
|
const baseId = payload.baseId as string;
|
||||||
|
if (!baseId) {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: 'Base is required' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: '' }));
|
||||||
|
await request('POST', API.addTrophyReset(baseId), payload);
|
||||||
|
form.reset();
|
||||||
|
await refreshData();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await request('POST', API.logout);
|
await request('POST', API.logout);
|
||||||
|
|
@ -663,8 +751,11 @@ export default function Page() {
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
function formatTrophies(value: number) {
|
function formatTrophies(value: number) {
|
||||||
const sign = value > 0 ? '+' : '';
|
const sign = value > 0 ? '' : '+';
|
||||||
return `${sign}${value} trophies`;
|
let gained = false;
|
||||||
|
if(sign === '+') gained = true;
|
||||||
|
let suffix = gained ? 'gained' : 'lost';
|
||||||
|
return `${sign}${value} trophies ${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
|
|
@ -985,7 +1076,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
{defenses.length ? (
|
{defenses.length ? (
|
||||||
defenses.map((defense) => {
|
defenses.map((defense) => {
|
||||||
const date = new Date(defense.createdAt);
|
const date = new Date(defense.createdAt);
|
||||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
|
||||||
return (
|
return (
|
||||||
<li key={defense.id} className="list-item">
|
<li key={defense.id} className="list-item">
|
||||||
<div className="defense-header">
|
<div className="defense-header">
|
||||||
|
|
@ -1026,7 +1117,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<div className="subsection">
|
<div className="subsection">
|
||||||
<h3>Existing Categories</h3>
|
<details>
|
||||||
|
<summary><h3>Existing Categories</h3></summary>
|
||||||
<ul className="list compact">
|
<ul className="list compact">
|
||||||
{categories.length ? (
|
{categories.length ? (
|
||||||
categories.map((category) => (
|
categories.map((category) => (
|
||||||
|
|
@ -1052,6 +1144,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
<li>No categories yet.</li>
|
<li>No categories yet.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
@ -1110,7 +1203,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<div className="subsection">
|
<div className="subsection">
|
||||||
<h3>Manage Bases</h3>
|
<details>
|
||||||
|
<summary><h3>Manage Bases</h3></summary>
|
||||||
<ul className="list compact">
|
<ul className="list compact">
|
||||||
{bases.length ? (
|
{bases.length ? (
|
||||||
bases.map((base) => (
|
bases.map((base) => (
|
||||||
|
|
@ -1146,6 +1240,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
<li>No bases yet.</li>
|
<li>No bases yet.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{baseBeingEdited && (
|
{baseBeingEdited && (
|
||||||
<div className="subsection">
|
<div className="subsection">
|
||||||
|
|
@ -1263,9 +1358,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Army Category
|
Army Category
|
||||||
<select name="armyCategoryId" required defaultValue="">
|
<select name="armyCategoryId" defaultValue="">
|
||||||
<option value="" disabled>
|
<option value="">
|
||||||
{categories.length ? 'Select an army category' : 'Add an army category first'}
|
{categories.length ? '(No category)' : 'Add an army category first'}
|
||||||
</option>
|
</option>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
|
|
@ -1303,12 +1398,13 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
<div className="subsection">
|
<div className="subsection">
|
||||||
<h3>Manage Attacks</h3>
|
<details>
|
||||||
|
<summary><h3>Manage Attacks</h3></summary>
|
||||||
<ul className="list compact">
|
<ul className="list compact">
|
||||||
{defenses.length ? (
|
{defenses.length ? (
|
||||||
defenses.slice(0, 10).map((defense) => {
|
defenses.slice(0, 10).map((defense) => {
|
||||||
const categoryName =
|
const categoryName =
|
||||||
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army';
|
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || '(No category)';
|
||||||
return (
|
return (
|
||||||
<li key={defense.id} className="list-item">
|
<li key={defense.id} className="list-item">
|
||||||
<div className="defense-header">
|
<div className="defense-header">
|
||||||
|
|
@ -1349,6 +1445,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
{defenses.length > 10 ? (
|
{defenses.length > 10 ? (
|
||||||
<p className="muted">Showing the latest 10 entries.</p>
|
<p className="muted">Showing the latest 10 entries.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{defenseBeingEdited && (
|
{defenseBeingEdited && (
|
||||||
<div className="subsection">
|
<div className="subsection">
|
||||||
|
|
@ -1370,7 +1467,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Army Category
|
Army Category
|
||||||
<select name="armyCategoryId" required defaultValue={defenseBeingEdited.armyCategoryId}>
|
<select name="armyCategoryId" defaultValue={defenseBeingEdited.armyCategoryId || ''}>
|
||||||
|
<option value="">(No category)</option>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{category.name}
|
{category.name}
|
||||||
|
|
@ -1434,6 +1532,134 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Log Legend League Day Reset</h2>
|
||||||
|
<form id="trophy-reset-form" className="form compact" onSubmit={handleTrophyResetSubmit}>
|
||||||
|
<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>
|
||||||
|
Date
|
||||||
|
<input type="date" name="date" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Trophies
|
||||||
|
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Trophies Gained/Lost
|
||||||
|
<input type="number" name="trophiesLost" step={1} required className="styled-number" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Number of Defenses
|
||||||
|
<input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Record Reset
|
||||||
|
</button>
|
||||||
|
<p className="form-error" data-for="trophyReset">
|
||||||
|
{errors.trophyReset}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
<div className="subsection">
|
||||||
|
<details>
|
||||||
|
<summary><h3>Manage Resets</h3></summary>
|
||||||
|
<ul className="list compact">
|
||||||
|
{bases.flatMap((base) => base.trophyResets).length ? (
|
||||||
|
bases.flatMap((base) => base.trophyResets).slice(0, 10).map((reset) => (
|
||||||
|
<li key={reset.id} className="list-item">
|
||||||
|
<div className="defense-header">
|
||||||
|
<div>
|
||||||
|
<strong>{bases.find((b) => b.id === bases.find((b) => b.trophyResets.some((r) => r.id === reset.id))?.id)?.title}</strong>{' '}
|
||||||
|
<span className="badge">{new Date(reset.date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={() => startEditingTrophyReset(reset.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={() => handleDeleteTrophyReset(reset.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<span>{reset.trophiesAtStart} trophies</span>
|
||||||
|
<span>{formatTrophies(reset.trophiesLost)}</span>
|
||||||
|
<span>{reset.numberOfDefenses} defenses</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li>No resets logged yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{editingTrophyResetId && trophyResetBeingEdited && (
|
||||||
|
<div className="subsection">
|
||||||
|
<h3>Edit Reset</h3>
|
||||||
|
<form
|
||||||
|
key={editingTrophyResetId}
|
||||||
|
className="form compact"
|
||||||
|
onSubmit={handleTrophyResetEditSubmit}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Base
|
||||||
|
<select name="baseId" required defaultValue={bases.find(b => b.trophyResets.some(r => r.id === editingTrophyResetId))?.id}>
|
||||||
|
{bases.map((base) => (
|
||||||
|
<option key={base.id} value={base.id}>
|
||||||
|
{base.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Date
|
||||||
|
<input type="date" name="date" required defaultValue={new Date(trophyResetBeingEdited.date).toISOString().split('T')[0]} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Trophies
|
||||||
|
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesAtStart} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Trophies Gained/Lost
|
||||||
|
<input type="number" name="trophiesLost" step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesLost} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Number of Defenses
|
||||||
|
<input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.numberOfDefenses} />
|
||||||
|
</label>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Save Reset
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={cancelEditingTrophyReset}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="form-error">{errors.trophyReset}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}>
|
<section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}>
|
||||||
|
|
@ -1487,7 +1713,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Army Categories vs This Base</h3>
|
<details open>
|
||||||
|
<summary><h3>Army Categories vs This Base</h3></summary>
|
||||||
<ul id="base-detail-categories" className="list">
|
<ul id="base-detail-categories" className="list">
|
||||||
{baseDetail && baseDetail.categories.length ? (
|
{baseDetail && baseDetail.categories.length ? (
|
||||||
baseDetail.categories.map((category) => (
|
baseDetail.categories.map((category) => (
|
||||||
|
|
@ -1511,16 +1738,57 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
<li>No army categories have attacked this base yet.</li>
|
<li>No army categories have attacked this base yet.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Defenses</h3>
|
<details open>
|
||||||
|
<summary><h3>Recent Resets</h3></summary>
|
||||||
|
<ul className="list compact">
|
||||||
|
{baseDetailMeta?.trophyResets?.length ? (
|
||||||
|
baseDetailMeta.trophyResets.map((reset) => (
|
||||||
|
<li key={reset.id} className="list-item">
|
||||||
|
<div className="defense-header">
|
||||||
|
<span>{new Date(reset.date).toLocaleDateString()}</span>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={() => startEditingTrophyReset(reset.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={() => handleDeleteTrophyReset(reset.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<span>{reset.trophiesAtStart} trophies</span>
|
||||||
|
<span>{formatTrophies(reset.trophiesLost)}</span>
|
||||||
|
<span>{reset.numberOfDefenses} defenses</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li>No resets logged yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<details open>
|
||||||
|
<summary><h3>Defenses</h3></summary>
|
||||||
<ul id="base-detail-defenses" className="list">
|
<ul id="base-detail-defenses" className="list">
|
||||||
{defenses.filter((defense) => defense.baseId === selectedBaseId).length ? (
|
{defenses.filter((defense) => defense.baseId === selectedBaseId).length ? (
|
||||||
defenses
|
defenses
|
||||||
.filter((defense) => defense.baseId === selectedBaseId)
|
.filter((defense) => defense.baseId === selectedBaseId)
|
||||||
.map((defense) => {
|
.map((defense) => {
|
||||||
const date = new Date(defense.createdAt);
|
const date = new Date(defense.createdAt);
|
||||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
|
||||||
return (
|
return (
|
||||||
<li key={defense.id} className="list-item">
|
<li key={defense.id} className="list-item">
|
||||||
<div className="defense-header">
|
<div className="defense-header">
|
||||||
|
|
@ -1539,6 +1807,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
<li>No defenses recorded for this base yet.</li>
|
<li>No defenses recorded for this base yet.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -1627,13 +1896,37 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Defenses</h3>
|
<details open>
|
||||||
|
<summary><h3>Recent Resets</h3></summary>
|
||||||
|
<ul className="list compact">
|
||||||
|
{profileSelectedBase.trophyResets.length ? (
|
||||||
|
profileSelectedBase.trophyResets.map((reset) => (
|
||||||
|
<li key={reset.id} className="list-item">
|
||||||
|
<div className="defense-header">
|
||||||
|
<span>{new Date(reset.date).toLocaleDateString()}</span>
|
||||||
|
<div className="defense-meta">
|
||||||
|
<span>{reset.trophiesAtStart} trophies at start</span>
|
||||||
|
<span>{formatTrophies(reset.trophiesLost)} lost</span>
|
||||||
|
<span>{reset.numberOfDefenses} defenses</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li>No resets logged yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<details>
|
||||||
|
<summary><h3>Defenses</h3></summary>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{profileSelectedBase.defenses.length ? (
|
{profileSelectedBase.defenses.length ? (
|
||||||
profileSelectedBase.defenses.map((defense) => (
|
profileSelectedBase.defenses.map((defense) => (
|
||||||
<li key={defense.id} className="list-item">
|
<li key={defense.id} className="list-item">
|
||||||
<div className="defense-header">
|
<div className="defense-header">
|
||||||
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong>
|
<strong>{defense.armyCategoryName || '(No category)'}</strong>
|
||||||
<div>
|
<div>
|
||||||
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1647,6 +1940,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
||||||
<li>No defenses recorded yet.</li>
|
<li>No defenses recorded yet.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ export function ServiceWorkerProvider() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
const isProduction = window.location.hostname === 'basetracker.lona-development.org';
|
||||||
|
|
||||||
|
if (isProduction) {
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
} else {
|
} else {
|
||||||
navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => {
|
navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue