2043 lines
		
	
	
	
		
			77 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			2043 lines
		
	
	
	
		
			77 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 'use client';
 | |
| 
 | |
| import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
 | |
| 
 | |
| const defaultApiBase =
 | |
|   process.env.NODE_ENV === 'production'
 | |
|     ? 'https://backend.basetracker.lona-development.org'
 | |
|     : 'https://backend.dev.basetracker.lona-development.org';
 | |
| 
 | |
| // Normalize backend URL so production always uses HTTPS while keeping local HTTP.
 | |
| function normalizeApiBase(url: string) {
 | |
|   try {
 | |
|     const parsed = new URL(url);
 | |
|     const isLocalhost = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
 | |
|     if (!isLocalhost && parsed.protocol === 'http:') {
 | |
|       parsed.protocol = 'https:';
 | |
|     }
 | |
|     return parsed.toString().replace(/\/$/, '');
 | |
|   } catch (_error) {
 | |
|     return url.replace(/\/$/, '');
 | |
|   }
 | |
| }
 | |
| 
 | |
| const API_BASE = normalizeApiBase(process.env.NEXT_PUBLIC_BACKEND_URL ?? defaultApiBase);
 | |
| 
 | |
| const API = {
 | |
|   signup: `${API_BASE}/auth/signup`,
 | |
|   login: `${API_BASE}/auth/login`,
 | |
|   logout: `${API_BASE}/auth/logout`,
 | |
|   me: `${API_BASE}/auth/me`,
 | |
|   categories: `${API_BASE}/army-categories`,
 | |
|   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}`,
 | |
|   addTrophyReset: (baseId: string) => `${API_BASE}/bases/${baseId}/trophy-resets`,
 | |
|   updateTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
 | |
|   deleteTrophyReset: (resetId: string) => `${API_BASE}/trophy-resets/${resetId}`,
 | |
|   updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
 | |
|   deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
 | |
|   profiles: `${API_BASE}/profiles`,
 | |
|   profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`,
 | |
| };
 | |
| 
 | |
| function resolveImageUrl(url: string | null | undefined) {
 | |
|   if (!url) {
 | |
|     return '';
 | |
|   }
 | |
|   try {
 | |
|     return new URL(url, `${API_BASE}/`).toString();
 | |
|   } catch (_error) {
 | |
|     return url;
 | |
|   }
 | |
| }
 | |
| 
 | |
| type User = {
 | |
|   id: string;
 | |
|   username: string;
 | |
|   createdAt: string;
 | |
| };
 | |
| 
 | |
| type ArmyCategory = {
 | |
|   id: string;
 | |
|   name: string;
 | |
|   createdAt: string;
 | |
| };
 | |
| 
 | |
| type TrophyReset = {
 | |
|   id: string;
 | |
|   date: string;
 | |
|   trophiesAtStart: number;
 | |
|   trophiesLost: number;
 | |
|   numberOfDefenses: number;
 | |
|   createdAt: string;
 | |
| };
 | |
| 
 | |
| type BaseItem = {
 | |
|   id: string;
 | |
|   title: string;
 | |
|   description: string;
 | |
|   url: string;
 | |
|   imageUrl: string;
 | |
|   isPrivate: boolean;
 | |
|   createdAt: string;
 | |
|   trophyResets: TrophyReset[];
 | |
| };
 | |
| 
 | |
| type DefenseItem = {
 | |
|   id: string;
 | |
|   stars: number;
 | |
|   percent: number;
 | |
|   trophies: number;
 | |
|   armyCategoryId: string;
 | |
|   createdAt: string;
 | |
|   baseId: string;
 | |
|   baseTitle: 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[];
 | |
|   trophyResets: TrophyReset[];
 | |
| };
 | |
| 
 | |
| type ProfileCategorySummary = Summary & {
 | |
|   armyCategoryId: string;
 | |
|   name: string;
 | |
| };
 | |
| 
 | |
| 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;
 | |
|   averagePercent: number;
 | |
|   averageTrophies: number;
 | |
| };
 | |
| 
 | |
| type BaseSummary = Summary & {
 | |
|   baseId: string;
 | |
|   title: string;
 | |
|   categories: Array<Summary & { categoryId: string; name: string }>;
 | |
| };
 | |
| 
 | |
| type CategorySummary = Summary & {
 | |
|   categoryId: string;
 | |
|   name: string;
 | |
|   bases: Array<Summary & { baseId: string; title: string }>;
 | |
| };
 | |
| 
 | |
| type Summaries = {
 | |
|   overall: Summary;
 | |
|   categories: CategorySummary[];
 | |
|   bases: BaseSummary[];
 | |
| };
 | |
| 
 | |
| type ErrorState = {
 | |
|   login: string;
 | |
|   signup: string;
 | |
|   category: string;
 | |
|   base: string;
 | |
|   defense: string;
 | |
|   trophyReset: string;
 | |
| };
 | |
| 
 | |
| async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
 | |
|   const isForm = options?.isForm ?? false;
 | |
|   const fetchOptions: RequestInit = {
 | |
|     method,
 | |
|     credentials: 'include',
 | |
|   };
 | |
| 
 | |
|   if (isForm && body instanceof FormData) {
 | |
|     fetchOptions.body = body;
 | |
|   } else if (body !== undefined) {
 | |
|     fetchOptions.headers = { 'Content-Type': 'application/json' };
 | |
|     fetchOptions.body = JSON.stringify(body);
 | |
|   }
 | |
| 
 | |
|   const response = await fetch(url, fetchOptions);
 | |
|   const contentType = response.headers.get('content-type') ?? '';
 | |
|   const data = contentType.includes('application/json') ? await response.json() : {};
 | |
|   if (!response.ok) {
 | |
|     const message = (data && data.error) || 'Something went wrong';
 | |
|     throw new Error(message);
 | |
|   }
 | |
|   return data;
 | |
| }
 | |
| 
 | |
| const initialErrors: ErrorState = {
 | |
|   login: '',
 | |
|   signup: '',
 | |
|   category: '',
 | |
|   base: '',
 | |
|   defense: '',
 | |
|   trophyReset: '',
 | |
| };
 | |
| 
 | |
| export default function Page() {
 | |
|   const [authTab, setAuthTab] = useState<'login' | 'signup'>('login');
 | |
|   const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail' | 'profileBaseDetail'>(
 | |
|     'dashboard'
 | |
|   );
 | |
|   const [user, setUser] = useState<User | null>(null);
 | |
|   const [categories, setCategories] = useState<ArmyCategory[]>([]);
 | |
|   const [bases, setBases] = useState<BaseItem[]>([]);
 | |
|   const [defenses, setDefenses] = useState<DefenseItem[]>([]);
 | |
|   const [summaries, setSummaries] = useState<Summaries | null>(null);
 | |
|   const [selectedBaseId, setSelectedBaseId] = useState<string | null>(null);
 | |
|   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 [editingTrophyResetId, setEditingTrophyResetId] = 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 [profileSelectedBase, setProfileSelectedBase] = useState<ProfileBase | null>(null);
 | |
|   const [loading, setLoading] = useState(true);
 | |
|   const isAuthenticatedRef = useRef(false);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     (async () => {
 | |
|       try {
 | |
|         const data = await request('GET', API.me);
 | |
|         setUser(data.user);
 | |
|         isAuthenticatedRef.current = true;
 | |
|         await refreshData();
 | |
|         setView('dashboard');
 | |
|       } catch (_err) {
 | |
|         setUser(null);
 | |
|         isAuthenticatedRef.current = false;
 | |
|         setView('dashboard');
 | |
|       } finally {
 | |
|         setLoading(false);
 | |
|       }
 | |
|     })();
 | |
|   }, []);
 | |
| 
 | |
|   async function refreshData() {
 | |
|     if (!isAuthenticatedRef.current) {
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       const [basesRes, categoriesRes, defensesRes] = await Promise.all([
 | |
|         request('GET', API.bases),
 | |
|         request('GET', API.categories),
 | |
|         request('GET', API.defenses),
 | |
|       ]);
 | |
|       const normalizedBases = ((basesRes.bases || []) as BaseItem[]).map((base) => ({
 | |
|         ...base,
 | |
|         imageUrl: resolveImageUrl(base.imageUrl),
 | |
|       }));
 | |
|       setBases(normalizedBases);
 | |
|       setCategories(categoriesRes.categories || []);
 | |
|       setDefenses(defensesRes.defenses || []);
 | |
|       setSummaries({
 | |
|         overall: defensesRes.overall,
 | |
|         categories: defensesRes.categories,
 | |
|         bases: defensesRes.bases,
 | |
|       });
 | |
|     } catch (error) {
 | |
|       console.error(error);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const baseSummaryMap = useMemo(() => {
 | |
|     const map = new Map<string, BaseSummary>();
 | |
|     summaries?.bases.forEach((baseSummary) => map.set(baseSummary.baseId, baseSummary));
 | |
|     return map;
 | |
|   }, [summaries]);
 | |
| 
 | |
|   const categorySummaryMap = useMemo(() => {
 | |
|     const map = new Map<string, CategorySummary>();
 | |
|     summaries?.categories.forEach((categorySummary) => map.set(categorySummary.categoryId, categorySummary));
 | |
|     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]);
 | |
| 
 | |
|   const trophyResetBeingEdited = useMemo(() => {
 | |
|     if (!editingTrophyResetId) return null;
 | |
|     const allResets = bases.flatMap((base) => base.trophyResets);
 | |
|     return allResets.find((reset) => reset.id === editingTrophyResetId) ?? null;
 | |
|   }, [editingTrophyResetId, bases]);
 | |
| 
 | |
|   const profileSelectedBaseCategories = useMemo(() => {
 | |
|     if (!profileSelectedBase) {
 | |
|       return [] as ProfileCategorySummary[];
 | |
|     }
 | |
|     const buckets = new Map<string, { name: string; items: ProfileDefense[] }>();
 | |
|     profileSelectedBase.defenses.forEach((defense) => {
 | |
|       const key = defense.armyCategoryId || defense.armyCategoryName;
 | |
|       if (!buckets.has(key)) {
 | |
|         buckets.set(key, { name: defense.armyCategoryName || '(No category)', items: [] });
 | |
|       }
 | |
|       buckets.get(key)!.items.push(defense);
 | |
|     });
 | |
|     const summaries: ProfileCategorySummary[] = Array.from(buckets.entries()).map(([armyCategoryId, bucket]) => ({
 | |
|       armyCategoryId,
 | |
|       name: bucket.name,
 | |
|       ...summarizeProfileDefenses(bucket.items),
 | |
|     }));
 | |
|     summaries.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
 | |
|     return summaries;
 | |
|   }, [profileSelectedBase]);
 | |
| 
 | |
|   async function handleLogin(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     const formData = new FormData(event.currentTarget);
 | |
|     const payload = Object.fromEntries(formData.entries());
 | |
|     try {
 | |
|       setErrors((prev) => ({ ...prev, login: '' }));
 | |
|       const data = await request('POST', API.login, payload);
 | |
|       setUser(data.user);
 | |
|       isAuthenticatedRef.current = true;
 | |
|       await refreshData();
 | |
|       setView('dashboard');
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, login: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleSignup(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     const formData = new FormData(event.currentTarget);
 | |
|     const payload = Object.fromEntries(formData.entries());
 | |
|     try {
 | |
|       if ((payload.password as string)?.length < 6) {
 | |
|         throw new Error('Use at least 6 characters for your password.');
 | |
|       }
 | |
|       setErrors((prev) => ({ ...prev, signup: '' }));
 | |
|       const data = await request('POST', API.signup, payload);
 | |
|       setUser(data.user);
 | |
|       isAuthenticatedRef.current = true;
 | |
|       await refreshData();
 | |
|       setView('dashboard');
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, signup: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleCategorySubmit(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     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);
 | |
|       form.reset();
 | |
|       await refreshData();
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, category: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleBaseSubmit(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     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 });
 | |
|       form.reset();
 | |
|       setImageMode('upload');
 | |
|       await refreshData();
 | |
|       await refreshOwnProfileDetail();
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, base: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleDefenseSubmit(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     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);
 | |
|       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);
 | |
|   }
 | |
| 
 | |
|   function startEditingTrophyReset(resetId: string) {
 | |
|     setEditingTrophyResetId(resetId);
 | |
|     setErrors((prev) => ({ ...prev, trophyReset: '' }));
 | |
|   }
 | |
| 
 | |
|   function cancelEditingTrophyReset() {
 | |
|     setEditingTrophyResetId(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 handleTrophyResetEditSubmit(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     if (!editingTrophyResetId) {
 | |
|       return;
 | |
|     }
 | |
|     const form = event.currentTarget;
 | |
|     const formData = new FormData(form);
 | |
|     const payload = Object.fromEntries(formData.entries());
 | |
|     try {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: '' }));
 | |
|       await request('PUT', API.updateTrophyReset(editingTrophyResetId), payload);
 | |
|       setEditingTrophyResetId(null);
 | |
|       await refreshData();
 | |
|       await refreshOwnProfileDetail();
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleDeleteTrophyReset(resetId: string) {
 | |
|     const confirmDelete = window.confirm('Delete this reset?');
 | |
|     if (!confirmDelete) {
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       await request('DELETE', API.deleteTrophyReset(resetId));
 | |
|       if (editingTrophyResetId === resetId) {
 | |
|         setEditingTrophyResetId(null);
 | |
|       }
 | |
|       await refreshData();
 | |
|       await refreshOwnProfileDetail();
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleProfileSearch(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     setProfileError('');
 | |
|     const term = profileSearchTerm.trim();
 | |
|     if (!term) {
 | |
|       setProfileResults([]);
 | |
|       setProfileDetail(null);
 | |
|       setProfileSelectedBase(null);
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       setProfileLoading(true);
 | |
|       const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`);
 | |
|       setProfileResults(data.profiles || []);
 | |
|       setProfileDetail(null);
 | |
|       setProfileSelectedBase(null);
 | |
|     } catch (error: any) {
 | |
|       setProfileResults([]);
 | |
|       setProfileDetail(null);
 | |
|       setProfileSelectedBase(null);
 | |
|       setProfileError(error.message);
 | |
|     } finally {
 | |
|       setProfileLoading(false);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function loadProfile(username: string) {
 | |
|     try {
 | |
|       setProfileLoading(true);
 | |
|       setProfileError('');
 | |
|       setProfileSelectedBase(null);
 | |
|       const data: ProfileDetail = await request('GET', API.profileDetail(username));
 | |
|       const normalizedDetail: ProfileDetail = {
 | |
|         ...data,
 | |
|         bases: data.bases.map((base) => ({
 | |
|           ...base,
 | |
|           imageUrl: resolveImageUrl(base.imageUrl),
 | |
|         })),
 | |
|       };
 | |
|       setProfileDetail(normalizedDetail);
 | |
|       setView('dashboard');
 | |
|     } catch (error: any) {
 | |
|       setProfileDetail(null);
 | |
|       setProfileSelectedBase(null);
 | |
|       setProfileError(error.message);
 | |
|     } finally {
 | |
|       setProfileLoading(false);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   function clearProfileDetail() {
 | |
|     setProfileDetail(null);
 | |
|     setProfileSelectedBase(null);
 | |
|     setView('dashboard');
 | |
|   }
 | |
| 
 | |
|   function openProfileBaseDetail(base: ProfileBase) {
 | |
|     setProfileSelectedBase(base);
 | |
|     setView('profileBaseDetail');
 | |
|   }
 | |
| 
 | |
|   function closeProfileBaseDetail() {
 | |
|     setProfileSelectedBase(null);
 | |
|     setView('dashboard');
 | |
|   }
 | |
| 
 | |
|   function goToDashboard() {
 | |
|     setView('dashboard');
 | |
|     setProfileSelectedBase(null);
 | |
|   }
 | |
| 
 | |
|   async function refreshOwnProfileDetail() {
 | |
|     if (profileDetail && user && profileDetail.profile.username === user.username) {
 | |
|       await loadProfile(user.username);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleTrophyResetSubmit(event: FormEvent<HTMLFormElement>) {
 | |
|     event.preventDefault();
 | |
|     const form = event.currentTarget;
 | |
|     const formData = new FormData(form);
 | |
|     const payload = Object.fromEntries(formData.entries());
 | |
|     const baseId = payload.baseId as string;
 | |
|     if (!baseId) {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: 'Base is required' }));
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: '' }));
 | |
|       await request('POST', API.addTrophyReset(baseId), payload);
 | |
|       form.reset();
 | |
|       await refreshData();
 | |
|     } catch (error: any) {
 | |
|       setErrors((prev) => ({ ...prev, trophyReset: error.message }));
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async function handleLogout() {
 | |
|     try {
 | |
|       await request('POST', API.logout);
 | |
|     } catch (error) {
 | |
|       console.error(error);
 | |
|     } finally {
 | |
|       setUser(null);
 | |
|       setBases([]);
 | |
|       setCategories([]);
 | |
|       setDefenses([]);
 | |
|       setSummaries(null);
 | |
|       setSelectedBaseId(null);
 | |
|       setSelectedCategoryId(null);
 | |
|       setEditingBaseId(null);
 | |
|       setEditingDefenseId(null);
 | |
|       setEditImageMode('keep');
 | |
|       setProfileResults([]);
 | |
|       setProfileDetail(null);
 | |
|       setProfileSelectedBase(null);
 | |
|       setProfileSearchTerm('');
 | |
|       setProfileError('');
 | |
|       setProfileLoading(false);
 | |
|       isAuthenticatedRef.current = false;
 | |
|       setView('dashboard');
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   function openBaseDetail(baseId: string) {
 | |
|     setSelectedBaseId(baseId);
 | |
|     setView('baseDetail');
 | |
|   }
 | |
| 
 | |
|   function openCategoryDetail(categoryId: string) {
 | |
|     setSelectedCategoryId(categoryId);
 | |
|     setView('categoryDetail');
 | |
|   }
 | |
| 
 | |
|   const baseDetail = selectedBaseId ? baseSummaryMap.get(selectedBaseId) : null;
 | |
|   const baseDetailMeta = selectedBaseId ? bases.find((base) => base.id === selectedBaseId) : null;
 | |
|   const categoryDetail = selectedCategoryId ? categorySummaryMap.get(selectedCategoryId) : null;
 | |
| 
 | |
|   const categoryNameMap = useMemo(() => {
 | |
|     const map = new Map<string, string>();
 | |
|     categories.forEach((category) => map.set(category.id, category.name));
 | |
|     return map;
 | |
|   }, [categories]);
 | |
| 
 | |
| function formatTrophies(value: number) {
 | |
|   const sign = value > 0 ? '' : '+';
 | |
|   let gained = false;
 | |
|   if(sign === '+') gained = true;
 | |
|   let suffix = gained ? 'gained' : 'lost';
 | |
|   return `${sign}${value} trophies ${suffix}`; 
 | |
| }
 | |
| 
 | |
| function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
 | |
|   if (!defenses.length) {
 | |
|     return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 };
 | |
|   }
 | |
|   const totalStars = defenses.reduce((sum, defense) => sum + Number(defense.stars || 0), 0);
 | |
|   const totalPercent = defenses.reduce((sum, defense) => sum + Number(defense.percent || 0), 0);
 | |
|   const totalTrophies = defenses.reduce((sum, defense) => sum + Number(defense.trophies || 0), 0);
 | |
|   const count = defenses.length;
 | |
|   return {
 | |
|     count,
 | |
|     averageStars: Number((totalStars / count).toFixed(2)),
 | |
|     averagePercent: Number((totalPercent / count).toFixed(2)),
 | |
|     averageTrophies: Number((totalTrophies / count).toFixed(2)),
 | |
|   };
 | |
| }
 | |
| 
 | |
|   if (loading) {
 | |
|     return (
 | |
|       <main className="container">
 | |
|         <section className="card">
 | |
|           <h1>Loading Base Tracker...</h1>
 | |
|         </section>
 | |
|       </main>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   if (!user) {
 | |
|     return (
 | |
|       <main className="container">
 | |
|         <section id="auth-screen" className="card">
 | |
|           <h1>Base Tracker</h1>
 | |
|           <p className="subtitle">Track how each base defends against every army.</p>
 | |
|           <div className="tabs">
 | |
|             <button
 | |
|               className={`tab-button ${authTab === 'login' ? 'active' : ''}`}
 | |
|               onClick={() => setAuthTab('login')}
 | |
|             >
 | |
|               Log In
 | |
|             </button>
 | |
|             <button
 | |
|               className={`tab-button ${authTab === 'signup' ? 'active' : ''}`}
 | |
|               onClick={() => setAuthTab('signup')}
 | |
|             >
 | |
|               Sign Up
 | |
|             </button>
 | |
|           </div>
 | |
|           <div className="form-wrapper">
 | |
|             {authTab === 'login' ? (
 | |
|               <form className="form auth-form active" onSubmit={handleLogin}>
 | |
|                 <label>
 | |
|                   Username
 | |
|                   <input name="username" type="text" required autoComplete="username" />
 | |
|                 </label>
 | |
|                 <label>
 | |
|                   Password
 | |
|                   <input name="password" type="password" required />
 | |
|                 </label>
 | |
|                 <button type="submit" className="primary">
 | |
|                   Log In
 | |
|                 </button>
 | |
|                 <p className="form-error" data-for="login">
 | |
|                   {errors.login}
 | |
|                 </p>
 | |
|               </form>
 | |
|             ) : (
 | |
|               <form className="form auth-form active" onSubmit={handleSignup}>
 | |
|                 <label>
 | |
|                   Username
 | |
|                   <input name="username" type="text" minLength={3} required autoComplete="username" />
 | |
|                 </label>
 | |
|                 <label>
 | |
|                   Password
 | |
|                   <input name="password" type="password" minLength={6} required autoComplete="new-password" />
 | |
|                 </label>
 | |
|                 <button type="submit" className="primary">
 | |
|                   Create Account
 | |
|                 </button>
 | |
|                 <p className="form-error" data-for="signup">
 | |
|                   {errors.signup}
 | |
|                 </p>
 | |
|               </form>
 | |
|             )}
 | |
|           </div>
 | |
|         </section>
 | |
|       </main>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return (
 | |
|     <main className="container">
 | |
|       <section id="app-screen">
 | |
|         <header className="top-bar">
 | |
|           <div>
 | |
|             <h1>Base Tracker</h1>
 | |
|             <span id="user-username">{user.username}</span>
 | |
|           </div>
 | |
|           <div className="top-bar-actions">
 | |
|             <button
 | |
|               id="dashboard-button"
 | |
|               className={`ghost ${view === 'dashboard' ? 'hidden' : ''}`}
 | |
|               type="button"
 | |
|               onClick={goToDashboard}
 | |
|             >
 | |
|               Dashboard
 | |
|             </button>
 | |
|             <button
 | |
|               id="new-button"
 | |
|               className={`ghost accent small ${view === 'forms' ? 'active' : ''}`}
 | |
|               type="button"
 | |
|               onClick={() => setView('forms')}
 | |
|             >
 | |
|               New Entry
 | |
|             </button>
 | |
|             <button id="logout-button" className="ghost" type="button" onClick={handleLogout}>
 | |
|               Log Out
 | |
|             </button>
 | |
|           </div>
 | |
|         </header>
 | |
| 
 | |
|         <section id="dashboard-view" className={`view-section ${view !== 'dashboard' ? 'hidden' : ''}`}>
 | |
|           <section className="summary-grid">
 | |
|             <div className="card">
 | |
|               <h2>Overall Average</h2>
 | |
|               <div className="stat" id="overall-summary">
 | |
|                 {summaries && summaries.overall.count ? (
 | |
|                   <>
 | |
|                     <strong>{summaries.overall.averageStars}★</strong> average •{' '}
 | |
|                     <strong>{summaries.overall.averagePercent}%</strong> destruction
 | |
|                     <br />
 | |
|                     <span className="badge">{formatTrophies(summaries.overall.averageTrophies)}</span>{' '}
 | |
|                     <span className="badge">{summaries.overall.count} attacks</span>
 | |
|                   </>
 | |
|                 ) : (
 | |
|                   'No defenses logged yet.'
 | |
|                 )}
 | |
|               </div>
 | |
|               <div className="subsection">
 | |
|                 <h3>Base Averages</h3>
 | |
|                 <ul id="base-summary" className="list compact">
 | |
|                   {summaries && summaries.bases.length ? (
 | |
|                     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
 | |
|                         ? 'Bases have defenses pending tracking.'
 | |
|                         : 'Add a base to start collecting its averages.'}
 | |
|                     </li>
 | |
|                   )}
 | |
|                 </ul>
 | |
|               </div>
 | |
|             </div>
 | |
|             <div className="card">
 | |
|               <h2>Category Averages</h2>
 | |
|               <ul id="category-summary" className="list">
 | |
|                 {summaries && summaries.categories.length ? (
 | |
|                   summaries.categories.map((category) => (
 | |
|                     <li
 | |
|                       key={category.categoryId}
 | |
|                       className="list-item clickable"
 | |
|                       onClick={() => openCategoryDetail(category.categoryId)}
 | |
|                     >
 | |
|                       <div className="defense-header">
 | |
|                         <strong>{category.name}</strong>
 | |
|                         <span className="badge">{category.count} attacks</span>
 | |
|                       </div>
 | |
|                       <div className="defense-meta">
 | |
|                         <span>{category.averageStars}★ avg</span>
 | |
|                         <span>{category.averagePercent}% avg</span>
 | |
|                         <span>{formatTrophies(category.averageTrophies)} avg</span>
 | |
|                       </div>
 | |
|                     </li>
 | |
|                   ))
 | |
|                 ) : (
 | |
|                   <li>Create an army category to start tracking.</li>
 | |
|                 )}
 | |
|               </ul>
 | |
|             </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 clickable" onClick={() => openProfileBaseDetail(base)}>
 | |
|                         <div className="defense-header">
 | |
|                           <div>
 | |
|                             <strong>{base.title}</strong>{' '}
 | |
|                             {base.isPrivate ? <span className="badge muted">Private</span> : null}
 | |
|                           </div>
 | |
|                           <span className="badge">{base.summary.count} attacks</span>
 | |
|                         </div>
 | |
|                         <div className="defense-meta">
 | |
|                           <span>{base.summary.averageStars}★ avg</span>
 | |
|                           <span>{base.summary.averagePercent}% avg</span>
 | |
|                           <span>{formatTrophies(base.summary.averageTrophies)}</span>
 | |
|                         </div>
 | |
|                         <p className="muted">Click to open full details.</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>
 | |
|             <ul id="defense-list" className="list">
 | |
|               {defenses.length ? (
 | |
|                 defenses.map((defense) => {
 | |
|                   const date = new Date(defense.createdAt);
 | |
|                   const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
 | |
|                   return (
 | |
|                     <li key={defense.id} className="list-item">
 | |
|                       <div className="defense-header">
 | |
|                         <div>
 | |
|                           <strong>{defense.baseTitle}</strong>
 | |
|                           <span className="badge">{categoryName}</span>
 | |
|                         </div>
 | |
|                         <div>
 | |
|                           <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
 | |
|                         </div>
 | |
|                       </div>
 | |
|                       <div className="defense-meta">
 | |
|                         <span>{date.toLocaleString()}</span>
 | |
|                       </div>
 | |
|                     </li>
 | |
|                   );
 | |
|                 })
 | |
|               ) : (
 | |
|                 <li>No defenses recorded yet.</li>
 | |
|               )}
 | |
|             </ul>
 | |
|           </section>
 | |
|         </section>
 | |
| 
 | |
|         <section id="forms-view" className={`forms-grid view-section ${view !== 'forms' ? 'hidden' : ''}`}>
 | |
|           <div className="card">
 | |
|             <h2>New Army Category</h2>
 | |
|             <form id="category-form" className="form compact" onSubmit={handleCategorySubmit}>
 | |
|               <label>
 | |
|                 Name
 | |
|                 <input type="text" name="name" required />
 | |
|               </label>
 | |
|               <button type="submit" className="primary">
 | |
|                 Add Category
 | |
|               </button>
 | |
|               <p className="form-error" data-for="category">
 | |
|                 {errors.category}
 | |
|               </p>
 | |
|             </form>
 | |
|             <div className="subsection">
 | |
|               <details>
 | |
|                 <summary><h3>Existing Categories</h3></summary>
 | |
|                 <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>
 | |
|               </details>
 | |
|             </div>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <h2>New Base</h2>
 | |
|             <form id="base-form" className="form compact" onSubmit={handleBaseSubmit}>
 | |
|               <label>
 | |
|                 Title
 | |
|                 <input type="text" name="title" required />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Description
 | |
|                 <textarea name="description" rows={2}></textarea>
 | |
|               </label>
 | |
|               <label>
 | |
|                 Planning Link
 | |
|                 <input type="url" name="url" placeholder="https://" />
 | |
|               </label>
 | |
|               <div className="input-group">
 | |
|                 <span className="input-label">Image Source</span>
 | |
|                 <div className="image-mode">
 | |
|                   <label>
 | |
|                     <input
 | |
|                       type="radio"
 | |
|                       name="imageMode"
 | |
|                       value="upload"
 | |
|                       checked={imageMode === 'upload'}
 | |
|                       onChange={() => setImageMode('upload')}
 | |
|                     />
 | |
|                     Upload
 | |
|                   </label>
 | |
|                   <label>
 | |
|                     <input
 | |
|                       type="radio"
 | |
|                       name="imageMode"
 | |
|                       value="url"
 | |
|                       checked={imageMode === 'url'}
 | |
|                       onChange={() => setImageMode('url')}
 | |
|                     />
 | |
|                     URL
 | |
|                   </label>
 | |
|                 </div>
 | |
|                 {imageMode === 'upload' ? (
 | |
|                   <input type="file" name="imageFile" accept="image/*" />
 | |
|                 ) : (
 | |
|                   <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>
 | |
|               <p className="form-error" data-for="base">
 | |
|                 {errors.base}
 | |
|               </p>
 | |
|             </form>
 | |
|             <div className="subsection">
 | |
|               <details>
 | |
|                 <summary><h3>Manage Bases</h3></summary>
 | |
|                 <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>
 | |
|               </details>
 | |
|             </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>
 | |
|             <form id="defense-form" className="form compact" onSubmit={handleDefenseSubmit}>
 | |
|               <label>
 | |
|                 Base
 | |
|                 <select name="baseId" required defaultValue="">
 | |
|                   <option value="" disabled>
 | |
|                     {bases.length ? 'Select a base' : 'Add a base first'}
 | |
|                   </option>
 | |
|                   {bases.map((base) => (
 | |
|                     <option key={base.id} value={base.id}>
 | |
|                       {base.title}
 | |
|                     </option>
 | |
|                   ))}
 | |
|                 </select>
 | |
|               </label>
 | |
|               <label>
 | |
|                 Army Category
 | |
|                 <select name="armyCategoryId" defaultValue="">
 | |
|                   <option value="">
 | |
|                     {categories.length ? '(No category)' : 'Add an army category first'}
 | |
|                   </option>
 | |
|                   {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 className="styled-number" />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Destruction %
 | |
|                 <input type="number" name="percent" min={0} max={100} step={1} required className="styled-number" />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Trophies ±
 | |
|                 <input
 | |
|                   type="number"
 | |
|                   name="trophies"
 | |
|                   min={-200}
 | |
|                   max={200}
 | |
|                   step={1}
 | |
|                   defaultValue={0}
 | |
|                   required
 | |
|                   className="styled-number"
 | |
|                 />
 | |
|               </label>
 | |
|               <button type="submit" className="primary">
 | |
|                 Record Defense
 | |
|               </button>
 | |
|               <p className="form-error" data-for="defense">
 | |
|                 {errors.defense}
 | |
|               </p>
 | |
|             </form>
 | |
|             <div className="subsection">
 | |
|               <details>
 | |
|                 <summary><h3>Manage Attacks</h3></summary>
 | |
|                 <ul className="list compact">
 | |
|                 {defenses.length ? (
 | |
|                   defenses.slice(0, 10).map((defense) => {
 | |
|                     const categoryName =
 | |
|                       categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || '(No category)';
 | |
|                     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}
 | |
|               </details>
 | |
|             </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" defaultValue={defenseBeingEdited.armyCategoryId || ''}>
 | |
|                       <option value="">(No category)</option>
 | |
|                       {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 className="card">
 | |
|             <h2>Log Legend League Day Reset</h2>
 | |
|             <form id="trophy-reset-form" className="form compact" onSubmit={handleTrophyResetSubmit}>
 | |
|               <label>
 | |
|                 Base
 | |
|                 <select name="baseId" required defaultValue="">
 | |
|                   <option value="" disabled>
 | |
|                     {bases.length ? 'Select a base' : 'Add a base first'}
 | |
|                   </option>
 | |
|                   {bases.map((base) => (
 | |
|                     <option key={base.id} value={base.id}>
 | |
|                       {base.title}
 | |
|                     </option>
 | |
|                   ))}
 | |
|                 </select>
 | |
|               </label>
 | |
|               <label>
 | |
|                 Date
 | |
|                 <input type="date" name="date" required />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Trophies
 | |
|                 <input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Trophies Gained/Lost
 | |
|                 <input type="number" name="trophiesLost" step={1} required className="styled-number" />
 | |
|               </label>
 | |
|               <label>
 | |
|                 Number of Defenses
 | |
|                 <input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" />
 | |
|               </label>
 | |
|               <button type="submit" className="primary">
 | |
|                 Record Reset
 | |
|               </button>
 | |
|               <p className="form-error" data-for="trophyReset">
 | |
|                 {errors.trophyReset}
 | |
|               </p>
 | |
|             </form>
 | |
|             <div className="subsection">
 | |
|               <details>
 | |
|                 <summary><h3>Manage Resets</h3></summary>
 | |
|                 <ul className="list compact">
 | |
|                   {bases.flatMap((base) => base.trophyResets).length ? (
 | |
|                     bases.flatMap((base) => base.trophyResets).slice(0, 10).map((reset) => (
 | |
|                       <li key={reset.id} className="list-item">
 | |
|                         <div className="defense-header">
 | |
|                           <div>
 | |
|                             <strong>{bases.find((b) => b.id === bases.find((b) => b.trophyResets.some((r) => r.id === reset.id))?.id)?.title}</strong>{' '}
 | |
|                             <span className="badge">{new Date(reset.date).toLocaleDateString()}</span>
 | |
|                           </div>
 | |
|                           <div className="defense-meta">
 | |
|                             <button
 | |
|                               type="button"
 | |
|                               className="ghost small"
 | |
|                               onClick={() => startEditingTrophyReset(reset.id)}
 | |
|                             >
 | |
|                               Edit
 | |
|                             </button>
 | |
|                             <button
 | |
|                               type="button"
 | |
|                               className="ghost small"
 | |
|                               onClick={() => handleDeleteTrophyReset(reset.id)}
 | |
|                             >
 | |
|                               Delete
 | |
|                             </button>
 | |
|                           </div>
 | |
|                         </div>
 | |
|                         <div className="defense-meta">
 | |
|                           <span>{reset.trophiesAtStart} trophies</span>
 | |
|                           <span>{formatTrophies(reset.trophiesLost)}</span>
 | |
|                           <span>{reset.numberOfDefenses} defenses</span>
 | |
|                         </div>
 | |
|                       </li>
 | |
|                     ))
 | |
|                   ) : (
 | |
|                     <li>No resets logged yet.</li>
 | |
|                   )}
 | |
|                 </ul>
 | |
|               </details>
 | |
|             </div>
 | |
|             {editingTrophyResetId && trophyResetBeingEdited && (
 | |
|               <div className="subsection">
 | |
|                 <h3>Edit Reset</h3>
 | |
|                 <form
 | |
|                   key={editingTrophyResetId}
 | |
|                   className="form compact"
 | |
|                   onSubmit={handleTrophyResetEditSubmit}
 | |
|                 >
 | |
|                   <label>
 | |
|                     Base
 | |
|                     <select name="baseId" required defaultValue={bases.find(b => b.trophyResets.some(r => r.id === editingTrophyResetId))?.id}>
 | |
|                       {bases.map((base) => (
 | |
|                         <option key={base.id} value={base.id}>
 | |
|                           {base.title}
 | |
|                         </option>
 | |
|                       ))}
 | |
|                     </select>
 | |
|                   </label>
 | |
|                   <label>
 | |
|                     Date
 | |
|                     <input type="date" name="date" required defaultValue={new Date(trophyResetBeingEdited.date).toISOString().split('T')[0]} />
 | |
|                   </label>
 | |
|                   <label>
 | |
|                     Trophies
 | |
|                     <input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesAtStart} />
 | |
|                   </label>
 | |
|                   <label>
 | |
|                     Trophies Gained/Lost
 | |
|                     <input type="number" name="trophiesLost" step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.trophiesLost} />
 | |
|                   </label>
 | |
|                   <label>
 | |
|                     Number of Defenses
 | |
|                     <input type="number" name="numberOfDefenses" min={0} step={1} required className="styled-number" defaultValue={trophyResetBeingEdited.numberOfDefenses} />
 | |
|                   </label>
 | |
|                   <div className="defense-meta">
 | |
|                     <button type="submit" className="primary">
 | |
|                       Save Reset
 | |
|                     </button>
 | |
|                     <button type="button" className="ghost" onClick={cancelEditingTrophyReset}>
 | |
|                       Cancel
 | |
|                     </button>
 | |
|                   </div>
 | |
|                   <p className="form-error">{errors.trophyReset}</p>
 | |
|                 </form>
 | |
|               </div>
 | |
|             )}
 | |
|           </div>
 | |
|         </section>
 | |
| 
 | |
|         <section id="base-detail-view" className={`view-section ${view !== 'baseDetail' ? 'hidden' : ''}`}>
 | |
|           <div className="card">
 | |
|             <div className="detail-header">
 | |
|               <button className="ghost" type="button" onClick={() => setView('dashboard')}>
 | |
|                 Back
 | |
|               </button>
 | |
|               <span id="base-detail-created" className="detail-created">
 | |
|                 {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''}
 | |
|               </span>
 | |
|             </div>
 | |
|             <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>
 | |
|             <div id="base-detail-links" className={`detail-links ${baseDetailMeta?.url ? '' : 'hidden'}`}>
 | |
|               {baseDetailMeta?.url && (
 | |
|                 <a href={baseDetailMeta.url} target="_blank" rel="noopener noreferrer">
 | |
|                   Open planning link
 | |
|                 </a>
 | |
|               )}
 | |
|             </div>
 | |
|             <div
 | |
|               id="base-detail-image-wrapper"
 | |
|               className={`detail-image ${baseDetailMeta?.imageUrl ? '' : 'hidden'}`}
 | |
|             >
 | |
|               {baseDetailMeta?.imageUrl && (
 | |
|                 // eslint-disable-next-line @next/next/no-img-element
 | |
|                 <img src={baseDetailMeta.imageUrl} alt={`Preview of ${baseDetailMeta.title}`} />
 | |
|               )}
 | |
|             </div>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <h3>Base Averages</h3>
 | |
|             <div id="base-detail-stats" className="stat">
 | |
|               {baseDetail && baseDetail.count ? (
 | |
|                 <>
 | |
|                   <strong>{baseDetail.averageStars}★</strong> average •{' '}
 | |
|                   <strong>{baseDetail.averagePercent}%</strong> destruction
 | |
|                   <br />
 | |
|                   <span className="badge">{formatTrophies(baseDetail.averageTrophies)} avg</span>{' '}
 | |
|                   <span className="badge">{baseDetail.count} defenses</span>
 | |
|                 </>
 | |
|               ) : (
 | |
|                 'No defenses logged yet.'
 | |
|               )}
 | |
|             </div>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <details open>
 | |
|               <summary><h3>Army Categories vs This Base</h3></summary>
 | |
|               <ul id="base-detail-categories" className="list">
 | |
|               {baseDetail && baseDetail.categories.length ? (
 | |
|                 baseDetail.categories.map((category) => (
 | |
|                   <li
 | |
|                     key={category.categoryId}
 | |
|                     className="list-item clickable"
 | |
|                     onClick={() => openCategoryDetail(category.categoryId)}
 | |
|                   >
 | |
|                     <div className="defense-header">
 | |
|                       <strong>{category.name}</strong>
 | |
|                       <span className="badge">{category.count} attacks</span>
 | |
|                     </div>
 | |
|                     <div className="defense-meta">
 | |
|                       <span>{category.averageStars}★ avg</span>
 | |
|                       <span>{category.averagePercent}% avg</span>
 | |
|                       <span>{formatTrophies(category.averageTrophies)} avg</span>
 | |
|                     </div>
 | |
|                   </li>
 | |
|                 ))
 | |
|               ) : (
 | |
|                 <li>No army categories have attacked this base yet.</li>
 | |
|               )}
 | |
|             </ul>
 | |
|             </details>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <details open>
 | |
|               <summary><h3>Recent Resets</h3></summary>
 | |
|               <ul className="list compact">
 | |
|                 {baseDetailMeta?.trophyResets?.length ? (
 | |
|                   baseDetailMeta.trophyResets.map((reset) => (
 | |
|                     <li key={reset.id} className="list-item">
 | |
|                       <div className="defense-header">
 | |
|                         <span>{new Date(reset.date).toLocaleDateString()}</span>
 | |
|                         <div className="defense-meta">
 | |
|                           <button
 | |
|                             type="button"
 | |
|                             className="ghost small"
 | |
|                             onClick={() => startEditingTrophyReset(reset.id)}
 | |
|                           >
 | |
|                             Edit
 | |
|                           </button>
 | |
|                           <button
 | |
|                             type="button"
 | |
|                             className="ghost small"
 | |
|                             onClick={() => handleDeleteTrophyReset(reset.id)}
 | |
|                           >
 | |
|                             Delete
 | |
|                           </button>
 | |
|                         </div>
 | |
|                       </div>
 | |
|                       <div className="defense-meta">
 | |
|                         <span>{reset.trophiesAtStart} trophies</span>
 | |
|                         <span>{formatTrophies(reset.trophiesLost)}</span>
 | |
|                         <span>{reset.numberOfDefenses} defenses</span>
 | |
|                       </div>
 | |
|                     </li>
 | |
|                   ))
 | |
|                 ) : (
 | |
|                   <li>No resets logged yet.</li>
 | |
|                 )}
 | |
|               </ul>
 | |
|             </details>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <details open>
 | |
|               <summary><h3>Defenses</h3></summary>
 | |
|               <ul id="base-detail-defenses" className="list">
 | |
|               {defenses.filter((defense) => defense.baseId === selectedBaseId).length ? (
 | |
|                 defenses
 | |
|                   .filter((defense) => defense.baseId === selectedBaseId)
 | |
|                   .map((defense) => {
 | |
|                     const date = new Date(defense.createdAt);
 | |
|                     const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
 | |
|                     return (
 | |
|                       <li key={defense.id} className="list-item">
 | |
|                         <div className="defense-header">
 | |
|                           <strong>{categoryName}</strong>
 | |
|                           <div>
 | |
|                             <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
 | |
|                           </div>
 | |
|                         </div>
 | |
|                         <div className="defense-meta">
 | |
|                           <span>{date.toLocaleString()}</span>
 | |
|                         </div>
 | |
|                       </li>
 | |
|                     );
 | |
|                   })
 | |
|               ) : (
 | |
|                 <li>No defenses recorded for this base yet.</li>
 | |
|               )}
 | |
|             </ul>
 | |
|             </details>
 | |
|           </div>
 | |
|         </section>
 | |
| 
 | |
|         <section
 | |
|           id="profile-base-detail-view"
 | |
|           className={`view-section ${view !== 'profileBaseDetail' ? 'hidden' : ''}`}
 | |
|         >
 | |
|           {profileSelectedBase ? (
 | |
|             <>
 | |
|               <div className="card">
 | |
|                 <div className="detail-header">
 | |
|                   <button className="ghost" type="button" onClick={closeProfileBaseDetail}>
 | |
|                     Back
 | |
|                   </button>
 | |
|                   <span className="detail-created">
 | |
|                     {`Created ${new Date(profileSelectedBase.createdAt).toLocaleString()}`}
 | |
|                   </span>
 | |
|                 </div>
 | |
|                 <h2>
 | |
|                   {profileSelectedBase.title}{' '}
 | |
|                   {profileSelectedBase.isPrivate ? <span className="badge muted">Private</span> : null}
 | |
|                 </h2>
 | |
|                 <p className={profileSelectedBase.description ? '' : 'muted'}>
 | |
|                   {profileSelectedBase.description || 'No description yet.'}
 | |
|                 </p>
 | |
|                 <div className="defense-meta">
 | |
|                   <span>
 | |
|                     Shared by <strong>{profileDetail?.profile.username || 'Unknown player'}</strong>
 | |
|                   </span>
 | |
|                 </div>
 | |
|                 <div className={`detail-links ${profileSelectedBase.url ? '' : 'hidden'}`}>
 | |
|                   {profileSelectedBase.url && (
 | |
|                     <a href={profileSelectedBase.url} target="_blank" rel="noopener noreferrer">
 | |
|                       Planning Link
 | |
|                     </a>
 | |
|                   )}
 | |
|                 </div>
 | |
|                 <div className={`detail-image ${profileSelectedBase.imageUrl ? '' : 'hidden'}`}>
 | |
|                   {profileSelectedBase.imageUrl && (
 | |
|                     // eslint-disable-next-line @next/next/no-img-element
 | |
|                     <img
 | |
|                       src={profileSelectedBase.imageUrl}
 | |
|                       alt={`Preview of ${profileSelectedBase.title}`}
 | |
|                     />
 | |
|                   )}
 | |
|                 </div>
 | |
|               </div>
 | |
|               <div className="card">
 | |
|                 <h3>Base Averages</h3>
 | |
|                 <div className="stat">
 | |
|                   {profileSelectedBase.summary.count ? (
 | |
|                     <>
 | |
|                       <strong>{profileSelectedBase.summary.averageStars}★</strong> average •{' '}
 | |
|                       <strong>{profileSelectedBase.summary.averagePercent}%</strong> destruction
 | |
|                       <br />
 | |
|                       <span className="badge">
 | |
|                         {formatTrophies(profileSelectedBase.summary.averageTrophies)}
 | |
|                       </span>{' '}
 | |
|                       <span className="badge">{profileSelectedBase.summary.count} defenses</span>
 | |
|                     </>
 | |
|                   ) : (
 | |
|                     'No defenses logged yet.'
 | |
|                   )}
 | |
|                 </div>
 | |
|               </div>
 | |
|               <div className="card">
 | |
|                 <h3>Army Categories vs This Base</h3>
 | |
|                 <ul className="list">
 | |
|                   {profileSelectedBaseCategories.length ? (
 | |
|                     profileSelectedBaseCategories.map((category) => (
 | |
|                       <li key={category.armyCategoryId} className="list-item">
 | |
|                         <div className="defense-header">
 | |
|                           <strong>{category.name}</strong>
 | |
|                           <span className="badge">{category.count} attacks</span>
 | |
|                         </div>
 | |
|                         <div className="defense-meta">
 | |
|                           <span>{category.averageStars}★ avg</span>
 | |
|                           <span>{category.averagePercent}% avg</span>
 | |
|                           <span>{formatTrophies(category.averageTrophies)} avg</span>
 | |
|                         </div>
 | |
|                       </li>
 | |
|                     ))
 | |
|                   ) : (
 | |
|                     <li>No attacks recorded for this base yet.</li>
 | |
|                   )}
 | |
|                 </ul>
 | |
|               </div>
 | |
|               <div className="card">
 | |
|                 <details open>
 | |
|                   <summary><h3>Recent Resets</h3></summary>
 | |
|                   <ul className="list compact">
 | |
|                     {profileSelectedBase.trophyResets.length ? (
 | |
|                       profileSelectedBase.trophyResets.map((reset) => (
 | |
|                         <li key={reset.id} className="list-item">
 | |
|                           <div className="defense-header">
 | |
|                             <span>{new Date(reset.date).toLocaleDateString()}</span>
 | |
|                             <div className="defense-meta">
 | |
|                               <span>{reset.trophiesAtStart} trophies at start</span>
 | |
|                               <span>{formatTrophies(reset.trophiesLost)} lost</span>
 | |
|                               <span>{reset.numberOfDefenses} defenses</span>
 | |
|                             </div>
 | |
|                           </div>
 | |
|                         </li>
 | |
|                       ))
 | |
|                     ) : (
 | |
|                       <li>No resets logged yet.</li>
 | |
|                     )}
 | |
|                   </ul>
 | |
|                 </details>
 | |
|               </div>
 | |
|               <div className="card">
 | |
|                 <details>
 | |
|                   <summary><h3>Defenses</h3></summary>
 | |
|                   <ul className="list">
 | |
|                     {profileSelectedBase.defenses.length ? (
 | |
|                       profileSelectedBase.defenses.map((defense) => (
 | |
|                         <li key={defense.id} className="list-item">
 | |
|                           <div className="defense-header">
 | |
|                             <strong>{defense.armyCategoryName || '(No category)'}</strong>
 | |
|                             <div>
 | |
|                               <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
 | |
|                             </div>
 | |
|                           </div>
 | |
|                           <div className="defense-meta">
 | |
|                             <span>{new Date(defense.createdAt).toLocaleString()}</span>
 | |
|                           </div>
 | |
|                         </li>
 | |
|                       ))
 | |
|                     ) : (
 | |
|                       <li>No defenses recorded yet.</li>
 | |
|                     )}
 | |
|                   </ul>
 | |
|                 </details>
 | |
|               </div>
 | |
|             </>
 | |
|           ) : (
 | |
|             <div className="card">
 | |
|               <div className="detail-header">
 | |
|                 <button className="ghost" type="button" onClick={closeProfileBaseDetail}>
 | |
|                   Back
 | |
|                 </button>
 | |
|               </div>
 | |
|               <p className="muted">Select a base from a profile search to view its details.</p>
 | |
|             </div>
 | |
|           )}
 | |
|         </section>
 | |
| 
 | |
|         <section id="category-detail-view" className={`view-section ${view !== 'categoryDetail' ? 'hidden' : ''}`}>
 | |
|           <div className="card">
 | |
|             <div className="detail-header">
 | |
|               <button className="ghost" type="button" onClick={() => setView('dashboard')}>
 | |
|                 Back
 | |
|               </button>
 | |
|             </div>
 | |
|             <h2 id="category-detail-title">{categoryDetail?.name}</h2>
 | |
|             <p id="category-detail-description" className="muted">
 | |
|               Average performance of this army across your bases.
 | |
|             </p>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <h3>Category Averages</h3>
 | |
|             <div id="category-detail-stats" className="stat">
 | |
|               {categoryDetail && categoryDetail.count ? (
 | |
|                 <>
 | |
|                   <strong>{categoryDetail.averageStars}★</strong> average •{' '}
 | |
|                   <strong>{categoryDetail.averagePercent}%</strong> destruction
 | |
|                   <br />
 | |
|                   <span className="badge">{formatTrophies(categoryDetail.averageTrophies)} avg</span>{' '}
 | |
|                   <span className="badge">{categoryDetail.count} attacks</span>
 | |
|                 </>
 | |
|               ) : (
 | |
|                 'No defenses logged yet.'
 | |
|               )}
 | |
|             </div>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <h3>Performance by Base</h3>
 | |
|             <ul id="category-detail-bases" className="list">
 | |
|               {categoryDetail && categoryDetail.bases.length ? (
 | |
|                 categoryDetail.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>
 | |
|                 ))
 | |
|               ) : (
 | |
|                 <li>This army has not attacked any bases yet.</li>
 | |
|               )}
 | |
|             </ul>
 | |
|           </div>
 | |
|           <div className="card">
 | |
|             <h3>Defenses</h3>
 | |
|             <ul id="category-detail-defenses" className="list">
 | |
|               {defenses.filter((defense) => defense.armyCategoryId === selectedCategoryId).length ? (
 | |
|                 defenses
 | |
|                   .filter((defense) => defense.armyCategoryId === selectedCategoryId)
 | |
|                   .map((defense) => {
 | |
|                     const date = new Date(defense.createdAt);
 | |
|                     return (
 | |
|                       <li key={defense.id} className="list-item">
 | |
|                         <div className="defense-header">
 | |
|                           <strong>{defense.baseTitle}</strong>
 | |
|                           <div>
 | |
|                             <strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
 | |
|                           </div>
 | |
|                         </div>
 | |
|                         <div className="defense-meta">
 | |
|                           <span>{date.toLocaleString()}</span>
 | |
|                         </div>
 | |
|                       </li>
 | |
|                     );
 | |
|                   })
 | |
|               ) : (
 | |
|                 <li>No logged defenses for this army yet.</li>
 | |
|               )}
 | |
|             </ul>
 | |
|           </div>
 | |
|         </section>
 | |
|       </section>
 | |
|     </main>
 | |
|   );
 | |
| }
 |