Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
707 lines
28 KiB
TypeScript
707 lines
28 KiB
TypeScript
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 <AppCore isSignedIn={!!isSignedIn} />
|
|
}
|
|
|
|
export const App: React.FC = () => {
|
|
return hasClerk ? <AppWithAuth /> : <AppCore isSignedIn={true} />
|
|
}
|
|
|
|
const AppCore: React.FC<AppCoreProps> = ({ 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<LayoutMode>(
|
|
() => (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<DbSection>('backups')
|
|
const [welcomeSection, setWelcomeSection] = useState<WelcomeSection>('config')
|
|
const [showBackendConfig, setShowBackendConfig] = useState(false)
|
|
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(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<void>) | 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<Set<TabId>>(() => new Set<TabId>([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) => (
|
|
<TabPane active={currentTab === id}>
|
|
{mounted.has(id) ? node : null}
|
|
</TabPane>
|
|
)
|
|
|
|
// #165: when the SPA never reached the backend, show an informative setup
|
|
// screen instead of an empty, non-working dashboard.
|
|
if (connState === 'error') {
|
|
return <BackendUnreachable onRetry={() => 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).
|
|
<div key={i18n.language} className="h-screen flex flex-col overflow-hidden bg-background">
|
|
<Toast.Provider />
|
|
|
|
{/* Header */}
|
|
<header
|
|
className="flex items-center justify-between px-6 py-3 border-b border-border bg-surface shrink-0"
|
|
style={{ background: 'linear-gradient(180deg, var(--surface-secondary) 0%, var(--surface) 100%)' }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
className="text-xl font-bold uppercase tracking-[0.2em] text-accent px-0 h-auto min-w-0 hover:opacity-80"
|
|
onPress={() => navigate(`/${DEFAULT_TAB}`)}
|
|
aria-label={t('app.goHome')}
|
|
>
|
|
{t('app.title')}
|
|
</Button>
|
|
{status?.control && status.control !== 'none' && <span className="text-xs text-muted">{status.control}</span>}
|
|
{status?.ssh_host && <span className="text-xs text-muted">{status.ssh_host}</span>}
|
|
{status?.db_host && status.control !== 'kubectl' && (
|
|
<span className="text-xs text-muted">{status.db_host}</span>
|
|
)}
|
|
{status?.version && (
|
|
<Button
|
|
variant="ghost"
|
|
className="text-xs text-muted hover:text-foreground px-0 h-auto min-w-0"
|
|
onPress={() => setShowBackendConfig(true)}
|
|
aria-label={t('app.openSettings')}
|
|
>
|
|
v
|
|
{status.version}
|
|
</Button>
|
|
)}
|
|
{updateInfo?.needs_update && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowUpdateModal(true)}
|
|
aria-label={t('app.updateAvailable')}
|
|
className="cursor-pointer border-0 bg-transparent p-0"
|
|
>
|
|
<Chip size="sm" color="warning" variant="soft">
|
|
↑
|
|
{' '}
|
|
{updateInfo.latest.replace(/^v/, '')}
|
|
</Chip>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{status?.executor === 'ssh' && <ConnectionBadge label="SSH" connected={status.ssh_connected} />}
|
|
<ConnectionBadge label="DB" connected={status?.db_connected ?? false} />
|
|
{status && !status.db_connected && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
isDisabled={reconnecting}
|
|
onPress={handleReconnect}
|
|
>
|
|
{reconnecting ? t('app.reconnecting') : t('app.reconnect')}
|
|
</Button>
|
|
)}
|
|
{status?.pod_ns && (
|
|
<span className="text-xs text-muted">
|
|
ns:
|
|
{status.pod_ns}
|
|
</span>
|
|
)}
|
|
|
|
<HelpMenu status={status} />
|
|
<ThemeSelector />
|
|
<LanguageSelector />
|
|
<ToggleButtonGroup
|
|
selectionMode="single"
|
|
disallowEmptySelection
|
|
selectedKeys={[layoutMode]}
|
|
onSelectionChange={(keys) => {
|
|
const next = [...keys][0]
|
|
if (next === 'sidenav' || next === 'topnav') setLayout(next)
|
|
}}
|
|
>
|
|
<ToggleButton id="sidenav" isIconOnly aria-label={t('app.switchToSidenav')}>
|
|
<Icon name="layout-panel-left" />
|
|
</ToggleButton>
|
|
<ToggleButton id="topnav" isIconOnly aria-label={t('app.switchToTopnav')}>
|
|
<Icon name="layout-panel-top" />
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
aria-label={t('app.configureBackend')}
|
|
onPress={() => setShowBackendConfig((v) => !v)}
|
|
className={showBackendConfig ? 'text-accent border-accent' : ''}
|
|
>
|
|
<Icon name="settings" />
|
|
{' '}
|
|
{t('app.settings')}
|
|
</Button>
|
|
|
|
{hasClerk && (
|
|
<>
|
|
<Show when="signed-out">
|
|
<SignInButton>
|
|
<Button size="sm" variant="outline">
|
|
{t('app.signIn')}
|
|
</Button>
|
|
</SignInButton>
|
|
</Show>
|
|
<Show when="signed-in">
|
|
<UserButton />
|
|
</Show>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Settings modal — structure mirrors BotControlPanel */}
|
|
<Modal>
|
|
<Modal.Backdrop isOpen={showBackendConfig} onOpenChange={(v) => !v && setShowBackendConfig(false)}>
|
|
<Modal.Container size="cover" scroll="outside">
|
|
<Modal.Dialog className="h-[92vh] flex flex-col">
|
|
<Modal.CloseTrigger />
|
|
<Modal.Header>
|
|
<div className="flex items-baseline gap-6 flex-wrap">
|
|
<Modal.Heading className="text-accent">{t('app.settings')}</Modal.Heading>
|
|
{status && (
|
|
<div className="flex items-center gap-4 text-xs text-muted">
|
|
{status.version && (
|
|
<span className="font-mono">
|
|
v
|
|
{status.version}
|
|
</span>
|
|
)}
|
|
{status.control && status.control !== 'none' && <span>{status.control}</span>}
|
|
{status.commit && status.commit !== 'unknown' && (
|
|
<span className="font-mono opacity-60">{status.commit}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal.Header>
|
|
|
|
{/* Body scrolls; form fills it with its own internal tab scroll */}
|
|
<Modal.Body className="flex flex-col overflow-y-auto flex-1 min-h-0 pr-1">
|
|
{showBackendConfig && (
|
|
<SettingsConfigForm saveRef={formSaveRef} onSavingChange={setFormSaving} />
|
|
)}
|
|
</Modal.Body>
|
|
|
|
<Modal.Footer className="flex items-center gap-2">
|
|
{/* Left: update controls — fixed positions so buttons don't shift */}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onPress={checkUpdate}
|
|
isDisabled={updateChecking || updateApplying}
|
|
>
|
|
{updateChecking
|
|
? (
|
|
<>
|
|
<Spinner size="sm" color="current" />
|
|
{' '}
|
|
{t('common.checking')}
|
|
</>
|
|
)
|
|
: t('app.checkUpdates')}
|
|
</Button>
|
|
{updateInfo && !updateInfo.needs_update && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onPress={() => applyUpdate(true)}
|
|
isDisabled={updateApplying}
|
|
>
|
|
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.reinstall')}
|
|
</Button>
|
|
)}
|
|
{updateInfo?.needs_update && (
|
|
<Button size="sm" onPress={() => applyUpdate()} isDisabled={updateApplying}>
|
|
{updateApplying
|
|
? <Spinner size="sm" color="current" />
|
|
: (
|
|
<span className="font-mono text-xs">
|
|
v
|
|
{updateInfo.current}
|
|
{' → '}
|
|
v
|
|
{updateInfo.latest.replace(/^v/, '')}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<span className="flex-1" />
|
|
|
|
{/* Right: save + close */}
|
|
<span className="text-xs text-muted">{t('app.changesNote')}</span>
|
|
<Button
|
|
size="sm"
|
|
onPress={() => formSaveRef.current?.()}
|
|
isDisabled={formSaving}
|
|
>
|
|
{formSaving
|
|
? (
|
|
<>
|
|
<Spinner size="sm" color="current" />
|
|
{' '}
|
|
{t('common.saving')}
|
|
</>
|
|
)
|
|
: (
|
|
<>
|
|
<Icon name="save" />
|
|
{' '}
|
|
{t('app.saveApply')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="tertiary"
|
|
onPress={() => setShowBackendConfig(false)}
|
|
>
|
|
{t('common.close')}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Modal.Dialog>
|
|
</Modal.Container>
|
|
</Modal.Backdrop>
|
|
</Modal>
|
|
|
|
{/* Update-available prompt — opened from the header release widget (#129).
|
|
Reuses the backend update check for the release-notes link + Continue/Cancel. */}
|
|
<Modal>
|
|
<Modal.Backdrop isOpen={showUpdateModal} onOpenChange={(v) => !v && setShowUpdateModal(false)}>
|
|
<Modal.Container size="sm">
|
|
<Modal.Dialog>
|
|
<Modal.CloseTrigger />
|
|
<Modal.Header>
|
|
<Modal.Heading className="text-accent">{t('app.updateAvailable')}</Modal.Heading>
|
|
</Modal.Header>
|
|
<Modal.Body className="flex flex-col gap-3">
|
|
<p className="text-sm text-muted">
|
|
{t('app.updateAvailableBody', {
|
|
current: updateInfo?.current ?? '',
|
|
latest: updateInfo?.latest?.replace(/^v/, '') ?? '',
|
|
})}
|
|
</p>
|
|
{updateInfo?.release_url && (
|
|
<a
|
|
href={updateInfo.release_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-accent hover:opacity-80"
|
|
>
|
|
<Icon name="external-link" />
|
|
{' '}
|
|
{t('app.viewReleaseNotes')}
|
|
</a>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer className="flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="tertiary"
|
|
onPress={() => setShowUpdateModal(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onPress={() => {
|
|
setShowUpdateModal(false)
|
|
void applyUpdate()
|
|
}}
|
|
isDisabled={updateApplying}
|
|
>
|
|
{updateApplying ? <Spinner size="sm" color="current" /> : t('app.updateNow')}
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Modal.Dialog>
|
|
</Modal.Container>
|
|
</Modal.Backdrop>
|
|
</Modal>
|
|
|
|
{/* 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'
|
|
? <MDatabaseTab section={dbSection} showSubnav onSectionChange={setDbSection} />
|
|
: <MDatabaseTab section={dbSection} />
|
|
|
|
const welcomeNode = layoutMode === 'topnav'
|
|
? <MWelcomePackageTab section={welcomeSection} showSubnav onSectionChange={setWelcomeSection} />
|
|
: <MWelcomePackageTab section={welcomeSection} />
|
|
|
|
if (layoutMode === 'sidenav') {
|
|
return (
|
|
<div className="flex-1 flex gap-3 p-3 overflow-hidden min-h-0">
|
|
<nav className="w-60 shrink-0 flex flex-col gap-2 overflow-y-auto">
|
|
{/* Operations: rendered separately so Database can expand DB sub-items inline */}
|
|
<SideNav
|
|
width="w-full"
|
|
title={NAV_GROUPS[0].title}
|
|
items={[
|
|
...NAV_GROUPS[0].items.slice(0, 3),
|
|
...(currentTab === 'database' ? DB_SECTIONS : []),
|
|
...NAV_GROUPS[0].items.slice(3),
|
|
] as { key: string, label: string, depth?: number }[]}
|
|
active={currentTab === 'database' ? `db:${dbSection}` : currentTab}
|
|
onSelect={(k: string) => {
|
|
if (k.startsWith('db:')) {
|
|
setDbSection(k.slice(3) as DbSection)
|
|
if (currentTab !== 'database') navigate('/database')
|
|
}
|
|
else {
|
|
navigate(`/${k}`)
|
|
}
|
|
}}
|
|
/>
|
|
{/* Player World: unchanged */}
|
|
<SideNav
|
|
key={NAV_GROUPS[1].title}
|
|
width="w-full"
|
|
title={NAV_GROUPS[1].title}
|
|
items={NAV_GROUPS[1].items}
|
|
active={currentTab}
|
|
onSelect={(k) => navigate(`/${k}`)}
|
|
/>
|
|
{/* Economy: expand Welcome sub-items inline */}
|
|
<SideNav
|
|
width="w-full"
|
|
title={NAV_GROUPS[2].title}
|
|
items={[
|
|
...NAV_GROUPS[2].items,
|
|
...(currentTab === 'welcome' ? WELCOME_SECTIONS : []),
|
|
] as { key: string, label: string, depth?: number }[]}
|
|
active={currentTab === 'welcome' ? `welcome:${welcomeSection}` : currentTab}
|
|
onSelect={(k: string) => {
|
|
if (k.startsWith('welcome:')) {
|
|
setWelcomeSection(k.slice(8) as WelcomeSection)
|
|
if (currentTab !== 'welcome') navigate('/welcome')
|
|
}
|
|
else {
|
|
navigate(`/${k}`)
|
|
}
|
|
}}
|
|
/>
|
|
</nav>
|
|
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
|
|
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
|
|
{renderTab('database', databaseNode)}
|
|
{renderTab('logs', <MLogsTab control={status?.control} />)}
|
|
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('landsraad', <MLandsraadTab />)}
|
|
{renderTab('storage', <MStorageTab />)}
|
|
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
|
|
{renderTab('server', <MServerSettingsTab />)}
|
|
{renderTab('director', <MDirectorTab />)}
|
|
{renderTab('market', <MMarketTab />)}
|
|
{renderTab('welcome', welcomeNode)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// topnav mode: horizontal nav bar + full-width content area
|
|
return (
|
|
<>
|
|
<Tabs
|
|
selectedKey={currentTab}
|
|
onSelectionChange={(k) => navigate(`/${String(k)}`)}
|
|
className="shrink-0 border-b border-border bg-surface"
|
|
>
|
|
<Tabs.ListContainer className="px-3 py-2 overflow-x-auto">
|
|
<Tabs.List aria-label={t('app.title')}>
|
|
{NAV_GROUPS.flatMap((g) => g.items).map((item) => (
|
|
<Tabs.Tab key={item.key} id={item.key}>
|
|
{item.label}
|
|
<Tabs.Indicator />
|
|
</Tabs.Tab>
|
|
))}
|
|
</Tabs.List>
|
|
</Tabs.ListContainer>
|
|
</Tabs>
|
|
<div className="flex-1 flex flex-col p-3 overflow-hidden min-h-0">
|
|
<main className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
{renderTab('battlegroup', <MBattlegroupTab isActive={currentTab === 'battlegroup'} />)}
|
|
{renderTab('players', <MPlayersTab isActive={currentTab === 'players'} />)}
|
|
{renderTab('database', databaseNode)}
|
|
{renderTab('logs', <MLogsTab control={status?.control} />)}
|
|
{renderTab('blueprints', <MBlueprintsTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('bases', <MBasesTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('guilds', <MGuildsTab isSignedIn={isSignedIn} />)}
|
|
{renderTab('landsraad', <MLandsraadTab />)}
|
|
{renderTab('storage', <MStorageTab />)}
|
|
{renderTab('livemap', <MLiveMapTab isActive={currentTab === 'livemap'} />)}
|
|
{renderTab('server', <MServerSettingsTab />)}
|
|
{renderTab('director', <MDirectorTab />)}
|
|
{renderTab('market', <MMarketTab />)}
|
|
{renderTab('welcome', welcomeNode)}
|
|
</main>
|
|
</div>
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TabPane({ active, children }: TabPaneProps) {
|
|
return (
|
|
<div className={`flex-1 min-h-0 ${active ? 'flex flex-col dune-tab-active' : 'hidden'}`}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConnectionBadge({ label, connected }: ConnectionBadgeProps) {
|
|
return (
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-success' : 'bg-muted/40'}`} />
|
|
<span className={connected ? 'text-foreground' : 'text-muted'}>{label}</span>
|
|
</div>
|
|
)
|
|
}
|