'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'; // 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}`, updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`, profiles: `${API_BASE}/profiles`, profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`, }; const PROFILE_DEFENSE_PREVIEW_LIMIT = 5; type User = { id: string; username: string; createdAt: string; }; type ArmyCategory = { id: string; name: string; createdAt: string; }; type BaseItem = { id: string; title: string; description: string; url: string; imageUrl: string; isPrivate: boolean; createdAt: string; }; 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[]; }; 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; }; 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 [editImageMode, setEditImageMode] = useState<'keep' | 'upload' | 'url' | 'remove'>('keep'); const [editingBaseId, setEditingBaseId] = useState(null); const [editingDefenseId, setEditingDefenseId] = useState(null); const [profileSearchTerm, setProfileSearchTerm] = useState(''); const [profileResults, setProfileResults] = useState([]); const [profileDetail, setProfileDetail] = useState(null); const [profileError, setProfileError] = useState(''); const [profileLoading, setProfileLoading] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { (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]); const baseBeingEdited = useMemo(() => { if (!editingBaseId) return null; return bases.find((base) => base.id === editingBaseId) ?? null; }, [editingBaseId, bases]); const defenseBeingEdited = useMemo(() => { if (!editingDefenseId) return null; return defenses.find((defense) => defense.id === editingDefenseId) ?? null; }, [editingDefenseId, defenses]); async function handleLogin(event: FormEvent) { event.preventDefault(); const formData = new FormData(event.currentTarget); 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 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) { 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) { 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) { event.preventDefault(); if (!editingBaseId || !baseBeingEdited) { return; } const form = event.currentTarget; const formData = new FormData(form); formData.set('imageMode', editImageMode); formData.set('removeImage', editImageMode === 'remove' ? 'true' : 'false'); const requestedPrivate = formData.get('isPrivate'); const isPrivate = typeof requestedPrivate === 'string' ? requestedPrivate === 'on' : false; formData.set('isPrivate', isPrivate ? 'true' : 'false'); if (editImageMode === 'upload') { formData.delete('imageUrl'); } else if (editImageMode === 'url') { formData.delete('imageFile'); } else { formData.delete('imageFile'); formData.delete('imageUrl'); } try { setErrors((prev) => ({ ...prev, base: '' })); await request('PUT', API.updateBase(editingBaseId), formData, { isForm: true }); setEditingBaseId(null); setEditImageMode('keep'); await refreshData(); await refreshOwnProfileDetail(); } catch (error: any) { setErrors((prev) => ({ ...prev, base: error.message })); } } async function handleDeleteBase(baseId: string) { const base = bases.find((item) => item.id === baseId); const name = base ? `"${base.title}"` : 'this base'; const confirmDelete = window.confirm(`Delete ${name}? This also removes its attacks.`); if (!confirmDelete) { return; } try { await request('DELETE', API.deleteBase(baseId)); if (editingBaseId === baseId) { setEditingBaseId(null); setEditImageMode('keep'); } if (selectedBaseId === baseId) { setSelectedBaseId(null); setView('dashboard'); } await refreshData(); await refreshOwnProfileDetail(); } catch (error: any) { setErrors((prev) => ({ ...prev, base: error.message })); } } async function handleDeleteCategory(categoryId: string) { const category = categories.find((item) => item.id === categoryId); const name = category ? `"${category.name}"` : 'this category'; const confirmDelete = window.confirm(`Delete ${name}? Attacks tracked for it will also go away.`); if (!confirmDelete) { return; } try { await request('DELETE', API.deleteCategory(categoryId)); if (selectedCategoryId === categoryId) { setSelectedCategoryId(null); setView('dashboard'); } await refreshData(); await refreshOwnProfileDetail(); } catch (error: any) { setErrors((prev) => ({ ...prev, category: error.message })); } } function startEditingDefense(defenseId: string) { setEditingDefenseId(defenseId); setErrors((prev) => ({ ...prev, defense: '' })); } function cancelEditingDefense() { setEditingDefenseId(null); } async function handleDefenseEditSubmit(event: FormEvent) { event.preventDefault(); if (!editingDefenseId || !defenseBeingEdited) { return; } const form = event.currentTarget; const formData = new FormData(form); const payload = Object.fromEntries(formData.entries()); try { setErrors((prev) => ({ ...prev, defense: '' })); await request('PUT', API.updateDefense(editingDefenseId), payload); setEditingDefenseId(null); await refreshData(); await refreshOwnProfileDetail(); } catch (error: any) { setErrors((prev) => ({ ...prev, defense: error.message })); } } async function handleDeleteDefense(defenseId: string) { const confirmDelete = window.confirm('Delete this attack?'); if (!confirmDelete) { return; } try { await request('DELETE', API.deleteDefense(defenseId)); if (editingDefenseId === defenseId) { setEditingDefenseId(null); } await refreshData(); await refreshOwnProfileDetail(); } catch (error: any) { setErrors((prev) => ({ ...prev, defense: error.message })); } } async function handleProfileSearch(event: FormEvent) { event.preventDefault(); setProfileError(''); const term = profileSearchTerm.trim(); if (!term) { setProfileResults([]); setProfileDetail(null); return; } try { setProfileLoading(true); const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`); setProfileResults(data.profiles || []); setProfileDetail(null); } catch (error: any) { setProfileResults([]); setProfileDetail(null); setProfileError(error.message); } finally { setProfileLoading(false); } } async function loadProfile(username: string) { try { setProfileLoading(true); setProfileError(''); const data: ProfileDetail = await request('GET', API.profileDetail(username)); setProfileDetail(data); } catch (error: any) { setProfileDetail(null); setProfileError(error.message); } finally { setProfileLoading(false); } } function clearProfileDetail() { setProfileDetail(null); } async function refreshOwnProfileDetail() { if (profileDetail && user && profileDetail.profile.username === user.username) { await loadProfile(user.username); } } async function handleLogout() { try { await request('POST', API.logout); } 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); setProfileSearchTerm(''); setProfileError(''); setProfileLoading(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(); 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) => { const baseMeta = bases.find((item) => item.id === base.baseId); return (
  • openBaseDetail(base.baseId)} >
    {base.title}{' '} {baseMeta?.isPrivate ? Private : null}
    {base.count} defenses
    {base.averageStars}★ avg {base.averagePercent}% avg {formatTrophies(base.averageTrophies)} avg
  • ); }) ) : (
  • {bases.length ? '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.
  • )}

Search Profiles

{profileError}

Results

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

Profile: {profileDetail.profile.username}

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

    No public attacks yet.

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

Private bases stay hidden from other players.

) : null}
)}

Defense Log

Newest entries appear on top.

    {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}

Existing Categories

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

New Base

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

{errors.base}

Manage Bases

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

Edit Base: {baseBeingEdited.title}

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

{errors.base}

) : null}
)}

Log Defense

{errors.defense}

Manage Attacks

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

Showing the latest 10 entries.

) : null}
{defenseBeingEdited && (

Edit Attack

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

{errors.defense}

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

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

{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.
  • )}
); }