'use client'; import { FormEvent, useEffect, useMemo, useState } from 'react'; const defaultApiBase = process.env.NODE_ENV === 'production' ? 'https://backend.basetracker.lona-development.org' : 'http://localhost:4100'; const API_BASE = 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`, }; type User = { id: string; username: string; createdAt: string; }; type ArmyCategory = { id: string; name: string; createdAt: string; }; type BaseItem = { id: string; title: string; description: string; url: string; imageUrl: string; createdAt: string; }; type DefenseItem = { id: string; stars: number; percent: number; trophies: number; armyCategoryId: string; createdAt: string; baseId: string; baseTitle: string; categoryName?: string; }; type Summary = { count: number; averageStars: number; averagePercent: number; averageTrophies: number; }; type BaseSummary = Summary & { baseId: string; title: string; categories: Array; }; type CategorySummary = Summary & { categoryId: string; name: string; bases: Array; }; type Summaries = { overall: Summary; categories: CategorySummary[]; bases: BaseSummary[]; }; type ErrorState = { login: string; signup: string; category: string; base: string; defense: 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: '', }; export default function Page() { const [authTab, setAuthTab] = useState<'login' | 'signup'>('login'); const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail'>('dashboard'); const [user, setUser] = useState(null); const [categories, setCategories] = useState([]); const [bases, setBases] = useState([]); const [defenses, setDefenses] = useState([]); const [summaries, setSummaries] = useState(null); const [selectedBaseId, setSelectedBaseId] = useState(null); const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [errors, setErrors] = useState(initialErrors); const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload'); const [loading, setLoading] = useState(true); useEffect(() => { (async () => { try { const data = await request('GET', API.me); setUser(data.user); await refreshData(); setView('dashboard'); } catch (_err) { setUser(null); setView('dashboard'); } finally { setLoading(false); } })(); }, []); async function refreshData() { if (!user && !document.cookie.includes('token')) { return; } try { const [basesRes, categoriesRes, defensesRes] = await Promise.all([ request('GET', API.bases), request('GET', API.categories), request('GET', API.defenses), ]); setBases(basesRes.bases || []); 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(); summaries?.bases.forEach((baseSummary) => map.set(baseSummary.baseId, baseSummary)); return map; }, [summaries]); const categorySummaryMap = useMemo(() => { const map = new Map(); summaries?.categories.forEach((categorySummary) => map.set(categorySummary.categoryId, categorySummary)); return map; }, [summaries]); async function handleLogin(event: FormEvent) { 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); await refreshData(); setView('dashboard'); } catch (error: any) { setErrors((prev) => ({ ...prev, login: error.message })); } } async function handleSignup(event: FormEvent) { 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); await refreshData(); setView('dashboard'); } catch (error: any) { setErrors((prev) => ({ ...prev, signup: error.message })); } } async function handleCategorySubmit(event: FormEvent) { event.preventDefault(); const formData = new FormData(event.currentTarget); const payload = Object.fromEntries(formData.entries()); try { setErrors((prev) => ({ ...prev, category: '' })); await request('POST', API.categories, payload); event.currentTarget.reset(); await refreshData(); } catch (error: any) { setErrors((prev) => ({ ...prev, category: error.message })); } } async function handleBaseSubmit(event: FormEvent) { event.preventDefault(); const formData = new FormData(event.currentTarget); formData.set('imageMode', imageMode); if (imageMode === 'upload') { formData.delete('imageUrl'); } else { formData.delete('imageFile'); } try { setErrors((prev) => ({ ...prev, base: '' })); await request('POST', API.bases, formData, { isForm: true }); event.currentTarget.reset(); setImageMode('upload'); await refreshData(); } catch (error: any) { setErrors((prev) => ({ ...prev, base: error.message })); } } async function handleDefenseSubmit(event: FormEvent) { event.preventDefault(); const formData = new FormData(event.currentTarget); const payload = Object.fromEntries(formData.entries()); try { const baseId = payload.baseId as string; setErrors((prev) => ({ ...prev, defense: '' })); await request('POST', API.addDefense(baseId), payload); event.currentTarget.reset(); await refreshData(); } catch (error: any) { setErrors((prev) => ({ ...prev, defense: 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); 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(); categories.forEach((category) => map.set(category.id, category.name)); return map; }, [categories]); function formatTrophies(value: number) { const sign = value > 0 ? '+' : ''; return `${sign}${value} trophies`; } if (loading) { return (

Loading Base Noter...

); } if (!user) { return (

Base Noter

Track how each base defends against every army.

{authTab === 'login' ? (

{errors.login}

) : (

{errors.signup}

)}
); } return (

Base Noter

{user.username}

Overall Average

{summaries && summaries.overall.count ? ( <> {summaries.overall.averageStars}★ average •{' '} {summaries.overall.averagePercent}% destruction
{formatTrophies(summaries.overall.averageTrophies)}{' '} {summaries.overall.count} attacks ) : ( 'No defenses logged yet.' )}

Base Averages

    {summaries && summaries.bases.length ? ( summaries.bases.map((base) => (
  • openBaseDetail(base.baseId)} >
    {base.title} {base.count} defenses
    {base.averageStars}★ avg {base.averagePercent}% avg {formatTrophies(base.averageTrophies)} avg
  • )) ) : (
  • {bases.length ? 'Bases have defenses pending tracking.' : 'Add a base to start collecting its averages.'}
  • )}

Category Averages

    {summaries && summaries.categories.length ? ( summaries.categories.map((category) => (
  • openCategoryDetail(category.categoryId)} >
    {category.name} {category.count} attacks
    {category.averageStars}★ avg {category.averagePercent}% avg {formatTrophies(category.averageTrophies)} avg
  • )) ) : (
  • Create an army category to start tracking.
  • )}

Defense Log

Newest entries appear on top.

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

New Army Category

{errors.category}

New Base

Image Source
{imageMode === 'upload' ? ( ) : ( )}

{errors.base}

Log Defense

{errors.defense}

{baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''}

{baseDetailMeta?.title}

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

{baseDetailMeta?.imageUrl && ( // eslint-disable-next-line @next/next/no-img-element {`Preview )}

Base Averages

{baseDetail && baseDetail.count ? ( <> {baseDetail.averageStars}★ average •{' '} {baseDetail.averagePercent}% destruction
{formatTrophies(baseDetail.averageTrophies)} avg{' '} {baseDetail.count} defenses ) : ( 'No defenses logged yet.' )}

Army Categories vs This Base

    {baseDetail && baseDetail.categories.length ? ( baseDetail.categories.map((category) => (
  • openCategoryDetail(category.categoryId)} >
    {category.name} {category.count} attacks
    {category.averageStars}★ avg {category.averagePercent}% avg {formatTrophies(category.averageTrophies)} avg
  • )) ) : (
  • No army categories have attacked this base yet.
  • )}

Defenses

    {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) || 'Unknown Army'; return (
  • {categoryName}
    {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)}
    {date.toLocaleString()}
  • ); }) ) : (
  • No defenses recorded for this base yet.
  • )}

{categoryDetail?.name}

Average performance of this army across your bases.

Category Averages

{categoryDetail && categoryDetail.count ? ( <> {categoryDetail.averageStars}★ average •{' '} {categoryDetail.averagePercent}% destruction
{formatTrophies(categoryDetail.averageTrophies)} avg{' '} {categoryDetail.count} attacks ) : ( 'No defenses logged yet.' )}

Performance by Base

    {categoryDetail && categoryDetail.bases.length ? ( categoryDetail.bases.map((base) => (
  • openBaseDetail(base.baseId)} >
    {base.title} {base.count} defenses
    {base.averageStars}★ avg {base.averagePercent}% avg {formatTrophies(base.averageTrophies)} avg
  • )) ) : (
  • This army has not attacked any bases yet.
  • )}

Defenses

    {defenses.filter((defense) => defense.armyCategoryId === selectedCategoryId).length ? ( defenses .filter((defense) => defense.armyCategoryId === selectedCategoryId) .map((defense) => { const date = new Date(defense.createdAt); return (
  • {defense.baseTitle}
    {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)}
    {date.toLocaleString()}
  • ); }) ) : (
  • No logged defenses for this army yet.
  • )}
); }