Compare commits
	
		
			No commits in common. "a3410d244487a45a44d6fdf4021521bd576094cb" and "df76a1f0f2bed37b5b200ad3c139bfdfa65e40cd" have entirely different histories.
		
	
	
		
			a3410d2444
			...
			df76a1f0f2
		
	
		
					 6 changed files with 130 additions and 583 deletions
				
			
		|  | @ -39,18 +39,6 @@ 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 { | ||||||
|  | @ -61,6 +49,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,11 +271,6 @@ 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) => ({ | ||||||
|  | @ -286,14 +281,6 @@ 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, |  | ||||||
|       })), |  | ||||||
|     })), |     })), | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | @ -493,51 +480,6 @@ 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; | ||||||
|  | @ -547,6 +489,9 @@ 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' }); | ||||||
|     } |     } | ||||||
|  | @ -557,25 +502,22 @@ 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 = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); |     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) { |     if (!base) { | ||||||
|       return res.status(404).json({ error: 'Base not found' }); |       return res.status(404).json({ error: 'Base not found' }); | ||||||
|     } |     } | ||||||
| 
 |     if (!category) { | ||||||
|     let category = null; |       return res.status(404).json({ error: 'Army category not found' }); | ||||||
|     if (armyCategoryId) { |  | ||||||
|       category = await prisma.armyCategory.findFirst({ |  | ||||||
|         where: { id: armyCategoryId, userId: req.user.id }, |  | ||||||
|       }); |  | ||||||
|       if (!category) { |  | ||||||
|         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 ? category.id : null, |         armyCategoryId: category.id, | ||||||
|         stars: parsedStars, |         stars: parsedStars, | ||||||
|         percent: parsedPercent, |         percent: parsedPercent, | ||||||
|         trophies: parsedTrophies, |         trophies: parsedTrophies, | ||||||
|  | @ -589,81 +531,6 @@ 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; | ||||||
|  | @ -672,6 +539,9 @@ 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); | ||||||
|  | @ -687,34 +557,29 @@ 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 = await prisma.defense.findFirst({ |     const [defense, base, category] = await Promise.all([ | ||||||
|       where: { id: defenseId, base: { userId: req.user.id } }, |       prisma.defense.findFirst({ | ||||||
|     }); |         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' }); | ||||||
|     } |     } | ||||||
| 
 |     if (!category) { | ||||||
|     let category = null; |       return res.status(404).json({ error: 'Army category not found' }); | ||||||
|     if (armyCategoryId) { |  | ||||||
|       category = await prisma.armyCategory.findFirst({ |  | ||||||
|         where: { id: armyCategoryId, userId: req.user.id }, |  | ||||||
|       }); |  | ||||||
|       if (!category) { |  | ||||||
|         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: armyCategoryId ? category.id : null, |         armyCategoryId: category.id, | ||||||
|         stars: parsedStars, |         stars: parsedStars, | ||||||
|         percent: parsedPercent, |         percent: parsedPercent, | ||||||
|         trophies: parsedTrophies, |         trophies: parsedTrophies, | ||||||
|  | @ -763,16 +628,10 @@ 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, { | ||||||
|  | @ -954,9 +813,6 @@ app.get('/profiles/:username', requireAuth, async (req, res) => { | ||||||
|             }, |             }, | ||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|         trophyResets: { |  | ||||||
|           orderBy: { createdAt: 'desc' }, |  | ||||||
|         }, |  | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -968,15 +824,7 @@ 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 || '(No category)', |         armyCategoryName: defense.armyCategory?.name || 'Unknown Army', | ||||||
|       })); |  | ||||||
|       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, | ||||||
|  | @ -988,7 +836,6 @@ app.get('/profiles/:username', requireAuth, async (req, res) => { | ||||||
|         createdAt: base.createdAt, |         createdAt: base.createdAt, | ||||||
|         summary: summarizeDefenses(defenses), |         summary: summarizeDefenses(defenses), | ||||||
|         defenses, |         defenses, | ||||||
|         trophyResets, |  | ||||||
|       }; |       }; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,10 +5,14 @@ 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_ORIGINS: https://basetracker.lona-development.org,https://dev.basetracker.lona-development.org |       FRONTEND_ORIGIN: https://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,6 +6,10 @@ 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' | ||||||
|     : 'https://backend.dev.basetracker.lona-development.org'; |     : 'http://localhost:4100'; | ||||||
| 
 | 
 | ||||||
| // 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,9 +35,6 @@ 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`, | ||||||
|  | @ -67,15 +64,6 @@ 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; | ||||||
|  | @ -84,7 +72,6 @@ type BaseItem = { | ||||||
|   imageUrl: string; |   imageUrl: string; | ||||||
|   isPrivate: boolean; |   isPrivate: boolean; | ||||||
|   createdAt: string; |   createdAt: string; | ||||||
|   trophyResets: TrophyReset[]; |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type DefenseItem = { | type DefenseItem = { | ||||||
|  | @ -127,7 +114,6 @@ type ProfileBase = { | ||||||
|   createdAt: string; |   createdAt: string; | ||||||
|   summary: Summary; |   summary: Summary; | ||||||
|   defenses: ProfileDefense[]; |   defenses: ProfileDefense[]; | ||||||
|   trophyResets: TrophyReset[]; |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type ProfileCategorySummary = Summary & { | type ProfileCategorySummary = Summary & { | ||||||
|  | @ -179,7 +165,6 @@ 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 }) { | ||||||
|  | @ -212,7 +197,6 @@ const initialErrors: ErrorState = { | ||||||
|   category: '', |   category: '', | ||||||
|   base: '', |   base: '', | ||||||
|   defense: '', |   defense: '', | ||||||
|   trophyReset: '', |  | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default function Page() { | export default function Page() { | ||||||
|  | @ -232,7 +216,6 @@ 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); | ||||||
|  | @ -309,12 +292,6 @@ 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[]; | ||||||
|  | @ -323,7 +300,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 || '(No category)', items: [] }); |         buckets.set(key, { name: defense.armyCategoryName || 'Unknown Army', items: [] }); | ||||||
|       } |       } | ||||||
|       buckets.get(key)!.items.push(defense); |       buckets.get(key)!.items.push(defense); | ||||||
|     }); |     }); | ||||||
|  | @ -525,15 +502,6 @@ 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) { | ||||||
|  | @ -570,42 +538,6 @@ 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(''); | ||||||
|  | @ -683,26 +615,6 @@ 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); | ||||||
|  | @ -751,11 +663,8 @@ export default function Page() { | ||||||
|   }, [categories]); |   }, [categories]); | ||||||
| 
 | 
 | ||||||
| function formatTrophies(value: number) { | function formatTrophies(value: number) { | ||||||
|   const sign = value > 0 ? '' : '+'; |   const sign = value > 0 ? '+' : ''; | ||||||
|   let gained = false; |   return `${sign}${value} trophies`; | ||||||
|   if(sign === '+') gained = true; |  | ||||||
|   let suffix = gained ? 'gained' : 'lost'; |  | ||||||
|   return `${sign}${value} trophies ${suffix}`;  |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|  | @ -1076,7 +985,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) || '(No category)'; |                   const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; | ||||||
|                   return ( |                   return ( | ||||||
|                     <li key={defense.id} className="list-item"> |                     <li key={defense.id} className="list-item"> | ||||||
|                       <div className="defense-header"> |                       <div className="defense-header"> | ||||||
|  | @ -1117,34 +1026,32 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|             <div className="subsection"> |             <div className="subsection"> | ||||||
|               <details> |               <h3>Existing Categories</h3> | ||||||
|                 <summary><h3>Existing Categories</h3></summary> |               <ul className="list compact"> | ||||||
|                 <ul className="list compact"> |                 {categories.length ? ( | ||||||
|                   {categories.length ? ( |                   categories.map((category) => ( | ||||||
|                     categories.map((category) => ( |                     <li key={category.id} className="list-item"> | ||||||
|                       <li key={category.id} className="list-item"> |                       <div className="defense-header"> | ||||||
|                         <div className="defense-header"> |                         <span>{category.name}</span> | ||||||
|                           <span>{category.name}</span> |  | ||||||
|                           <div className="defense-meta"> |  | ||||||
|                             <button |  | ||||||
|                               type="button" |  | ||||||
|                               className="ghost small" |  | ||||||
|                               onClick={() => handleDeleteCategory(category.id)} |  | ||||||
|                             > |  | ||||||
|                               Delete |  | ||||||
|                             </button> |  | ||||||
|                           </div> |  | ||||||
|                         </div> |  | ||||||
|                         <div className="defense-meta"> |                         <div className="defense-meta"> | ||||||
|                           <span>{new Date(category.createdAt).toLocaleDateString()}</span> |                           <button | ||||||
|  |                             type="button" | ||||||
|  |                             className="ghost small" | ||||||
|  |                             onClick={() => handleDeleteCategory(category.id)} | ||||||
|  |                           > | ||||||
|  |                             Delete | ||||||
|  |                           </button> | ||||||
|                         </div> |                         </div> | ||||||
|                       </li> |                       </div> | ||||||
|                     )) |                       <div className="defense-meta"> | ||||||
|                   ) : ( |                         <span>{new Date(category.createdAt).toLocaleDateString()}</span> | ||||||
|                     <li>No categories yet.</li> |                       </div> | ||||||
|                   )} |                     </li> | ||||||
|                 </ul> |                   )) | ||||||
|               </details> |                 ) : ( | ||||||
|  |                   <li>No categories yet.</li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="card"> |           <div className="card"> | ||||||
|  | @ -1203,44 +1110,42 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|             <div className="subsection"> |             <div className="subsection"> | ||||||
|               <details> |               <h3>Manage Bases</h3> | ||||||
|                 <summary><h3>Manage Bases</h3></summary> |               <ul className="list compact"> | ||||||
|                 <ul className="list compact"> |                 {bases.length ? ( | ||||||
|                   {bases.length ? ( |                   bases.map((base) => ( | ||||||
|                     bases.map((base) => ( |                     <li key={base.id} className="list-item"> | ||||||
|                       <li key={base.id} className="list-item"> |                       <div className="defense-header"> | ||||||
|                         <div className="defense-header"> |                         <div> | ||||||
|                           <div> |                           <strong>{base.title}</strong>{' '} | ||||||
|                             <strong>{base.title}</strong>{' '} |                           {base.isPrivate ? <span className="badge muted">Private</span> : null} | ||||||
|                             {base.isPrivate ? <span className="badge muted">Private</span> : null} |  | ||||||
|                           </div> |  | ||||||
|                           <div className="defense-meta"> |  | ||||||
|                             <button |  | ||||||
|                               type="button" |  | ||||||
|                               className="ghost small" |  | ||||||
|                               onClick={() => startEditingBase(base.id)} |  | ||||||
|                             > |  | ||||||
|                               Edit |  | ||||||
|                             </button> |  | ||||||
|                             <button |  | ||||||
|                               type="button" |  | ||||||
|                               className="ghost small" |  | ||||||
|                               onClick={() => handleDeleteBase(base.id)} |  | ||||||
|                             > |  | ||||||
|                               Delete |  | ||||||
|                             </button> |  | ||||||
|                           </div> |  | ||||||
|                         </div> |                         </div> | ||||||
|                         <div className="defense-meta"> |                         <div className="defense-meta"> | ||||||
|                           <span>{new Date(base.createdAt).toLocaleDateString()}</span> |                           <button | ||||||
|  |                             type="button" | ||||||
|  |                             className="ghost small" | ||||||
|  |                             onClick={() => startEditingBase(base.id)} | ||||||
|  |                           > | ||||||
|  |                             Edit | ||||||
|  |                           </button> | ||||||
|  |                           <button | ||||||
|  |                             type="button" | ||||||
|  |                             className="ghost small" | ||||||
|  |                             onClick={() => handleDeleteBase(base.id)} | ||||||
|  |                           > | ||||||
|  |                             Delete | ||||||
|  |                           </button> | ||||||
|                         </div> |                         </div> | ||||||
|                       </li> |                       </div> | ||||||
|                     )) |                       <div className="defense-meta"> | ||||||
|                   ) : ( |                         <span>{new Date(base.createdAt).toLocaleDateString()}</span> | ||||||
|                     <li>No bases yet.</li> |                       </div> | ||||||
|                   )} |                     </li> | ||||||
|                 </ul> |                   )) | ||||||
|               </details> |                 ) : ( | ||||||
|  |                   <li>No bases yet.</li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|             </div> |             </div> | ||||||
|             {baseBeingEdited && ( |             {baseBeingEdited && ( | ||||||
|               <div className="subsection"> |               <div className="subsection"> | ||||||
|  | @ -1358,9 +1263,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|               </label> |               </label> | ||||||
|               <label> |               <label> | ||||||
|                 Army Category |                 Army Category | ||||||
|                 <select name="armyCategoryId" defaultValue=""> |                 <select name="armyCategoryId" required defaultValue=""> | ||||||
|                   <option value=""> |                   <option value="" disabled> | ||||||
|                     {categories.length ? '(No category)' : 'Add an army category first'} |                     {categories.length ? 'Select an army 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}> | ||||||
|  | @ -1398,13 +1303,12 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|             <div className="subsection"> |             <div className="subsection"> | ||||||
|               <details> |               <h3>Manage Attacks</h3> | ||||||
|                 <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 || '(No category)'; |                       categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army'; | ||||||
|                     return ( |                     return ( | ||||||
|                       <li key={defense.id} className="list-item"> |                       <li key={defense.id} className="list-item"> | ||||||
|                         <div className="defense-header"> |                         <div className="defense-header"> | ||||||
|  | @ -1445,7 +1349,6 @@ 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"> | ||||||
|  | @ -1467,8 +1370,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|                   </label> |                   </label> | ||||||
|                   <label> |                   <label> | ||||||
|                     Army Category |                     Army Category | ||||||
|                     <select name="armyCategoryId" defaultValue={defenseBeingEdited.armyCategoryId || ''}> |                     <select name="armyCategoryId" required 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} | ||||||
|  | @ -1532,134 +1434,6 @@ 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' : ''}`}> | ||||||
|  | @ -1713,9 +1487,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="card"> |           <div className="card"> | ||||||
|             <details open> |             <h3>Army Categories vs This Base</h3> | ||||||
|               <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) => ( | ||||||
|                   <li |                   <li | ||||||
|  | @ -1738,57 +1511,16 @@ 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"> | ||||||
|             <details open> |             <h3>Defenses</h3> | ||||||
|               <summary><h3>Recent Resets</h3></summary> |             <ul id="base-detail-defenses" className="list"> | ||||||
|               <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"> |  | ||||||
|               {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) || '(No category)'; |                     const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; | ||||||
|                     return ( |                     return ( | ||||||
|                       <li key={defense.id} className="list-item"> |                       <li key={defense.id} className="list-item"> | ||||||
|                         <div className="defense-header"> |                         <div className="defense-header"> | ||||||
|  | @ -1807,7 +1539,6 @@ 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> | ||||||
| 
 | 
 | ||||||
|  | @ -1896,51 +1627,26 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary { | ||||||
|                 </ul> |                 </ul> | ||||||
|               </div> |               </div> | ||||||
|               <div className="card"> |               <div className="card"> | ||||||
|                 <details open> |                 <h3>Defenses</h3> | ||||||
|                   <summary><h3>Recent Resets</h3></summary> |                 <ul className="list"> | ||||||
|                   <ul className="list compact"> |                   {profileSelectedBase.defenses.length ? ( | ||||||
|                     {profileSelectedBase.trophyResets.length ? ( |                     profileSelectedBase.defenses.map((defense) => ( | ||||||
|                       profileSelectedBase.trophyResets.map((reset) => ( |                       <li key={defense.id} className="list-item"> | ||||||
|                         <li key={reset.id} className="list-item"> |                         <div className="defense-header"> | ||||||
|                           <div className="defense-header"> |                           <strong>{defense.armyCategoryName || 'Unknown Army'}</strong> | ||||||
|                             <span>{new Date(reset.date).toLocaleDateString()}</span> |                           <div> | ||||||
|                             <div className="defense-meta"> |                             <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)} | ||||||
|                               <span>{reset.trophiesAtStart} trophies at start</span> |  | ||||||
|                               <span>{formatTrophies(reset.trophiesLost)} lost</span> |  | ||||||
|                               <span>{reset.numberOfDefenses} defenses</span> |  | ||||||
|                             </div> |  | ||||||
|                           </div> |                           </div> | ||||||
|                         </li> |                         </div> | ||||||
|                       )) |                         <div className="defense-meta"> | ||||||
|                     ) : ( |                           <span>{new Date(defense.createdAt).toLocaleString()}</span> | ||||||
|                       <li>No resets logged yet.</li> |                         </div> | ||||||
|                     )} |                       </li> | ||||||
|                   </ul> |                     )) | ||||||
|                 </details> |                   ) : ( | ||||||
|               </div> |                     <li>No defenses recorded yet.</li> | ||||||
|               <div className="card"> |                   )} | ||||||
|                 <details> |                 </ul> | ||||||
|                   <summary><h3>Defenses</h3></summary> |  | ||||||
|                   <ul className="list"> |  | ||||||
|                     {profileSelectedBase.defenses.length ? ( |  | ||||||
|                       profileSelectedBase.defenses.map((defense) => ( |  | ||||||
|                         <li key={defense.id} className="list-item"> |  | ||||||
|                           <div className="defense-header"> |  | ||||||
|                             <strong>{defense.armyCategoryName || '(No category)'}</strong> |  | ||||||
|                             <div> |  | ||||||
|                               <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)} |  | ||||||
|                             </div> |  | ||||||
|                           </div> |  | ||||||
|                           <div className="defense-meta"> |  | ||||||
|                             <span>{new Date(defense.createdAt).toLocaleString()}</span> |  | ||||||
|                           </div> |  | ||||||
|                         </li> |  | ||||||
|                       )) |  | ||||||
|                     ) : ( |  | ||||||
|                       <li>No defenses recorded yet.</li> |  | ||||||
|                     )} |  | ||||||
|                   </ul> |  | ||||||
|                 </details> |  | ||||||
|               </div> |               </div> | ||||||
|             </> |             </> | ||||||
|           ) : ( |           ) : ( | ||||||
|  |  | ||||||
|  | @ -28,9 +28,7 @@ export function ServiceWorkerProvider() { | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const isProduction = window.location.hostname === 'basetracker.lona-development.org'; |     if (process.env.NODE_ENV === 'production') { | ||||||
| 
 |  | ||||||
|     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