From 470b80aea98d156cc8bd38761b615fa52dc575da Mon Sep 17 00:00:00 2001 From: Hymmel Date: Thu, 9 Oct 2025 10:17:44 +0200 Subject: [PATCH] j --- backend/prisma/schema.prisma | 1 + backend/src/server.js | 400 ++++++++++++++++++- frontend/app/page.tsx | 749 +++++++++++++++++++++++++++++++++-- 3 files changed, 1124 insertions(+), 26 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2c2615c..f853145 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/server.js b/backend/src/server.js index 6a95c96..430f317 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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, diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ca1c34a..51647f5 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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(null); const [errors, setErrors] = useState(initialErrors); const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload'); + const [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep'); + const [editingBaseId, setEditingBaseId] = useState(null); + const [editingDefenseId, setEditingDefenseId] = useState(null); + const [profileSearchTerm, setProfileSearchTerm] = useState(''); + const [profileResults, setProfileResults] = useState([]); + const [profileDetail, setProfileDetail] = useState(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) { event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -232,12 +303,13 @@ export default function Page() { async function handleCategorySubmit(event: FormEvent) { 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) { 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) { 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) { + 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) { + 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) { + 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() {

Base Averages

    {summaries && summaries.bases.length ? ( - summaries.bases.map((base) => ( -
  • openBaseDetail(base.baseId)} - > -
    - {base.title} - {base.count} defenses -
    -
    - {base.averageStars}★ avg - {base.averagePercent}% avg - {formatTrophies(base.averageTrophies)} avg -
    -
  • - )) + summaries.bases.map((base) => { + const baseMeta = bases.find((item) => item.id === base.baseId); + return ( +
  • openBaseDetail(base.baseId)} + > +
    +
    + {base.title}{' '} + {baseMeta?.isPrivate ? Private : null} +
    + {base.count} defenses +
    +
    + {base.averageStars}★ avg + {base.averagePercent}% avg + {formatTrophies(base.averageTrophies)} avg +
    +
  • + ); + }) ) : (
  • {bases.length @@ -500,6 +773,134 @@ export default function Page() { +
    +

    Search Profiles

    +
    + +
    + + +
    +

    {profileError}

    +
    +
    +

    Results

    +
      + {profileResults.length ? ( + profileResults.map((profile) => ( +
    • +
      + {profile.username} + +
      +
      + {profile.publicBaseCount} public bases + {profile.publicDefenseCount} public attacks + {new Date(profile.createdAt).toLocaleDateString()} +
      +
    • + )) + ) : ( +
    • + {profileSearchTerm + ? profileLoading + ? 'Searching…' + : 'No profiles matched that search.' + : 'Type a username to search the community.'} +
    • + )} +
    +
    + {profileDetail && ( +
    +
    +

    Profile: {profileDetail.profile.username}

    + +
    +
    + {profileDetail.profile.visibleBaseCount} visible bases + {profileDetail.profile.summary.count} attacks + {profileDetail.profile.summary.averageStars}★ avg + {profileDetail.profile.summary.averagePercent}% avg +
    +
      + {profileDetail.bases.length ? ( + profileDetail.bases.map((base) => ( +
    • +
      +
      + {base.title}{' '} + {base.isPrivate ? Private : null} +
      +
      +
      + {base.summary.count} attacks + {base.summary.averageStars}★ avg + {base.summary.averagePercent}% avg +
      + {base.defenses.length ? ( +
        + {base.defenses.slice(0, PROFILE_DEFENSE_PREVIEW_LIMIT).map((defense) => ( +
      • + {defense.armyCategoryName} + {defense.stars}★ + {defense.percent}% + {formatTrophies(defense.trophies)} + {new Date(defense.createdAt).toLocaleString()} +
      • + ))} + {base.defenses.length > PROFILE_DEFENSE_PREVIEW_LIMIT ? ( +
      • + Showing {PROFILE_DEFENSE_PREVIEW_LIMIT} of {base.defenses.length} attacks. +
      • + ) : null} +
      + ) : ( +

      No public attacks yet.

      + )} +
    • + )) + ) : ( +
    • No public bases to show.
    • + )} +
    + {!profileDetail.profile.isOwner ? ( +

    Private bases stay hidden from other players.

    + ) : null} +
    + )} +
    +

    Defense Log

    Newest entries appear on top.

    @@ -547,6 +948,34 @@ export default function Page() { {errors.category}

    +
    +

    Existing Categories

    +
      + {categories.length ? ( + categories.map((category) => ( +
    • +
      + {category.name} +
      + +
      +
      +
      + {new Date(category.createdAt).toLocaleDateString()} +
      +
    • + )) + ) : ( +
    • No categories yet.
    • + )} +
    +

    New Base

    @@ -593,6 +1022,9 @@ export default function Page() { )}
    + @@ -600,6 +1032,141 @@ export default function Page() { {errors.base}

    +
    +

    Manage Bases

    +
      + {bases.length ? ( + bases.map((base) => ( +
    • +
      +
      + {base.title}{' '} + {base.isPrivate ? Private : null} +
      +
      + + +
      +
      +
      + {new Date(base.createdAt).toLocaleDateString()} +
      +
    • + )) + ) : ( +
    • No bases yet.
    • + )} +
    +
    + {baseBeingEdited && ( +
    +

    Edit Base: {baseBeingEdited.title}

    +
    + + + + +
    + Image Options +
    + + + + +
    + {editImageMode === 'upload' ? : null} + {editImageMode === 'url' ? ( + + ) : null} +
    +
    + + +
    + {editingBaseId === baseBeingEdited.id && errors.base ? ( +

    + {errors.base} +

    + ) : null} +
    +
    + )}

    Log Defense

    @@ -658,6 +1225,137 @@ export default function Page() { {errors.defense}

    +
    +

    Manage Attacks

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

    Showing the latest 10 entries.

    + ) : null} +
    + {defenseBeingEdited && ( +
    +

    Edit Attack

    +
    + + + + + +
    + + +
    + {editingDefenseId === defenseBeingEdited.id && errors.defense ? ( +

    + {errors.defense} +

    + ) : null} +
    +
    + )}
    @@ -671,7 +1369,10 @@ export default function Page() { {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} -

    {baseDetailMeta?.title}

    +

    + {baseDetailMeta?.title}{' '} + {baseDetailMeta?.isPrivate ? Private : null} +

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