import type React from 'react' import { memo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react' import { Show, SignInButton, UserButton, useAuth } from '@clerk/react' import { Button, Chip, Modal, Spinner, Tabs, Toast, ToggleButton, ToggleButtonGroup, toast } from '@heroui/react' import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useStatus } from './hooks/useStatus' import { BackendUnreachable } from './components/BackendUnreachable' import { SettingsConfigForm } from './components/SettingsConfigForm' import { LanguageSelector } from './components/LanguageSelector' import { ThemeSelector } from './components/ThemeSelector' import { HelpMenu } from './components/HelpMenu' import { BattlegroupTab } from './tabs/BattlegroupTab' import { LiveMapTab } from './tabs/LiveMapTab' import { PlayersTab } from './tabs/PlayersTab' import { DatabaseTab } from './tabs/DatabaseTab' import { LogsTab } from './tabs/LogsTab' import { BlueprintsTab } from './tabs/BlueprintsTab' import { BasesTab } from './tabs/BasesTab' import { GuildsTab } from './tabs/GuildsTab' import { LandsraadTab } from './tabs/LandsraadTab' import { StorageTab } from './tabs/StorageTab' import { ServerSettingsTab } from './tabs/ServerSettingsTab' import { DirectorTab } from './tabs/DirectorTab' import { MarketTab } from './tabs/MarketTab' import { WelcomePackageTab } from './tabs/WelcomePackageTab' import { Icon, SideNav } from './dune-ui' import { api } from './api/client' import type { UpdateCheckResult } from './api/client' const TAB_IDS = [ 'battlegroup', 'players', 'database', 'logs', 'blueprints', 'bases', 'guilds', 'landsraad', 'storage', 'livemap', 'server', 'director', 'market', 'welcome', ] as const type TabId = (typeof TAB_IDS)[number] const DEFAULT_TAB: TabId = 'battlegroup' function currentTabFromPath(pathname: string): TabId { const seg = pathname.replace(/^\//, '').split('/')[0] return (TAB_IDS as readonly string[]).includes(seg) ? (seg as TabId) : DEFAULT_TAB } type DbSection = 'backups' | 'tables' | 'describe' | 'sample' | 'search' | 'sql' type WelcomeSection = 'config' | 'packages' | 'grants' type LayoutMode = 'sidenav' | 'topnav' // Memoized at module level so identity is stable — prevents all inactive tabs from // re-rendering whenever AppCore re-renders (e.g. router location change, useStatus poll). const MBattlegroupTab = memo(BattlegroupTab) const MLiveMapTab = memo(LiveMapTab) const MPlayersTab = memo(PlayersTab) const MDatabaseTab = memo(DatabaseTab) const MLogsTab = memo(LogsTab) const MBlueprintsTab = memo(BlueprintsTab) const MBasesTab = memo(BasesTab) const MGuildsTab = memo(GuildsTab) const MLandsraadTab = memo(LandsraadTab) const MStorageTab = memo(StorageTab) const MServerSettingsTab = memo(ServerSettingsTab) const MDirectorTab = memo(DirectorTab) const MMarketTab = memo(MarketTab) const MWelcomePackageTab = memo(WelcomePackageTab) const hasClerk = !!import.meta.env.VITE_CLERK_PUBLISHABLE_KEY interface AppCoreProps { isSignedIn: boolean } interface TabPaneProps { active: boolean children: ReactNode } interface ConnectionBadgeProps { label: string connected: boolean } function AppWithAuth() { const { isSignedIn } = useAuth() return } export const App: React.FC = () => { return hasClerk ? : } const AppCore: React.FC = ({ isSignedIn }) => { const { status, state: connState } = useStatus() const location = useLocation() const navigate = useNavigate() const { t, i18n } = useTranslation() const [reconnecting, setReconnecting] = useState(false) const DB_SECTIONS: { key: string, label: string, depth: number }[] = [ { key: 'db:backups', label: `╰─ ${t('database.sections.backups')}`, depth: 1 }, { key: 'db:tables', label: `╰─ ${t('database.sections.tables')}`, depth: 1 }, { key: 'db:describe', label: `╰─ ${t('database.sections.describe')}`, depth: 1 }, { key: 'db:sample', label: `╰─ ${t('database.sections.sample')}`, depth: 1 }, { key: 'db:search', label: `╰─ ${t('database.sections.search')}`, depth: 1 }, { key: 'db:sql', label: `╰─ ${t('database.sections.sql')}`, depth: 1 }, ] const WELCOME_SECTIONS: { key: string, label: string, depth: number }[] = [ { key: 'welcome:config', label: `╰─ ${t('welcome.sections.config')}`, depth: 1 }, { key: 'welcome:packages', label: `╰─ ${t('welcome.sections.packages')}`, depth: 1 }, { key: 'welcome:grants', label: `╰─ ${t('welcome.sections.grants')}`, depth: 1 }, ] // Re-establish backend connections (DB + control plane) without a service // restart — used by the header Reconnect button when the DB shows disconnected // (e.g. dune-admin came up before the database was ready). const handleReconnect = async () => { setReconnecting(true) try { const s = await api.reconnect() if (s.db_connected) toast.success(t('app.reconnected')) else toast.danger(t('app.reconnectFailed', { error: 'database still unreachable' })) } catch (e) { toast.danger(t('app.reconnectFailed', { error: e instanceof Error ? e.message : String(e) })) } finally { setReconnecting(false) } } // Left-sidebar navigation, grouped to mirror the product's structure // (operator tooling today; a Player Portal group lands here later). const NAV_GROUPS: { title: string, items: { key: TabId, label: string }[] }[] = [ { title: t('nav.groups.operations'), items: [ { key: 'battlegroup' as TabId, label: t('nav.battlegroup') }, { key: 'logs' as TabId, label: t('nav.logs') }, { key: 'database' as TabId, label: t('nav.database') }, { key: 'server' as TabId, label: t('nav.server') }, { key: 'director' as TabId, label: t('nav.director') }, ], }, { title: t('nav.groups.playerWorld'), items: [ { key: 'players' as TabId, label: t('nav.players') }, { key: 'livemap' as TabId, label: t('nav.liveMap') }, { key: 'storage' as TabId, label: t('nav.storage') }, { key: 'bases' as TabId, label: t('nav.bases') }, { key: 'guilds' as TabId, label: t('nav.guilds') }, { key: 'landsraad' as TabId, label: t('nav.landsraad') }, { key: 'blueprints' as TabId, label: t('nav.blueprints') }, ], }, { title: t('nav.groups.economy'), items: [ { key: 'market' as TabId, label: t('nav.market') }, { key: 'welcome' as TabId, label: t('nav.welcome') }, ], }, ] const [layoutMode, setLayoutMode] = useState( () => (localStorage.getItem('dune_admin_layout') === 'topnav' ? 'topnav' : 'sidenav'), ) const setLayout = useCallback((m: LayoutMode) => { localStorage.setItem('dune_admin_layout', m) setLayoutMode(m) }, []) const [dbSection, setDbSection] = useState('backups') const [welcomeSection, setWelcomeSection] = useState('config') const [showBackendConfig, setShowBackendConfig] = useState(false) const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) const [updateChecking, setUpdateChecking] = useState(false) const [updateApplying, setUpdateApplying] = useState(false) const [formSaving, setFormSaving] = useState(false) const formSaveRef = useRef<(() => Promise) | null>(null) useEffect(() => { const seg = location.pathname.replace(/^\//, '').split('/')[0] if (!seg || !(TAB_IDS as readonly string[]).includes(seg)) { navigate(`/${DEFAULT_TAB}`, { replace: true }) } }, [location.pathname, navigate]) const currentTab = currentTabFromPath(location.pathname) // Tracks which tabs have been visited at least once — they get mounted and stay // mounted (TabPane keeps them hidden), preserving in-tab state and the isActive // auto-refresh contract. Unvisited tabs never mount, avoiding the startup query storm. const [mounted, setMounted] = useState>(() => new Set([currentTab])) useEffect(() => { setMounted((prev) => { // eslint-disable-line react-hooks/set-state-in-effect if (prev.has(currentTab)) return prev const next = new Set(prev) next.add(currentTab) return next }) }, [currentTab]) // Check for a newer release via the backend (it knows this build's version and // returns the release-notes URL) — drives the clickable header update widget (#129). useEffect(() => { api.update.check().then(setUpdateInfo).catch(() => {}) }, []) const checkUpdate = async () => { setUpdateChecking(true) try { setUpdateInfo(await api.update.check()) } catch { // silently ignore — user can retry } finally { setUpdateChecking(false) } } const applyUpdate = async (force = false) => { setUpdateApplying(true) try { const result = await api.update.apply(force) if (result.updated) { toast.success(force ? t('app.reinstalled', { version: result.version ?? 'latest' }) : t('app.updated', { version: result.version ?? 'latest' })) setUpdateInfo(null) setTimeout(() => { window.location.reload() }, 1500) } else { toast.info(result.message) } } catch (e) { toast.danger(t('app.updateFailed', { message: e instanceof Error ? e.message : String(e) })) } finally { setUpdateApplying(false) } } const renderTab = (id: TabId, node: ReactNode) => ( {mounted.has(id) ? node : null} ) // #165: when the SPA never reached the backend, show an informative setup // screen instead of an empty, non-working dashboard. if (connState === 'error') { return window.location.reload()} /> } return ( // Keyed on the active language so switching language remounts the content // subtree once. The module-level memo() tabs stay mounted and otherwise keep // stale-language text on a language change (their props don't change), until // an unrelated local state update forces them to re-render (#123).
{/* Header */}
{status?.control && status.control !== 'none' && {status.control}} {status?.ssh_host && {status.ssh_host}} {status?.db_host && status.control !== 'kubectl' && ( {status.db_host} )} {status?.version && ( )} {updateInfo?.needs_update && ( )}
{status?.executor === 'ssh' && } {status && !status.db_connected && ( )} {status?.pod_ns && ( ns: {status.pod_ns} )} { const next = [...keys][0] if (next === 'sidenav' || next === 'topnav') setLayout(next) }} > {hasClerk && ( <> )}
{/* Settings modal — structure mirrors BotControlPanel */} !v && setShowBackendConfig(false)}>
{t('app.settings')} {status && (
{status.version && ( v {status.version} )} {status.control && status.control !== 'none' && {status.control}} {status.commit && status.commit !== 'unknown' && ( {status.commit} )}
)}
{/* Body scrolls; form fills it with its own internal tab scroll */} {showBackendConfig && ( )} {/* Left: update controls — fixed positions so buttons don't shift */} {updateInfo && !updateInfo.needs_update && ( )} {updateInfo?.needs_update && ( )} {/* Spacer */} {/* Right: save + close */} {t('app.changesNote')}
{/* Update-available prompt — opened from the header release widget (#129). Reuses the backend update check for the release-notes link + Continue/Cancel. */} !v && setShowUpdateModal(false)}> {t('app.updateAvailable')}

{t('app.updateAvailableBody', { current: updateInfo?.current ?? '', latest: updateInfo?.latest?.replace(/^v/, '') ?? '', })}

{updateInfo?.release_url && ( {' '} {t('app.viewReleaseNotes')} )}
{/* Body: layout-conditional rendering. In sidenav mode: grouped left sidebar + content. In topnav mode: horizontal nav bar + full-width content. All tabs stay mounted (inactive hidden) so per-tab state and isActive auto-refresh behavior persist. */} {(() => { const databaseNode = layoutMode === 'topnav' ? : const welcomeNode = layoutMode === 'topnav' ? : if (layoutMode === 'sidenav') { return (
{renderTab('battlegroup', )} {renderTab('players', )} {renderTab('database', databaseNode)} {renderTab('logs', )} {renderTab('blueprints', )} {renderTab('bases', )} {renderTab('guilds', )} {renderTab('landsraad', )} {renderTab('storage', )} {renderTab('livemap', )} {renderTab('server', )} {renderTab('director', )} {renderTab('market', )} {renderTab('welcome', welcomeNode)}
) } // topnav mode: horizontal nav bar + full-width content area return ( <> navigate(`/${String(k)}`)} className="shrink-0 border-b border-border bg-surface" > {NAV_GROUPS.flatMap((g) => g.items).map((item) => ( {item.label} ))}
{renderTab('battlegroup', )} {renderTab('players', )} {renderTab('database', databaseNode)} {renderTab('logs', )} {renderTab('blueprints', )} {renderTab('bases', )} {renderTab('guilds', )} {renderTab('landsraad', )} {renderTab('storage', )} {renderTab('livemap', )} {renderTab('server', )} {renderTab('director', )} {renderTab('market', )} {renderTab('welcome', welcomeNode)}
) })()}
) } function TabPane({ active, children }: TabPaneProps) { return (
{children}
) } function ConnectionBadge({ label, connected }: ConnectionBadgeProps) { return (
{label}
) }