docs(reference): import Dune: Awakening server-manager references
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>
This commit is contained in:
706
docs/reference-repos/icehunter/web/src/App.tsx
Normal file
706
docs/reference-repos/icehunter/web/src/App.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user