j
This commit is contained in:
		
							parent
							
								
									4974e7d6f3
								
							
						
					
					
						commit
						470b80aea9
					
				
					 3 changed files with 1124 additions and 26 deletions
				
			
		|  | @ -34,6 +34,7 @@ model Base { | ||||||
|   url         String? |   url         String? | ||||||
|   imageUrl    String? |   imageUrl    String? | ||||||
|   imagePath   String? |   imagePath   String? | ||||||
|  |   isPrivate   Boolean  @default(false) | ||||||
|   createdAt   DateTime @default(now()) |   createdAt   DateTime @default(now()) | ||||||
|   user        User     @relation(fields: [userId], references: [id]) |   user        User     @relation(fields: [userId], references: [id]) | ||||||
|   userId      String |   userId      String | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ const app = express(); | ||||||
| const __filename = fileURLToPath(import.meta.url); | const __filename = fileURLToPath(import.meta.url); | ||||||
| const __dirname = path.dirname(__filename); | const __dirname = path.dirname(__filename); | ||||||
| const uploadDir = path.join(__dirname, '..', 'uploads'); | const uploadDir = path.join(__dirname, '..', 'uploads'); | ||||||
|  | const fsPromises = fs.promises; | ||||||
| const jwtSecret = process.env.JWT_SECRET || 'super-secret-key'; | const jwtSecret = process.env.JWT_SECRET || 'super-secret-key'; | ||||||
| const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100'; | const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100'; | ||||||
| const port = process.env.PORT || 4000; | const port = process.env.PORT || 4000; | ||||||
|  | @ -205,6 +206,34 @@ app.post('/army-categories', requireAuth, async (req, res) => { | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | app.delete('/army-categories/:categoryId', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { categoryId } = req.params; | ||||||
|  |     const category = await prisma.armyCategory.findFirst({ | ||||||
|  |       where: { id: categoryId, userId: req.user.id }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (!category) { | ||||||
|  |       return res.status(404).json({ error: 'Army category not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await prisma.$transaction([ | ||||||
|  |       prisma.defense.deleteMany({ | ||||||
|  |         where: { | ||||||
|  |           armyCategoryId: category.id, | ||||||
|  |           base: { userId: req.user.id }, | ||||||
|  |         }, | ||||||
|  |       }), | ||||||
|  |       prisma.armyCategory.delete({ where: { id: category.id } }), | ||||||
|  |     ]); | ||||||
|  | 
 | ||||||
|  |     return res.json({ message: 'Army category deleted' }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| app.get('/bases', requireAuth, async (req, res) => { | 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 }, | ||||||
|  | @ -217,6 +246,7 @@ app.get('/bases', requireAuth, async (req, res) => { | ||||||
|       description: base.description || '', |       description: base.description || '', | ||||||
|       url: base.url || '', |       url: base.url || '', | ||||||
|       imageUrl: buildImageUrl(base), |       imageUrl: buildImageUrl(base), | ||||||
|  |       isPrivate: base.isPrivate, | ||||||
|       createdAt: base.createdAt, |       createdAt: base.createdAt, | ||||||
|     })), |     })), | ||||||
|   }); |   }); | ||||||
|  | @ -224,7 +254,7 @@ app.get('/bases', requireAuth, async (req, res) => { | ||||||
| 
 | 
 | ||||||
| app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => { | app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => { | ||||||
|   try { |   try { | ||||||
|     const { title, description, url, imageMode, imageUrl } = req.body; |     const { title, description, url, imageMode, imageUrl, isPrivate } = req.body; | ||||||
|     if (!title || !title.trim()) { |     if (!title || !title.trim()) { | ||||||
|       return res.status(400).json({ error: 'Title is required' }); |       return res.status(400).json({ error: 'Title is required' }); | ||||||
|     } |     } | ||||||
|  | @ -238,7 +268,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => | ||||||
|       if (!imageUrl || !isValidUrl(imageUrl)) { |       if (!imageUrl || !isValidUrl(imageUrl)) { | ||||||
|         return res.status(400).json({ error: 'Image URL must be valid' }); |         return res.status(400).json({ error: 'Image URL must be valid' }); | ||||||
|       } |       } | ||||||
|       storedImageUrl = imageUrl; |       storedImageUrl = imageUrl.trim(); | ||||||
|     } else if (req.file) { |     } else if (req.file) { | ||||||
|       storedImagePath = req.file.filename; |       storedImagePath = req.file.filename; | ||||||
|     } |     } | ||||||
|  | @ -250,6 +280,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => | ||||||
|         url: url?.trim() || null, |         url: url?.trim() || null, | ||||||
|         imageUrl: storedImageUrl, |         imageUrl: storedImageUrl, | ||||||
|         imagePath: storedImagePath, |         imagePath: storedImagePath, | ||||||
|  |         isPrivate: parseBoolean(isPrivate), | ||||||
|         userId: req.user.id, |         userId: req.user.id, | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|  | @ -261,9 +292,141 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => | ||||||
|         description: base.description || '', |         description: base.description || '', | ||||||
|         url: base.url || '', |         url: base.url || '', | ||||||
|         imageUrl: buildImageUrl(base), |         imageUrl: buildImageUrl(base), | ||||||
|  |         isPrivate: base.isPrivate, | ||||||
|         createdAt: base.createdAt, |         createdAt: base.createdAt, | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     if (req.file) { | ||||||
|  |       await deleteImageFile(req.file.filename); | ||||||
|  |     } | ||||||
|  |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => { | ||||||
|  |   let newUploadFilename = null; | ||||||
|  |   try { | ||||||
|  |     const { baseId } = req.params; | ||||||
|  |     const { | ||||||
|  |       title, | ||||||
|  |       description, | ||||||
|  |       url, | ||||||
|  |       imageMode, | ||||||
|  |       imageUrl, | ||||||
|  |       removeImage, | ||||||
|  |       isPrivate, | ||||||
|  |     } = req.body; | ||||||
|  | 
 | ||||||
|  |     if (!title || !title.trim()) { | ||||||
|  |       if (req.file) { | ||||||
|  |         await deleteImageFile(req.file.filename); | ||||||
|  |       } | ||||||
|  |       return res.status(400).json({ error: 'Title is required' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (url && !isValidUrl(url)) { | ||||||
|  |       if (req.file) { | ||||||
|  |         await deleteImageFile(req.file.filename); | ||||||
|  |       } | ||||||
|  |       return res.status(400).json({ error: 'Planning link must be a valid URL' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); | ||||||
|  |     if (!base) { | ||||||
|  |       if (req.file) { | ||||||
|  |         await deleteImageFile(req.file.filename); | ||||||
|  |       } | ||||||
|  |       return res.status(404).json({ error: 'Base not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const shouldRemoveImage = parseBoolean(removeImage); | ||||||
|  |     const parsedIsPrivate = parseBoolean(isPrivate); | ||||||
|  | 
 | ||||||
|  |     let imagePath = base.imagePath; | ||||||
|  |     let storedImageUrl = base.imageUrl; | ||||||
|  |     let previousImagePathToDelete = null; | ||||||
|  | 
 | ||||||
|  |     if (shouldRemoveImage) { | ||||||
|  |       previousImagePathToDelete = base.imagePath; | ||||||
|  |       imagePath = null; | ||||||
|  |       storedImageUrl = null; | ||||||
|  |     } else if (imageMode === 'url') { | ||||||
|  |       if (imageUrl) { | ||||||
|  |         if (!isValidUrl(imageUrl)) { | ||||||
|  |           if (req.file) { | ||||||
|  |             await deleteImageFile(req.file.filename); | ||||||
|  |           } | ||||||
|  |           return res.status(400).json({ error: 'Image URL must be valid' }); | ||||||
|  |         } | ||||||
|  |         previousImagePathToDelete = base.imagePath; | ||||||
|  |         imagePath = null; | ||||||
|  |         storedImageUrl = imageUrl.trim(); | ||||||
|  |       } | ||||||
|  |     } else if (req.file) { | ||||||
|  |       newUploadFilename = req.file.filename; | ||||||
|  |       previousImagePathToDelete = base.imagePath; | ||||||
|  |       imagePath = req.file.filename; | ||||||
|  |       storedImageUrl = null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const updatedBase = await prisma.base.update({ | ||||||
|  |       where: { id: base.id }, | ||||||
|  |       data: { | ||||||
|  |         title: title.trim(), | ||||||
|  |         description: description?.trim() || null, | ||||||
|  |         url: url?.trim() || null, | ||||||
|  |         imageUrl: storedImageUrl, | ||||||
|  |         imagePath, | ||||||
|  |         isPrivate: parsedIsPrivate, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (previousImagePathToDelete && previousImagePathToDelete !== imagePath) { | ||||||
|  |       await deleteImageFile(previousImagePathToDelete); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return res.json({ | ||||||
|  |       base: { | ||||||
|  |         id: updatedBase.id, | ||||||
|  |         title: updatedBase.title, | ||||||
|  |         description: updatedBase.description || '', | ||||||
|  |         url: updatedBase.url || '', | ||||||
|  |         imageUrl: buildImageUrl(updatedBase), | ||||||
|  |         isPrivate: updatedBase.isPrivate, | ||||||
|  |         createdAt: updatedBase.createdAt, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     if (req.file) { | ||||||
|  |       await deleteImageFile(req.file.filename); | ||||||
|  |     } else if (newUploadFilename) { | ||||||
|  |       await deleteImageFile(newUploadFilename); | ||||||
|  |     } | ||||||
|  |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | app.delete('/bases/:baseId', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { baseId } = req.params; | ||||||
|  |     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.$transaction([ | ||||||
|  |       prisma.defense.deleteMany({ where: { baseId: base.id } }), | ||||||
|  |       prisma.base.delete({ where: { id: base.id } }), | ||||||
|  |     ]); | ||||||
|  | 
 | ||||||
|  |     if (base.imagePath) { | ||||||
|  |       await deleteImageFile(base.imagePath); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return res.json({ message: 'Base deleted' }); | ||||||
|   } catch (error) { |   } catch (error) { | ||||||
|     console.error(error); |     console.error(error); | ||||||
|     return res.status(500).json({ error: 'Internal server error' }); |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  | @ -321,6 +484,86 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => { | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | app.put('/defenses/:defenseId', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { defenseId } = req.params; | ||||||
|  |     const { baseId, armyCategoryId, stars, percent, trophies } = req.body || {}; | ||||||
|  | 
 | ||||||
|  |     if (!baseId) { | ||||||
|  |       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 parsedPercent = Number(percent); | ||||||
|  |     const parsedTrophies = Number(trophies ?? 0); | ||||||
|  | 
 | ||||||
|  |     if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) { | ||||||
|  |       return res.status(400).json({ error: 'Stars must be between 0 and 3' }); | ||||||
|  |     } | ||||||
|  |     if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) { | ||||||
|  |       return res.status(400).json({ error: 'Percent must be between 0 and 100' }); | ||||||
|  |     } | ||||||
|  |     if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) { | ||||||
|  |       return res.status(400).json({ error: 'Trophies must be between -200 and 200' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const [defense, base, category] = await Promise.all([ | ||||||
|  |       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) { | ||||||
|  |       return res.status(404).json({ error: 'Defense not found' }); | ||||||
|  |     } | ||||||
|  |     if (!base) { | ||||||
|  |       return res.status(404).json({ error: 'Base not found' }); | ||||||
|  |     } | ||||||
|  |     if (!category) { | ||||||
|  |       return res.status(404).json({ error: 'Army category not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await prisma.defense.update({ | ||||||
|  |       where: { id: defense.id }, | ||||||
|  |       data: { | ||||||
|  |         baseId: base.id, | ||||||
|  |         armyCategoryId: category.id, | ||||||
|  |         stars: parsedStars, | ||||||
|  |         percent: parsedPercent, | ||||||
|  |         trophies: parsedTrophies, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return res.json({ message: 'Defense updated' }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | app.delete('/defenses/:defenseId', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const { defenseId } = req.params; | ||||||
|  |     const result = await prisma.defense.deleteMany({ | ||||||
|  |       where: { id: defenseId, base: { userId: req.user.id } }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (!result.count) { | ||||||
|  |       return res.status(404).json({ error: 'Defense not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return res.json({ message: 'Defense deleted' }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     return res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| app.get('/defenses', requireAuth, async (req, res) => { | app.get('/defenses', requireAuth, async (req, res) => { | ||||||
|   try { |   try { | ||||||
|     const user = await prisma.user.findUnique({ |     const user = await prisma.user.findUnique({ | ||||||
|  | @ -443,6 +686,133 @@ app.get('/defenses', requireAuth, async (req, res) => { | ||||||
|   } |   } | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | app.get('/profiles', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const searchTerm = (req.query.search ?? '').toString().trim(); | ||||||
|  |     const users = await prisma.user.findMany({ | ||||||
|  |       where: searchTerm | ||||||
|  |         ? { | ||||||
|  |             username: { | ||||||
|  |               contains: searchTerm, | ||||||
|  |               mode: 'insensitive', | ||||||
|  |             }, | ||||||
|  |           } | ||||||
|  |         : undefined, | ||||||
|  |       orderBy: { username: 'asc' }, | ||||||
|  |       take: 25, | ||||||
|  |       select: { | ||||||
|  |         id: true, | ||||||
|  |         username: true, | ||||||
|  |         createdAt: true, | ||||||
|  |         bases: { | ||||||
|  |           where: { isPrivate: false }, | ||||||
|  |           select: { | ||||||
|  |             id: true, | ||||||
|  |             _count: { select: { defenses: true } }, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const profiles = users.map((user) => { | ||||||
|  |       const publicBaseCount = user.bases.length; | ||||||
|  |       const publicDefenseCount = user.bases.reduce((sum, base) => sum + base._count.defenses, 0); | ||||||
|  |       return { | ||||||
|  |         id: user.id, | ||||||
|  |         username: user.username, | ||||||
|  |         createdAt: user.createdAt, | ||||||
|  |         publicBaseCount, | ||||||
|  |         publicDefenseCount, | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     res.json({ profiles }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | app.get('/profiles/:username', requireAuth, async (req, res) => { | ||||||
|  |   try { | ||||||
|  |     const usernameParam = req.params.username.toLowerCase(); | ||||||
|  |     const profileUser = await prisma.user.findUnique({ | ||||||
|  |       where: { username: usernameParam }, | ||||||
|  |       select: { | ||||||
|  |         id: true, | ||||||
|  |         username: true, | ||||||
|  |         createdAt: true, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     if (!profileUser) { | ||||||
|  |       return res.status(404).json({ error: 'Profile not found' }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const isOwner = profileUser.id === req.user.id; | ||||||
|  | 
 | ||||||
|  |     const bases = await prisma.base.findMany({ | ||||||
|  |       where: { | ||||||
|  |         userId: profileUser.id, | ||||||
|  |         ...(isOwner ? {} : { isPrivate: false }), | ||||||
|  |       }, | ||||||
|  |       orderBy: { createdAt: 'desc' }, | ||||||
|  |       include: { | ||||||
|  |         defenses: { | ||||||
|  |           orderBy: { createdAt: 'desc' }, | ||||||
|  |           include: { | ||||||
|  |             armyCategory: { | ||||||
|  |               select: { id: true, name: true }, | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const serializedBases = bases.map((base) => { | ||||||
|  |       const defenses = base.defenses.map((defense) => ({ | ||||||
|  |         id: defense.id, | ||||||
|  |         stars: defense.stars, | ||||||
|  |         percent: defense.percent, | ||||||
|  |         trophies: defense.trophies, | ||||||
|  |         createdAt: defense.createdAt, | ||||||
|  |         armyCategoryId: defense.armyCategoryId, | ||||||
|  |         armyCategoryName: defense.armyCategory?.name || 'Unknown Army', | ||||||
|  |       })); | ||||||
|  |       return { | ||||||
|  |         id: base.id, | ||||||
|  |         title: base.title, | ||||||
|  |         description: base.description || '', | ||||||
|  |         url: base.url || '', | ||||||
|  |         imageUrl: buildImageUrl(base), | ||||||
|  |         isPrivate: base.isPrivate, | ||||||
|  |         createdAt: base.createdAt, | ||||||
|  |         summary: summarizeDefenses(defenses), | ||||||
|  |         defenses, | ||||||
|  |       }; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const allVisibleDefenses = serializedBases.flatMap((base) => base.defenses); | ||||||
|  |     const overallSummary = summarizeDefenses(allVisibleDefenses); | ||||||
|  | 
 | ||||||
|  |     res.json({ | ||||||
|  |       profile: { | ||||||
|  |         id: profileUser.id, | ||||||
|  |         username: profileUser.username, | ||||||
|  |         createdAt: profileUser.createdAt, | ||||||
|  |         isOwner, | ||||||
|  |         visibleBaseCount: serializedBases.length, | ||||||
|  |         defenseCount: allVisibleDefenses.length, | ||||||
|  |         summary: overallSummary, | ||||||
|  |       }, | ||||||
|  |       bases: serializedBases, | ||||||
|  |     }); | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     res.status(500).json({ error: 'Internal server error' }); | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| function summarizeDefenses(defenses) { | function summarizeDefenses(defenses) { | ||||||
|   if (!defenses.length) { |   if (!defenses.length) { | ||||||
|     return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 }; |     return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 }; | ||||||
|  | @ -459,6 +829,32 @@ function summarizeDefenses(defenses) { | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | async function deleteImageFile(imagePath) { | ||||||
|  |   if (!imagePath) return; | ||||||
|  |   const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath); | ||||||
|  |   try { | ||||||
|  |     await fsPromises.unlink(filePath); | ||||||
|  |   } catch (error) { | ||||||
|  |     if (error.code !== 'ENOENT') { | ||||||
|  |       console.error(`Failed to delete image file ${filePath}`, error); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function parseBoolean(value) { | ||||||
|  |   if (typeof value === 'boolean') { | ||||||
|  |     return value; | ||||||
|  |   } | ||||||
|  |   if (typeof value === 'number') { | ||||||
|  |     return value !== 0; | ||||||
|  |   } | ||||||
|  |   if (typeof value === 'string') { | ||||||
|  |     const normalized = value.trim().toLowerCase(); | ||||||
|  |     return ['true', '1', 'yes', 'on'].includes(normalized); | ||||||
|  |   } | ||||||
|  |   return false; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function sanitizeUser(user) { | function sanitizeUser(user) { | ||||||
|   return { |   return { | ||||||
|     id: user.id, |     id: user.id, | ||||||
|  |  | ||||||
|  | @ -32,8 +32,17 @@ const API = { | ||||||
|   bases: `${API_BASE}/bases`, |   bases: `${API_BASE}/bases`, | ||||||
|   addDefense: (baseId: string) => `${API_BASE}/bases/${baseId}/defenses`, |   addDefense: (baseId: string) => `${API_BASE}/bases/${baseId}/defenses`, | ||||||
|   defenses: `${API_BASE}/defenses`, |   defenses: `${API_BASE}/defenses`, | ||||||
|  |   deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`, | ||||||
|  |   updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`, | ||||||
|  |   deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`, | ||||||
|  |   updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, | ||||||
|  |   deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, | ||||||
|  |   profiles: `${API_BASE}/profiles`, | ||||||
|  |   profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const PROFILE_DEFENSE_PREVIEW_LIMIT = 5; | ||||||
|  | 
 | ||||||
| type User = { | type User = { | ||||||
|   id: string; |   id: string; | ||||||
|   username: string; |   username: string; | ||||||
|  | @ -52,6 +61,7 @@ type BaseItem = { | ||||||
|   description: string; |   description: string; | ||||||
|   url: string; |   url: string; | ||||||
|   imageUrl: string; |   imageUrl: string; | ||||||
|  |   isPrivate: boolean; | ||||||
|   createdAt: string; |   createdAt: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -67,6 +77,49 @@ type DefenseItem = { | ||||||
|   categoryName?: string; |   categoryName?: string; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | type ProfileSummaryItem = { | ||||||
|  |   id: string; | ||||||
|  |   username: string; | ||||||
|  |   createdAt: string; | ||||||
|  |   publicBaseCount: number; | ||||||
|  |   publicDefenseCount: number; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type ProfileDefense = { | ||||||
|  |   id: string; | ||||||
|  |   stars: number; | ||||||
|  |   percent: number; | ||||||
|  |   trophies: number; | ||||||
|  |   createdAt: string; | ||||||
|  |   armyCategoryId: string; | ||||||
|  |   armyCategoryName: string; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type ProfileBase = { | ||||||
|  |   id: string; | ||||||
|  |   title: string; | ||||||
|  |   description: string; | ||||||
|  |   url: string; | ||||||
|  |   imageUrl: string; | ||||||
|  |   isPrivate: boolean; | ||||||
|  |   createdAt: string; | ||||||
|  |   summary: Summary; | ||||||
|  |   defenses: ProfileDefense[]; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | type ProfileDetail = { | ||||||
|  |   profile: { | ||||||
|  |     id: string; | ||||||
|  |     username: string; | ||||||
|  |     createdAt: string; | ||||||
|  |     isOwner: boolean; | ||||||
|  |     visibleBaseCount: number; | ||||||
|  |     defenseCount: number; | ||||||
|  |     summary: Summary; | ||||||
|  |   }; | ||||||
|  |   bases: ProfileBase[]; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| type Summary = { | type Summary = { | ||||||
|   count: number; |   count: number; | ||||||
|   averageStars: number; |   averageStars: number; | ||||||
|  | @ -144,6 +197,14 @@ export default function Page() { | ||||||
|   const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null); |   const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null); | ||||||
|   const [errors, setErrors] = useState<ErrorState>(initialErrors); |   const [errors, setErrors] = useState<ErrorState>(initialErrors); | ||||||
|   const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload'); |   const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload'); | ||||||
|  |   const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep'); | ||||||
|  |   const [editingBaseId, setEditingBaseId] = useState<string | null>(null); | ||||||
|  |   const [editingDefenseId, setEditingDefenseId] = useState<string | null>(null); | ||||||
|  |   const [profileSearchTerm, setProfileSearchTerm] = useState(''); | ||||||
|  |   const [profileResults, setProfileResults] = useState<ProfileSummaryItem[]>([]); | ||||||
|  |   const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null); | ||||||
|  |   const [profileError, setProfileError] = useState(''); | ||||||
|  |   const [profileLoading, setProfileLoading] = useState(false); | ||||||
|   const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|  | @ -197,6 +258,16 @@ export default function Page() { | ||||||
|     return map; |     return map; | ||||||
|   }, [summaries]); |   }, [summaries]); | ||||||
| 
 | 
 | ||||||
|  |   const baseBeingEdited = useMemo(() => { | ||||||
|  |     if (!editingBaseId) return null; | ||||||
|  |     return bases.find((base) => base.id === editingBaseId) ?? null; | ||||||
|  |   }, [editingBaseId, bases]); | ||||||
|  | 
 | ||||||
|  |   const defenseBeingEdited = useMemo(() => { | ||||||
|  |     if (!editingDefenseId) return null; | ||||||
|  |     return defenses.find((defense) => defense.id === editingDefenseId) ?? null; | ||||||
|  |   }, [editingDefenseId, defenses]); | ||||||
|  | 
 | ||||||
|   async function handleLogin(event: FormEvent<HTMLFormElement>) { |   async function handleLogin(event: FormEvent<HTMLFormElement>) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     const formData = new FormData(event.currentTarget); |     const formData = new FormData(event.currentTarget); | ||||||
|  | @ -232,12 +303,13 @@ export default function Page() { | ||||||
| 
 | 
 | ||||||
|   async function handleCategorySubmit(event: FormEvent<HTMLFormElement>) { |   async function handleCategorySubmit(event: FormEvent<HTMLFormElement>) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     const formData = new FormData(event.currentTarget); |     const form = event.currentTarget; | ||||||
|  |     const formData = new FormData(form); | ||||||
|     const payload = Object.fromEntries(formData.entries()); |     const payload = Object.fromEntries(formData.entries()); | ||||||
|     try { |     try { | ||||||
|       setErrors((prev) => ({ ...prev, category: '' })); |       setErrors((prev) => ({ ...prev, category: '' })); | ||||||
|       await request('POST', API.categories, payload); |       await request('POST', API.categories, payload); | ||||||
|       event.currentTarget.reset(); |       form.reset(); | ||||||
|       await refreshData(); |       await refreshData(); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       setErrors((prev) => ({ ...prev, category: error.message })); |       setErrors((prev) => ({ ...prev, category: error.message })); | ||||||
|  | @ -246,19 +318,25 @@ export default function Page() { | ||||||
| 
 | 
 | ||||||
|   async function handleBaseSubmit(event: FormEvent<HTMLFormElement>) { |   async function handleBaseSubmit(event: FormEvent<HTMLFormElement>) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     const formData = new FormData(event.currentTarget); |     const form = event.currentTarget; | ||||||
|  |     const formData = new FormData(form); | ||||||
|     formData.set('imageMode', imageMode); |     formData.set('imageMode', imageMode); | ||||||
|     if (imageMode === 'upload') { |     if (imageMode === 'upload') { | ||||||
|       formData.delete('imageUrl'); |       formData.delete('imageUrl'); | ||||||
|     } else { |     } else { | ||||||
|       formData.delete('imageFile'); |       formData.delete('imageFile'); | ||||||
|     } |     } | ||||||
|  |     const requestedPrivate = formData.get('isPrivate'); | ||||||
|  |     const isPrivate = typeof requestedPrivate === 'string' ? requestedPrivate === 'on' : false; | ||||||
|  |     formData.set('isPrivate', isPrivate ? 'true' : 'false'); | ||||||
|  |     formData.set('removeImage', 'false'); | ||||||
|     try { |     try { | ||||||
|       setErrors((prev) => ({ ...prev, base: '' })); |       setErrors((prev) => ({ ...prev, base: '' })); | ||||||
|       await request('POST', API.bases, formData, { isForm: true }); |       await request('POST', API.bases, formData, { isForm: true }); | ||||||
|       event.currentTarget.reset(); |       form.reset(); | ||||||
|       setImageMode('upload'); |       setImageMode('upload'); | ||||||
|       await refreshData(); |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       setErrors((prev) => ({ ...prev, base: error.message })); |       setErrors((prev) => ({ ...prev, base: error.message })); | ||||||
|     } |     } | ||||||
|  | @ -266,19 +344,200 @@ export default function Page() { | ||||||
| 
 | 
 | ||||||
|   async function handleDefenseSubmit(event: FormEvent<HTMLFormElement>) { |   async function handleDefenseSubmit(event: FormEvent<HTMLFormElement>) { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     const formData = new FormData(event.currentTarget); |     const form = event.currentTarget; | ||||||
|  |     const formData = new FormData(form); | ||||||
|     const payload = Object.fromEntries(formData.entries()); |     const payload = Object.fromEntries(formData.entries()); | ||||||
|     try { |     try { | ||||||
|       const baseId = payload.baseId as string; |       const baseId = payload.baseId as string; | ||||||
|       setErrors((prev) => ({ ...prev, defense: '' })); |       setErrors((prev) => ({ ...prev, defense: '' })); | ||||||
|       await request('POST', API.addDefense(baseId), payload); |       await request('POST', API.addDefense(baseId), payload); | ||||||
|       event.currentTarget.reset(); |       form.reset(); | ||||||
|       await refreshData(); |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       setErrors((prev) => ({ ...prev, defense: error.message })); |       setErrors((prev) => ({ ...prev, defense: error.message })); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   function startEditingBase(baseId: string) { | ||||||
|  |     setEditingBaseId(baseId); | ||||||
|  |     setEditImageMode('keep'); | ||||||
|  |     setErrors((prev) => ({ ...prev, base: '' })); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function cancelEditingBase() { | ||||||
|  |     setEditingBaseId(null); | ||||||
|  |     setEditImageMode('keep'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleBaseEditSubmit(event: FormEvent<HTMLFormElement>) { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     if (!editingBaseId || !baseBeingEdited) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const form = event.currentTarget; | ||||||
|  |     const formData = new FormData(form); | ||||||
|  |     formData.set('imageMode', editImageMode); | ||||||
|  |     formData.set('removeImage', editImageMode === 'remove' ? 'true' : 'false'); | ||||||
|  |     const requestedPrivate = formData.get('isPrivate'); | ||||||
|  |     const isPrivate = typeof requestedPrivate === 'string' ? requestedPrivate === 'on' : false; | ||||||
|  |     formData.set('isPrivate', isPrivate ? 'true' : 'false'); | ||||||
|  |     if (editImageMode === 'upload') { | ||||||
|  |       formData.delete('imageUrl'); | ||||||
|  |     } else if (editImageMode === 'url') { | ||||||
|  |       formData.delete('imageFile'); | ||||||
|  |     } else { | ||||||
|  |       formData.delete('imageFile'); | ||||||
|  |       formData.delete('imageUrl'); | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       setErrors((prev) => ({ ...prev, base: '' })); | ||||||
|  |       await request('PUT', API.updateBase(editingBaseId), formData, { isForm: true }); | ||||||
|  |       setEditingBaseId(null); | ||||||
|  |       setEditImageMode('keep'); | ||||||
|  |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setErrors((prev) => ({ ...prev, base: error.message })); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleDeleteBase(baseId: string) { | ||||||
|  |     const base = bases.find((item) => item.id === baseId); | ||||||
|  |     const name = base ? `"${base.title}"` : 'this base'; | ||||||
|  |     const confirmDelete = window.confirm(`Delete ${name}? This also removes its attacks.`); | ||||||
|  |     if (!confirmDelete) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       await request('DELETE', API.deleteBase(baseId)); | ||||||
|  |       if (editingBaseId === baseId) { | ||||||
|  |         setEditingBaseId(null); | ||||||
|  |         setEditImageMode('keep'); | ||||||
|  |       } | ||||||
|  |       if (selectedBaseId === baseId) { | ||||||
|  |         setSelectedBaseId(null); | ||||||
|  |         setView('dashboard'); | ||||||
|  |       } | ||||||
|  |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setErrors((prev) => ({ ...prev, base: error.message })); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleDeleteCategory(categoryId: string) { | ||||||
|  |     const category = categories.find((item) => item.id === categoryId); | ||||||
|  |     const name = category ? `"${category.name}"` : 'this category'; | ||||||
|  |     const confirmDelete = window.confirm(`Delete ${name}? Attacks tracked for it will also go away.`); | ||||||
|  |     if (!confirmDelete) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       await request('DELETE', API.deleteCategory(categoryId)); | ||||||
|  |       if (selectedCategoryId === categoryId) { | ||||||
|  |         setSelectedCategoryId(null); | ||||||
|  |         setView('dashboard'); | ||||||
|  |       } | ||||||
|  |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setErrors((prev) => ({ ...prev, category: error.message })); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function startEditingDefense(defenseId: string) { | ||||||
|  |     setEditingDefenseId(defenseId); | ||||||
|  |     setErrors((prev) => ({ ...prev, defense: '' })); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function cancelEditingDefense() { | ||||||
|  |     setEditingDefenseId(null); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleDefenseEditSubmit(event: FormEvent<HTMLFormElement>) { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     if (!editingDefenseId || !defenseBeingEdited) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const form = event.currentTarget; | ||||||
|  |     const formData = new FormData(form); | ||||||
|  |     const payload = Object.fromEntries(formData.entries()); | ||||||
|  |     try { | ||||||
|  |       setErrors((prev) => ({ ...prev, defense: '' })); | ||||||
|  |       await request('PUT', API.updateDefense(editingDefenseId), payload); | ||||||
|  |       setEditingDefenseId(null); | ||||||
|  |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setErrors((prev) => ({ ...prev, defense: error.message })); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleDeleteDefense(defenseId: string) { | ||||||
|  |     const confirmDelete = window.confirm('Delete this attack?'); | ||||||
|  |     if (!confirmDelete) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       await request('DELETE', API.deleteDefense(defenseId)); | ||||||
|  |       if (editingDefenseId === defenseId) { | ||||||
|  |         setEditingDefenseId(null); | ||||||
|  |       } | ||||||
|  |       await refreshData(); | ||||||
|  |       await refreshOwnProfileDetail(); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setErrors((prev) => ({ ...prev, defense: error.message })); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function handleProfileSearch(event: FormEvent<HTMLFormElement>) { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     setProfileError(''); | ||||||
|  |     const term = profileSearchTerm.trim(); | ||||||
|  |     if (!term) { | ||||||
|  |       setProfileResults([]); | ||||||
|  |       setProfileDetail(null); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       setProfileLoading(true); | ||||||
|  |       const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`); | ||||||
|  |       setProfileResults(data.profiles || []); | ||||||
|  |       setProfileDetail(null); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setProfileResults([]); | ||||||
|  |       setProfileDetail(null); | ||||||
|  |       setProfileError(error.message); | ||||||
|  |     } finally { | ||||||
|  |       setProfileLoading(false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function loadProfile(username: string) { | ||||||
|  |     try { | ||||||
|  |       setProfileLoading(true); | ||||||
|  |       setProfileError(''); | ||||||
|  |       const data: ProfileDetail = await request('GET', API.profileDetail(username)); | ||||||
|  |       setProfileDetail(data); | ||||||
|  |     } catch (error: any) { | ||||||
|  |       setProfileDetail(null); | ||||||
|  |       setProfileError(error.message); | ||||||
|  |     } finally { | ||||||
|  |       setProfileLoading(false); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function clearProfileDetail() { | ||||||
|  |     setProfileDetail(null); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async function refreshOwnProfileDetail() { | ||||||
|  |     if (profileDetail && user && profileDetail.profile.username === user.username) { | ||||||
|  |       await loadProfile(user.username); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async function handleLogout() { |   async function handleLogout() { | ||||||
|     try { |     try { | ||||||
|       await request('POST', API.logout); |       await request('POST', API.logout); | ||||||
|  | @ -292,6 +551,14 @@ export default function Page() { | ||||||
|       setSummaries(null); |       setSummaries(null); | ||||||
|       setSelectedBaseId(null); |       setSelectedBaseId(null); | ||||||
|       setSelectedCategoryId(null); |       setSelectedCategoryId(null); | ||||||
|  |       setEditingBaseId(null); | ||||||
|  |       setEditingDefenseId(null); | ||||||
|  |       setEditImageMode('keep'); | ||||||
|  |       setProfileResults([]); | ||||||
|  |       setProfileDetail(null); | ||||||
|  |       setProfileSearchTerm(''); | ||||||
|  |       setProfileError(''); | ||||||
|  |       setProfileLoading(false); | ||||||
|       setView('dashboard'); |       setView('dashboard'); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | @ -445,14 +712,19 @@ export default function Page() { | ||||||
|                 <h3>Base Averages</h3> |                 <h3>Base Averages</h3> | ||||||
|                 <ul id="base-summary" className="list compact"> |                 <ul id="base-summary" className="list compact"> | ||||||
|                   {summaries && summaries.bases.length ? ( |                   {summaries && summaries.bases.length ? ( | ||||||
|                     summaries.bases.map((base) => ( |                     summaries.bases.map((base) => { | ||||||
|  |                       const baseMeta = bases.find((item) => item.id === base.baseId); | ||||||
|  |                       return ( | ||||||
|                         <li |                         <li | ||||||
|                           key={base.baseId} |                           key={base.baseId} | ||||||
|                           className="list-item clickable" |                           className="list-item clickable" | ||||||
|                           onClick={() => openBaseDetail(base.baseId)} |                           onClick={() => openBaseDetail(base.baseId)} | ||||||
|                         > |                         > | ||||||
|                           <div className="defense-header"> |                           <div className="defense-header"> | ||||||
|                           <strong>{base.title}</strong> |                             <div> | ||||||
|  |                               <strong>{base.title}</strong>{' '} | ||||||
|  |                               {baseMeta?.isPrivate ? <span className="badge muted">Private</span> : null} | ||||||
|  |                             </div> | ||||||
|                             <span className="badge">{base.count} defenses</span> |                             <span className="badge">{base.count} defenses</span> | ||||||
|                           </div> |                           </div> | ||||||
|                           <div className="defense-meta"> |                           <div className="defense-meta"> | ||||||
|  | @ -461,7 +733,8 @@ export default function Page() { | ||||||
|                             <span>{formatTrophies(base.averageTrophies)} avg</span> |                             <span>{formatTrophies(base.averageTrophies)} avg</span> | ||||||
|                           </div> |                           </div> | ||||||
|                         </li> |                         </li> | ||||||
|                     )) |                       ); | ||||||
|  |                     }) | ||||||
|                   ) : ( |                   ) : ( | ||||||
|                     <li> |                     <li> | ||||||
|                       {bases.length |                       {bases.length | ||||||
|  | @ -500,6 +773,134 @@ export default function Page() { | ||||||
|             </div> |             </div> | ||||||
|           </section> |           </section> | ||||||
| 
 | 
 | ||||||
|  |           <div className="card"> | ||||||
|  |             <h2>Search Profiles</h2> | ||||||
|  |             <form className="form compact" onSubmit={handleProfileSearch}> | ||||||
|  |               <label> | ||||||
|  |                 Username | ||||||
|  |                 <input | ||||||
|  |                   type="text" | ||||||
|  |                   value={profileSearchTerm} | ||||||
|  |                   onChange={(event) => setProfileSearchTerm(event.target.value)} | ||||||
|  |                   placeholder="Search by username" | ||||||
|  |                 /> | ||||||
|  |               </label> | ||||||
|  |               <div className="defense-meta"> | ||||||
|  |                 <button type="submit" className="primary" disabled={profileLoading}> | ||||||
|  |                   {profileLoading ? 'Searching…' : 'Search'} | ||||||
|  |                 </button> | ||||||
|  |                 <button | ||||||
|  |                   type="button" | ||||||
|  |                   className="ghost" | ||||||
|  |                   onClick={() => { | ||||||
|  |                     setProfileSearchTerm(''); | ||||||
|  |                     setProfileResults([]); | ||||||
|  |                     setProfileDetail(null); | ||||||
|  |                     setProfileError(''); | ||||||
|  |                   }} | ||||||
|  |                 > | ||||||
|  |                   Clear | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |               <p className="form-error">{profileError}</p> | ||||||
|  |             </form> | ||||||
|  |             <div className="subsection"> | ||||||
|  |               <h3>Results</h3> | ||||||
|  |               <ul className="list compact"> | ||||||
|  |                 {profileResults.length ? ( | ||||||
|  |                   profileResults.map((profile) => ( | ||||||
|  |                     <li key={profile.id} className="list-item"> | ||||||
|  |                       <div className="defense-header"> | ||||||
|  |                         <strong>{profile.username}</strong> | ||||||
|  |                         <button | ||||||
|  |                           type="button" | ||||||
|  |                           className="ghost small" | ||||||
|  |                           onClick={() => loadProfile(profile.username)} | ||||||
|  |                           disabled={profileLoading} | ||||||
|  |                         > | ||||||
|  |                           View | ||||||
|  |                         </button> | ||||||
|  |                       </div> | ||||||
|  |                       <div className="defense-meta"> | ||||||
|  |                         <span>{profile.publicBaseCount} public bases</span> | ||||||
|  |                         <span>{profile.publicDefenseCount} public attacks</span> | ||||||
|  |                         <span>{new Date(profile.createdAt).toLocaleDateString()}</span> | ||||||
|  |                       </div> | ||||||
|  |                     </li> | ||||||
|  |                   )) | ||||||
|  |                 ) : ( | ||||||
|  |                   <li> | ||||||
|  |                     {profileSearchTerm | ||||||
|  |                       ? profileLoading | ||||||
|  |                         ? 'Searching…' | ||||||
|  |                         : 'No profiles matched that search.' | ||||||
|  |                       : 'Type a username to search the community.'} | ||||||
|  |                   </li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|  |             </div> | ||||||
|  |             {profileDetail && ( | ||||||
|  |               <div className="subsection"> | ||||||
|  |                 <div className="defense-header"> | ||||||
|  |                   <h3>Profile: {profileDetail.profile.username}</h3> | ||||||
|  |                   <button type="button" className="ghost small" onClick={clearProfileDetail}> | ||||||
|  |                     Close | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |                 <div className="defense-meta"> | ||||||
|  |                   <span>{profileDetail.profile.visibleBaseCount} visible bases</span> | ||||||
|  |                   <span>{profileDetail.profile.summary.count} attacks</span> | ||||||
|  |                   <span>{profileDetail.profile.summary.averageStars}★ avg</span> | ||||||
|  |                   <span>{profileDetail.profile.summary.averagePercent}% avg</span> | ||||||
|  |                 </div> | ||||||
|  |                 <ul className="list compact"> | ||||||
|  |                   {profileDetail.bases.length ? ( | ||||||
|  |                     profileDetail.bases.map((base) => ( | ||||||
|  |                       <li key={base.id} className="list-item"> | ||||||
|  |                         <div className="defense-header"> | ||||||
|  |                           <div> | ||||||
|  |                             <strong>{base.title}</strong>{' '} | ||||||
|  |                             {base.isPrivate ? <span className="badge muted">Private</span> : null} | ||||||
|  |                           </div> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="defense-meta"> | ||||||
|  |                           <span>{base.summary.count} attacks</span> | ||||||
|  |                           <span>{base.summary.averageStars}★ avg</span> | ||||||
|  |                           <span>{base.summary.averagePercent}% avg</span> | ||||||
|  |                         </div> | ||||||
|  |                         {base.defenses.length ? ( | ||||||
|  |                           <ul className="list compact"> | ||||||
|  |                             {base.defenses.slice(0, PROFILE_DEFENSE_PREVIEW_LIMIT).map((defense) => ( | ||||||
|  |                               <li key={defense.id} className="defense-meta"> | ||||||
|  |                                 <span>{defense.armyCategoryName}</span> | ||||||
|  |                                 <span>{defense.stars}★</span> | ||||||
|  |                                 <span>{defense.percent}%</span> | ||||||
|  |                                 <span>{formatTrophies(defense.trophies)}</span> | ||||||
|  |                                 <span>{new Date(defense.createdAt).toLocaleString()}</span> | ||||||
|  |                               </li> | ||||||
|  |                             ))} | ||||||
|  |                             {base.defenses.length > PROFILE_DEFENSE_PREVIEW_LIMIT ? ( | ||||||
|  |                               <li className="muted"> | ||||||
|  |                                 Showing {PROFILE_DEFENSE_PREVIEW_LIMIT} of {base.defenses.length} attacks. | ||||||
|  |                               </li> | ||||||
|  |                             ) : null} | ||||||
|  |                           </ul> | ||||||
|  |                         ) : ( | ||||||
|  |                           <p className="muted">No public attacks yet.</p> | ||||||
|  |                         )} | ||||||
|  |                       </li> | ||||||
|  |                     )) | ||||||
|  |                   ) : ( | ||||||
|  |                     <li className="muted">No public bases to show.</li> | ||||||
|  |                   )} | ||||||
|  |                 </ul> | ||||||
|  |                 {!profileDetail.profile.isOwner ? ( | ||||||
|  |                   <p className="muted">Private bases stay hidden from other players.</p> | ||||||
|  |                 ) : null} | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|  |           </div> | ||||||
|  | 
 | ||||||
|           <section className="card" id="defense-log-card"> |           <section className="card" id="defense-log-card"> | ||||||
|             <h2>Defense Log</h2> |             <h2>Defense Log</h2> | ||||||
|             <p className="subtitle">Newest entries appear on top.</p> |             <p className="subtitle">Newest entries appear on top.</p> | ||||||
|  | @ -547,6 +948,34 @@ export default function Page() { | ||||||
|                 {errors.category} |                 {errors.category} | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|  |             <div className="subsection"> | ||||||
|  |               <h3>Existing Categories</h3> | ||||||
|  |               <ul className="list compact"> | ||||||
|  |                 {categories.length ? ( | ||||||
|  |                   categories.map((category) => ( | ||||||
|  |                     <li key={category.id} className="list-item"> | ||||||
|  |                       <div className="defense-header"> | ||||||
|  |                         <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"> | ||||||
|  |                         <span>{new Date(category.createdAt).toLocaleDateString()}</span> | ||||||
|  |                       </div> | ||||||
|  |                     </li> | ||||||
|  |                   )) | ||||||
|  |                 ) : ( | ||||||
|  |                   <li>No categories yet.</li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|  |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div className="card"> |           <div className="card"> | ||||||
|             <h2>New Base</h2> |             <h2>New Base</h2> | ||||||
|  | @ -593,6 +1022,9 @@ export default function Page() { | ||||||
|                   <input type="url" name="imageUrl" placeholder="https://" required /> |                   <input type="url" name="imageUrl" placeholder="https://" required /> | ||||||
|                 )} |                 )} | ||||||
|               </div> |               </div> | ||||||
|  |               <label> | ||||||
|  |                 <input type="checkbox" name="isPrivate" /> Make this base private | ||||||
|  |               </label> | ||||||
|               <button type="submit" className="primary"> |               <button type="submit" className="primary"> | ||||||
|                 Add Base |                 Add Base | ||||||
|               </button> |               </button> | ||||||
|  | @ -600,6 +1032,141 @@ export default function Page() { | ||||||
|                 {errors.base} |                 {errors.base} | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|  |             <div className="subsection"> | ||||||
|  |               <h3>Manage Bases</h3> | ||||||
|  |               <ul className="list compact"> | ||||||
|  |                 {bases.length ? ( | ||||||
|  |                   bases.map((base) => ( | ||||||
|  |                     <li key={base.id} className="list-item"> | ||||||
|  |                       <div className="defense-header"> | ||||||
|  |                         <div> | ||||||
|  |                           <strong>{base.title}</strong>{' '} | ||||||
|  |                           {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 className="defense-meta"> | ||||||
|  |                         <span>{new Date(base.createdAt).toLocaleDateString()}</span> | ||||||
|  |                       </div> | ||||||
|  |                     </li> | ||||||
|  |                   )) | ||||||
|  |                 ) : ( | ||||||
|  |                   <li>No bases yet.</li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|  |             </div> | ||||||
|  |             {baseBeingEdited && ( | ||||||
|  |               <div className="subsection"> | ||||||
|  |                 <h3>Edit Base: {baseBeingEdited.title}</h3> | ||||||
|  |                 <form | ||||||
|  |                   key={baseBeingEdited.id} | ||||||
|  |                   className="form compact" | ||||||
|  |                   onSubmit={handleBaseEditSubmit} | ||||||
|  |                 > | ||||||
|  |                   <label> | ||||||
|  |                     Title | ||||||
|  |                     <input type="text" name="title" defaultValue={baseBeingEdited.title} required /> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Description | ||||||
|  |                     <textarea name="description" rows={2} defaultValue={baseBeingEdited.description}></textarea> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Planning Link | ||||||
|  |                     <input type="url" name="url" placeholder="https://" defaultValue={baseBeingEdited.url} /> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     <input type="checkbox" name="isPrivate" defaultChecked={baseBeingEdited.isPrivate} /> Keep this base private | ||||||
|  |                   </label> | ||||||
|  |                   <div className="input-group"> | ||||||
|  |                     <span className="input-label">Image Options</span> | ||||||
|  |                     <div className="image-mode"> | ||||||
|  |                       <label> | ||||||
|  |                         <input | ||||||
|  |                           type="radio" | ||||||
|  |                           name="imageMode" | ||||||
|  |                           value="keep" | ||||||
|  |                           checked={editImageMode === 'keep'} | ||||||
|  |                           onChange={() => setEditImageMode('keep')} | ||||||
|  |                         /> | ||||||
|  |                         Keep current | ||||||
|  |                       </label> | ||||||
|  |                       <label> | ||||||
|  |                         <input | ||||||
|  |                           type="radio" | ||||||
|  |                           name="imageMode" | ||||||
|  |                           value="upload" | ||||||
|  |                           checked={editImageMode === 'upload'} | ||||||
|  |                           onChange={() => setEditImageMode('upload')} | ||||||
|  |                         /> | ||||||
|  |                         Upload new | ||||||
|  |                       </label> | ||||||
|  |                       <label> | ||||||
|  |                         <input | ||||||
|  |                           type="radio" | ||||||
|  |                           name="imageMode" | ||||||
|  |                           value="url" | ||||||
|  |                           checked={editImageMode === 'url'} | ||||||
|  |                           onChange={() => setEditImageMode('url')} | ||||||
|  |                         /> | ||||||
|  |                         Use link | ||||||
|  |                       </label> | ||||||
|  |                       <label> | ||||||
|  |                         <input | ||||||
|  |                           type="radio" | ||||||
|  |                           name="imageMode" | ||||||
|  |                           value="remove" | ||||||
|  |                           checked={editImageMode === 'remove'} | ||||||
|  |                           onChange={() => setEditImageMode('remove')} | ||||||
|  |                         /> | ||||||
|  |                         Remove image | ||||||
|  |                       </label> | ||||||
|  |                     </div> | ||||||
|  |                     {editImageMode === 'upload' ? <input type="file" name="imageFile" accept="image/*" /> : null} | ||||||
|  |                     {editImageMode === 'url' ? ( | ||||||
|  |                       <input | ||||||
|  |                         type="url" | ||||||
|  |                         name="imageUrl" | ||||||
|  |                         placeholder="https://" | ||||||
|  |                         defaultValue={ | ||||||
|  |                           baseBeingEdited.imageUrl && baseBeingEdited.imageUrl.startsWith('http') | ||||||
|  |                             ? baseBeingEdited.imageUrl | ||||||
|  |                             : '' | ||||||
|  |                         } | ||||||
|  |                       /> | ||||||
|  |                     ) : null} | ||||||
|  |                   </div> | ||||||
|  |                   <div className="defense-meta"> | ||||||
|  |                     <button type="submit" className="primary"> | ||||||
|  |                       Save Changes | ||||||
|  |                     </button> | ||||||
|  |                     <button type="button" className="ghost" onClick={cancelEditingBase}> | ||||||
|  |                       Cancel | ||||||
|  |                     </button> | ||||||
|  |                   </div> | ||||||
|  |                   {editingBaseId === baseBeingEdited.id && errors.base ? ( | ||||||
|  |                     <p className="form-error" data-for="base"> | ||||||
|  |                       {errors.base} | ||||||
|  |                     </p> | ||||||
|  |                   ) : null} | ||||||
|  |                 </form> | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|           </div> |           </div> | ||||||
|           <div className="card"> |           <div className="card"> | ||||||
|             <h2>Log Defense</h2> |             <h2>Log Defense</h2> | ||||||
|  | @ -658,6 +1225,137 @@ export default function Page() { | ||||||
|                 {errors.defense} |                 {errors.defense} | ||||||
|               </p> |               </p> | ||||||
|             </form> |             </form> | ||||||
|  |             <div className="subsection"> | ||||||
|  |               <h3>Manage Attacks</h3> | ||||||
|  |               <ul className="list compact"> | ||||||
|  |                 {defenses.length ? ( | ||||||
|  |                   defenses.slice(0, 10).map((defense) => { | ||||||
|  |                     const categoryName = | ||||||
|  |                       categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army'; | ||||||
|  |                     return ( | ||||||
|  |                       <li key={defense.id} className="list-item"> | ||||||
|  |                         <div className="defense-header"> | ||||||
|  |                           <div> | ||||||
|  |                             <strong>{defense.baseTitle}</strong>{' '} | ||||||
|  |                             <span className="badge">{categoryName}</span> | ||||||
|  |                           </div> | ||||||
|  |                           <div className="defense-meta"> | ||||||
|  |                             <button | ||||||
|  |                               type="button" | ||||||
|  |                               className="ghost small" | ||||||
|  |                               onClick={() => startEditingDefense(defense.id)} | ||||||
|  |                             > | ||||||
|  |                               Edit | ||||||
|  |                             </button> | ||||||
|  |                             <button | ||||||
|  |                               type="button" | ||||||
|  |                               className="ghost small" | ||||||
|  |                               onClick={() => handleDeleteDefense(defense.id)} | ||||||
|  |                             > | ||||||
|  |                               Delete | ||||||
|  |                             </button> | ||||||
|  |                           </div> | ||||||
|  |                         </div> | ||||||
|  |                         <div className="defense-meta"> | ||||||
|  |                           <span>{defense.stars}★</span> | ||||||
|  |                           <span>{defense.percent}%</span> | ||||||
|  |                           <span>{formatTrophies(defense.trophies)}</span> | ||||||
|  |                           <span>{new Date(defense.createdAt).toLocaleString()}</span> | ||||||
|  |                         </div> | ||||||
|  |                       </li> | ||||||
|  |                     ); | ||||||
|  |                   }) | ||||||
|  |                 ) : ( | ||||||
|  |                   <li>No attacks logged yet.</li> | ||||||
|  |                 )} | ||||||
|  |               </ul> | ||||||
|  |               {defenses.length > 10 ? ( | ||||||
|  |                 <p className="muted">Showing the latest 10 entries.</p> | ||||||
|  |               ) : null} | ||||||
|  |             </div> | ||||||
|  |             {defenseBeingEdited && ( | ||||||
|  |               <div className="subsection"> | ||||||
|  |                 <h3>Edit Attack</h3> | ||||||
|  |                 <form | ||||||
|  |                   key={defenseBeingEdited.id} | ||||||
|  |                   className="form compact" | ||||||
|  |                   onSubmit={handleDefenseEditSubmit} | ||||||
|  |                 > | ||||||
|  |                   <label> | ||||||
|  |                     Base | ||||||
|  |                     <select name="baseId" required defaultValue={defenseBeingEdited.baseId}> | ||||||
|  |                       {bases.map((base) => ( | ||||||
|  |                         <option key={base.id} value={base.id}> | ||||||
|  |                           {base.title} | ||||||
|  |                         </option> | ||||||
|  |                       ))} | ||||||
|  |                     </select> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Army Category | ||||||
|  |                     <select name="armyCategoryId" required defaultValue={defenseBeingEdited.armyCategoryId}> | ||||||
|  |                       {categories.map((category) => ( | ||||||
|  |                         <option key={category.id} value={category.id}> | ||||||
|  |                           {category.name} | ||||||
|  |                         </option> | ||||||
|  |                       ))} | ||||||
|  |                     </select> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Stars | ||||||
|  |                     <input | ||||||
|  |                       type="number" | ||||||
|  |                       name="stars" | ||||||
|  |                       min={0} | ||||||
|  |                       max={3} | ||||||
|  |                       step={1} | ||||||
|  |                       required | ||||||
|  |                       defaultValue={defenseBeingEdited.stars} | ||||||
|  |                       className="styled-number" | ||||||
|  |                     /> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Destruction % | ||||||
|  |                     <input | ||||||
|  |                       type="number" | ||||||
|  |                       name="percent" | ||||||
|  |                       min={0} | ||||||
|  |                       max={100} | ||||||
|  |                       step={1} | ||||||
|  |                       required | ||||||
|  |                       defaultValue={defenseBeingEdited.percent} | ||||||
|  |                       className="styled-number" | ||||||
|  |                     /> | ||||||
|  |                   </label> | ||||||
|  |                   <label> | ||||||
|  |                     Trophies ± | ||||||
|  |                     <input | ||||||
|  |                       type="number" | ||||||
|  |                       name="trophies" | ||||||
|  |                       min={-200} | ||||||
|  |                       max={200} | ||||||
|  |                       step={1} | ||||||
|  |                       required | ||||||
|  |                       defaultValue={defenseBeingEdited.trophies} | ||||||
|  |                       className="styled-number" | ||||||
|  |                     /> | ||||||
|  |                   </label> | ||||||
|  |                   <div className="defense-meta"> | ||||||
|  |                     <button type="submit" className="primary"> | ||||||
|  |                       Save Attack | ||||||
|  |                     </button> | ||||||
|  |                     <button type="button" className="ghost" onClick={cancelEditingDefense}> | ||||||
|  |                       Cancel | ||||||
|  |                     </button> | ||||||
|  |                   </div> | ||||||
|  |                   {editingDefenseId === defenseBeingEdited.id && errors.defense ? ( | ||||||
|  |                     <p className="form-error" data-for="defense"> | ||||||
|  |                       {errors.defense} | ||||||
|  |                     </p> | ||||||
|  |                   ) : null} | ||||||
|  |                 </form> | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|           </div> |           </div> | ||||||
|         </section> |         </section> | ||||||
| 
 | 
 | ||||||
|  | @ -671,7 +1369,10 @@ export default function Page() { | ||||||
|                 {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} |                 {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} | ||||||
|               </span> |               </span> | ||||||
|             </div> |             </div> | ||||||
|             <h2 id="base-detail-title">{baseDetailMeta?.title}</h2> |             <h2 id="base-detail-title"> | ||||||
|  |               {baseDetailMeta?.title}{' '} | ||||||
|  |               {baseDetailMeta?.isPrivate ? <span className="badge muted">Private</span> : null} | ||||||
|  |             </h2> | ||||||
|             <p id="base-detail-description" className={baseDetailMeta?.description ? '' : 'muted'}> |             <p id="base-detail-description" className={baseDetailMeta?.description ? '' : 'muted'}> | ||||||
|               {baseDetailMeta?.description || 'No description yet.'} |               {baseDetailMeta?.description || 'No description yet.'} | ||||||
|             </p> |             </p> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 Hymmel
						Hymmel