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? | ||||
|   imageUrl    String? | ||||
|   imagePath   String? | ||||
|   isPrivate   Boolean  @default(false) | ||||
|   createdAt   DateTime @default(now()) | ||||
|   user        User     @relation(fields: [userId], references: [id]) | ||||
|   userId      String | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ const app = express(); | |||
| const __filename = fileURLToPath(import.meta.url); | ||||
| const __dirname = path.dirname(__filename); | ||||
| const uploadDir = path.join(__dirname, '..', 'uploads'); | ||||
| const fsPromises = fs.promises; | ||||
| const jwtSecret = process.env.JWT_SECRET || 'super-secret-key'; | ||||
| const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100'; | ||||
| 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) => { | ||||
|   const bases = await prisma.base.findMany({ | ||||
|     where: { userId: req.user.id }, | ||||
|  | @ -217,6 +246,7 @@ app.get('/bases', requireAuth, async (req, res) => { | |||
|       description: base.description || '', | ||||
|       url: base.url || '', | ||||
|       imageUrl: buildImageUrl(base), | ||||
|       isPrivate: base.isPrivate, | ||||
|       createdAt: base.createdAt, | ||||
|     })), | ||||
|   }); | ||||
|  | @ -224,7 +254,7 @@ app.get('/bases', requireAuth, async (req, res) => { | |||
| 
 | ||||
| app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => { | ||||
|   try { | ||||
|     const { title, description, url, imageMode, imageUrl } = req.body; | ||||
|     const { title, description, url, imageMode, imageUrl, isPrivate } = req.body; | ||||
|     if (!title || !title.trim()) { | ||||
|       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)) { | ||||
|         return res.status(400).json({ error: 'Image URL must be valid' }); | ||||
|       } | ||||
|       storedImageUrl = imageUrl; | ||||
|       storedImageUrl = imageUrl.trim(); | ||||
|     } else if (req.file) { | ||||
|       storedImagePath = req.file.filename; | ||||
|     } | ||||
|  | @ -250,6 +280,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => | |||
|         url: url?.trim() || null, | ||||
|         imageUrl: storedImageUrl, | ||||
|         imagePath: storedImagePath, | ||||
|         isPrivate: parseBoolean(isPrivate), | ||||
|         userId: req.user.id, | ||||
|       }, | ||||
|     }); | ||||
|  | @ -261,9 +292,141 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => | |||
|         description: base.description || '', | ||||
|         url: base.url || '', | ||||
|         imageUrl: buildImageUrl(base), | ||||
|         isPrivate: base.isPrivate, | ||||
|         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) { | ||||
|     console.error(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) => { | ||||
|   try { | ||||
|     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) { | ||||
|   if (!defenses.length) { | ||||
|     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) { | ||||
|   return { | ||||
|     id: user.id, | ||||
|  |  | |||
|  | @ -32,8 +32,17 @@ const API = { | |||
|   bases: `${API_BASE}/bases`, | ||||
|   addDefense: (baseId: string) => `${API_BASE}/bases/${baseId}/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 = { | ||||
|   id: string; | ||||
|   username: string; | ||||
|  | @ -52,6 +61,7 @@ type BaseItem = { | |||
|   description: string; | ||||
|   url: string; | ||||
|   imageUrl: string; | ||||
|   isPrivate: boolean; | ||||
|   createdAt: string; | ||||
| }; | ||||
| 
 | ||||
|  | @ -67,6 +77,49 @@ type DefenseItem = { | |||
|   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 = { | ||||
|   count: number; | ||||
|   averageStars: number; | ||||
|  | @ -144,6 +197,14 @@ export default function Page() { | |||
|   const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null); | ||||
|   const [errors, setErrors] = useState<ErrorState>(initialErrors); | ||||
|   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); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|  | @ -197,6 +258,16 @@ export default function Page() { | |||
|     return map; | ||||
|   }, [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>) { | ||||
|     event.preventDefault(); | ||||
|     const formData = new FormData(event.currentTarget); | ||||
|  | @ -232,12 +303,13 @@ export default function Page() { | |||
| 
 | ||||
|   async function handleCategorySubmit(event: FormEvent<HTMLFormElement>) { | ||||
|     event.preventDefault(); | ||||
|     const formData = new FormData(event.currentTarget); | ||||
|     const form = event.currentTarget; | ||||
|     const formData = new FormData(form); | ||||
|     const payload = Object.fromEntries(formData.entries()); | ||||
|     try { | ||||
|       setErrors((prev) => ({ ...prev, category: '' })); | ||||
|       await request('POST', API.categories, payload); | ||||
|       event.currentTarget.reset(); | ||||
|       form.reset(); | ||||
|       await refreshData(); | ||||
|     } catch (error: any) { | ||||
|       setErrors((prev) => ({ ...prev, category: error.message })); | ||||
|  | @ -246,19 +318,25 @@ export default function Page() { | |||
| 
 | ||||
|   async function handleBaseSubmit(event: FormEvent<HTMLFormElement>) { | ||||
|     event.preventDefault(); | ||||
|     const formData = new FormData(event.currentTarget); | ||||
|     const form = event.currentTarget; | ||||
|     const formData = new FormData(form); | ||||
|     formData.set('imageMode', imageMode); | ||||
|     if (imageMode === 'upload') { | ||||
|       formData.delete('imageUrl'); | ||||
|     } else { | ||||
|       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 { | ||||
|       setErrors((prev) => ({ ...prev, base: '' })); | ||||
|       await request('POST', API.bases, formData, { isForm: true }); | ||||
|       event.currentTarget.reset(); | ||||
|       form.reset(); | ||||
|       setImageMode('upload'); | ||||
|       await refreshData(); | ||||
|       await refreshOwnProfileDetail(); | ||||
|     } catch (error: any) { | ||||
|       setErrors((prev) => ({ ...prev, base: error.message })); | ||||
|     } | ||||
|  | @ -266,19 +344,200 @@ export default function Page() { | |||
| 
 | ||||
|   async function handleDefenseSubmit(event: FormEvent<HTMLFormElement>) { | ||||
|     event.preventDefault(); | ||||
|     const formData = new FormData(event.currentTarget); | ||||
|     const form = event.currentTarget; | ||||
|     const formData = new FormData(form); | ||||
|     const payload = Object.fromEntries(formData.entries()); | ||||
|     try { | ||||
|       const baseId = payload.baseId as string; | ||||
|       setErrors((prev) => ({ ...prev, defense: '' })); | ||||
|       await request('POST', API.addDefense(baseId), payload); | ||||
|       event.currentTarget.reset(); | ||||
|       form.reset(); | ||||
|       await refreshData(); | ||||
|       await refreshOwnProfileDetail(); | ||||
|     } catch (error: any) { | ||||
|       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() { | ||||
|     try { | ||||
|       await request('POST', API.logout); | ||||
|  | @ -292,6 +551,14 @@ export default function Page() { | |||
|       setSummaries(null); | ||||
|       setSelectedBaseId(null); | ||||
|       setSelectedCategoryId(null); | ||||
|       setEditingBaseId(null); | ||||
|       setEditingDefenseId(null); | ||||
|       setEditImageMode('keep'); | ||||
|       setProfileResults([]); | ||||
|       setProfileDetail(null); | ||||
|       setProfileSearchTerm(''); | ||||
|       setProfileError(''); | ||||
|       setProfileLoading(false); | ||||
|       setView('dashboard'); | ||||
|     } | ||||
|   } | ||||
|  | @ -445,23 +712,29 @@ export default function Page() { | |||
|                 <h3>Base Averages</h3> | ||||
|                 <ul id="base-summary" className="list compact"> | ||||
|                   {summaries && summaries.bases.length ? ( | ||||
|                     summaries.bases.map((base) => ( | ||||
|                       <li | ||||
|                         key={base.baseId} | ||||
|                         className="list-item clickable" | ||||
|                         onClick={() => openBaseDetail(base.baseId)} | ||||
|                       > | ||||
|                         <div className="defense-header"> | ||||
|                           <strong>{base.title}</strong> | ||||
|                           <span className="badge">{base.count} defenses</span> | ||||
|                         </div> | ||||
|                         <div className="defense-meta"> | ||||
|                           <span>{base.averageStars}★ avg</span> | ||||
|                           <span>{base.averagePercent}% avg</span> | ||||
|                           <span>{formatTrophies(base.averageTrophies)} avg</span> | ||||
|                         </div> | ||||
|                       </li> | ||||
|                     )) | ||||
|                     summaries.bases.map((base) => { | ||||
|                       const baseMeta = bases.find((item) => item.id === base.baseId); | ||||
|                       return ( | ||||
|                         <li | ||||
|                           key={base.baseId} | ||||
|                           className="list-item clickable" | ||||
|                           onClick={() => openBaseDetail(base.baseId)} | ||||
|                         > | ||||
|                           <div className="defense-header"> | ||||
|                             <div> | ||||
|                               <strong>{base.title}</strong>{' '} | ||||
|                               {baseMeta?.isPrivate ? <span className="badge muted">Private</span> : null} | ||||
|                             </div> | ||||
|                             <span className="badge">{base.count} defenses</span> | ||||
|                           </div> | ||||
|                           <div className="defense-meta"> | ||||
|                             <span>{base.averageStars}★ avg</span> | ||||
|                             <span>{base.averagePercent}% avg</span> | ||||
|                             <span>{formatTrophies(base.averageTrophies)} avg</span> | ||||
|                           </div> | ||||
|                         </li> | ||||
|                       ); | ||||
|                     }) | ||||
|                   ) : ( | ||||
|                     <li> | ||||
|                       {bases.length | ||||
|  | @ -500,6 +773,134 @@ export default function Page() { | |||
|             </div> | ||||
|           </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"> | ||||
|             <h2>Defense Log</h2> | ||||
|             <p className="subtitle">Newest entries appear on top.</p> | ||||
|  | @ -547,6 +948,34 @@ export default function Page() { | |||
|                 {errors.category} | ||||
|               </p> | ||||
|             </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 className="card"> | ||||
|             <h2>New Base</h2> | ||||
|  | @ -593,6 +1022,9 @@ export default function Page() { | |||
|                   <input type="url" name="imageUrl" placeholder="https://" required /> | ||||
|                 )} | ||||
|               </div> | ||||
|               <label> | ||||
|                 <input type="checkbox" name="isPrivate" /> Make this base private | ||||
|               </label> | ||||
|               <button type="submit" className="primary"> | ||||
|                 Add Base | ||||
|               </button> | ||||
|  | @ -600,6 +1032,141 @@ export default function Page() { | |||
|                 {errors.base} | ||||
|               </p> | ||||
|             </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 className="card"> | ||||
|             <h2>Log Defense</h2> | ||||
|  | @ -658,6 +1225,137 @@ export default function Page() { | |||
|                 {errors.defense} | ||||
|               </p> | ||||
|             </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> | ||||
|         </section> | ||||
| 
 | ||||
|  | @ -671,7 +1369,10 @@ export default function Page() { | |||
|                 {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} | ||||
|               </span> | ||||
|             </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'}> | ||||
|               {baseDetailMeta?.description || 'No description yet.'} | ||||
|             </p> | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 Hymmel
						Hymmel