568 lines
24 KiB
TypeScript
568 lines
24 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { AdminPanel } from '@/components/admin-panel'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||
import { Progress } from '@/components/ui/progress'
|
||
import { StatCard } from '@/components/ui/stat-card'
|
||
import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { CrewPanel } from '@/components/crew-panel'
|
||
import { EventLogPanel } from '@/components/event-log'
|
||
import { LocalSystemPanel } from '@/components/local-system'
|
||
import { MissionPanel } from '@/components/missions'
|
||
import { NextTurnButton } from '@/components/next-turn-button'
|
||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||
import { SkillTreePanel } from '@/components/skill-tree'
|
||
import { WorldMapPanel } from '@/components/world-map'
|
||
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
|
||
import type { CrewState, SystemState } from '@/lib/game/save'
|
||
import type { ShipState } from '@/lib/game/ship'
|
||
import type { TechDto } from '@/lib/game/tech'
|
||
import pkg from '@/package.json'
|
||
|
||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
||
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
|
||
// tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions,
|
||
// /api/events); the right column's CARGO HOLD renders from the lifted /api/system state. Single
|
||
// player, no auth, by design.
|
||
|
||
function useNarrow(threshold = 900): boolean {
|
||
const [narrow, setNarrow] = useState(
|
||
() => typeof window !== 'undefined' && window.innerWidth < threshold
|
||
)
|
||
useEffect(() => {
|
||
const handle = () => setNarrow(window.innerWidth < threshold)
|
||
window.addEventListener('resize', handle, { passive: true })
|
||
return () => window.removeEventListener('resize', handle)
|
||
}, [threshold])
|
||
return narrow
|
||
}
|
||
|
||
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
|
||
// #27 (an unknown stored value falls back to the default).
|
||
const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log']
|
||
const ACTIVE_TAB_KEY = 'sw-active-tab'
|
||
|
||
export function MissionControl() {
|
||
const narrow = useNarrow()
|
||
const [tick, setTick] = useState(0)
|
||
// Ship + system state is owned here (not in the tab panels): the SHIP SYSTEMS tab mirrors ship
|
||
// actions via setShip, and the right column's CARGO HOLD renders from the lifted inventory —
|
||
// the LOCAL SYSTEM tab reports each scan's response up via setSystem. Crew count feeds the
|
||
// roster bar — refetched on page load, like the other panels.
|
||
const [ship, setShip] = useState<ShipState | null>(null)
|
||
const [system, setSystem] = useState<SystemState | null>(null)
|
||
const [techs, setTechs] = useState<TechDto[] | null>(null)
|
||
const [crewCount, setCrewCount] = useState<number | null>(null)
|
||
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
|
||
// then so SSR and the first client render agree on the default tab (no hydration mismatch).
|
||
const [activeTab, setActiveTab] = useState<string | null>(null)
|
||
// #38 — admin DB CRUD overlay, opened from the header.
|
||
const [adminOpen, setAdminOpen] = useState(false)
|
||
|
||
// #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can
|
||
// re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory
|
||
// on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research
|
||
// completion) — a strict superset of refreshSystem, which stays for the jump callback. Same
|
||
// failure policy as the mount fetch — keep the old state on error.
|
||
const refreshAll = useCallback(() => {
|
||
let cancelled = false
|
||
fetch('/api/ship')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as ShipState
|
||
})
|
||
.then((s) => {
|
||
if (!cancelled) setShip(s)
|
||
})
|
||
.catch(() => {}) // left column + tab keep their loading state on failure
|
||
fetch('/api/system')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as SystemState
|
||
})
|
||
.then((s) => {
|
||
if (!cancelled) setSystem(s)
|
||
})
|
||
.catch(() => {})
|
||
fetch('/api/tech')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as TechDto[]
|
||
})
|
||
.then((t) => {
|
||
if (!cancelled) setTechs(t)
|
||
})
|
||
.catch(() => {})
|
||
fetch('/api/crew')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7)
|
||
})
|
||
.then((c) => {
|
||
if (!cancelled) setCrewCount(c.crew.length)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
// The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates.
|
||
useEffect(() => refreshAll(), [refreshAll])
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||
return () => clearInterval(id)
|
||
}, [])
|
||
|
||
// #27 — restore the persisted tab once on mount (validated against TAB_IDS).
|
||
useEffect(() => {
|
||
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
|
||
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
|
||
}, [])
|
||
|
||
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
|
||
// the lifted system state stays on the previous system until a page refresh. Same failure
|
||
// policy as the mount fetch — keep the old state on error.
|
||
const refreshSystem = useCallback(() => {
|
||
fetch('/api/system')
|
||
.then(async (res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
return (await res.json()) as SystemState
|
||
})
|
||
.then((s) => setSystem(s))
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||
|
||
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
|
||
// tab via the same pure helpers the API enforces (lib/game/tech.ts).
|
||
const researchedSlugs = new Set((techs ?? []).filter((t) => t.state === 'RESEARCHED').map((t) => t.slug))
|
||
const navAi = researchedSlugs.has('NAV_AI') // #35 — two-hop systems become jumpable chart fields
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
minHeight: '100vh',
|
||
// Wide layout is pinned to the viewport so only the columns scroll internally (#21);
|
||
// narrow keeps natural flow with body scroll.
|
||
height: narrow ? undefined : '100vh',
|
||
overflow: narrow ? undefined : 'hidden',
|
||
background: 'var(--background)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
fontFamily: 'var(--font-mono)',
|
||
}}
|
||
>
|
||
{/* ── HEADER ── */}
|
||
<header
|
||
style={{
|
||
height: '44px',
|
||
background: 'var(--surface)',
|
||
borderBottom: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: '0 1.25rem',
|
||
gap: '0.75rem',
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 50,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
color: 'var(--text-muted)',
|
||
fontSize: '0.7rem',
|
||
letterSpacing: '0.08em',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
v{pkg.version}
|
||
</span>
|
||
|
||
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
|
||
|
||
<h1
|
||
style={{
|
||
margin: 0,
|
||
color: 'var(--color-green)',
|
||
fontSize: '0.85rem',
|
||
fontWeight: 700,
|
||
letterSpacing: '0.12em',
|
||
textShadow: 'var(--text-glow-green)',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
SPACEWANDERER
|
||
</h1>
|
||
|
||
{!narrow && (
|
||
<span
|
||
style={{
|
||
color: 'var(--text-muted)',
|
||
fontSize: '0.72rem',
|
||
letterSpacing: '0.08em',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
// SHIP STATUS // CREW MANIFEST
|
||
</span>
|
||
)}
|
||
|
||
<div style={{ flex: 1 }} />
|
||
|
||
{!narrow && (
|
||
<Badge variant="ACTIVE">SHIP NOMINAL</Badge>
|
||
)}
|
||
|
||
<Badge variant="SCANNING">SECTOR SCAN</Badge>
|
||
|
||
{/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */}
|
||
<Button size="SM" onClick={() => setAdminOpen(true)}>ADMIN</Button>
|
||
|
||
<span
|
||
style={{
|
||
color: 'var(--color-green)',
|
||
fontSize: '0.65rem',
|
||
letterSpacing: '0.1em',
|
||
textShadow: 'var(--text-glow-green)',
|
||
flexShrink: 0,
|
||
opacity: tick % 2 === 0 ? 1 : 0.5,
|
||
transition: 'opacity 0.3s',
|
||
}}
|
||
>
|
||
{timeStr}
|
||
</span>
|
||
</header>
|
||
|
||
{adminOpen && <AdminPanel onClose={() => setAdminOpen(false)} />}
|
||
|
||
{/* ── BODY ── */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '1px',
|
||
background: 'var(--border)',
|
||
}}
|
||
>
|
||
{/* STAT CARDS — placeholder ship readouts */}
|
||
<div
|
||
style={{
|
||
background: 'var(--background)',
|
||
padding: '0.75rem 1rem',
|
||
display: 'grid',
|
||
gridTemplateColumns: narrow ? '1fr 1fr' : 'repeat(4, 1fr)',
|
||
gap: '0.75rem',
|
||
}}
|
||
>
|
||
<StatCard
|
||
label="HULL INTEGRITY"
|
||
value="87%"
|
||
sublabel="NOMINAL · NO BREACHES"
|
||
variant="ACTIVE"
|
||
/>
|
||
<StatCard
|
||
label="REACTOR OUTPUT"
|
||
value="94.2%"
|
||
sublabel="STABLE · IDLE CYCLE"
|
||
variant="ACTIVE"
|
||
/>
|
||
<StatCard
|
||
label="CREW ON BOARD"
|
||
value="3 / 6"
|
||
sublabel="AWAITING MANIFEST"
|
||
variant="DEFAULT"
|
||
/>
|
||
<StatCard
|
||
label="NEXT ENCOUNTER"
|
||
value="T-07:14"
|
||
sublabel="SECTOR 7G // DRIFT LANE"
|
||
variant="WARNING"
|
||
/>
|
||
</div>
|
||
|
||
{/* 3-COLUMN GRID */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
|
||
display: 'grid',
|
||
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
|
||
gap: '1px',
|
||
background: 'var(--border)',
|
||
}}
|
||
>
|
||
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS (mirrors the SHIP SYSTEMS tab) ── */}
|
||
<div
|
||
style={{
|
||
background: 'var(--background)',
|
||
padding: '1rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.875rem',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
<Panel notch="sm">
|
||
<PanelHeader>
|
||
<PanelTitle>SHIP SYSTEMS</PanelTitle>
|
||
<div style={{ marginLeft: 'auto' }}>
|
||
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
|
||
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
|
||
</Badge>
|
||
</div>
|
||
</PanelHeader>
|
||
|
||
{ship ? (
|
||
<StatusGrid
|
||
systems={ship.systems.map((s) => ({
|
||
name: s.type,
|
||
status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const),
|
||
detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined,
|
||
}))}
|
||
/>
|
||
) : (
|
||
<div style={{ padding: '0.5rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
|
||
<Panel notch="sm">
|
||
<PanelHeader>
|
||
<PanelTitle>VESSEL METRICS</PanelTitle>
|
||
</PanelHeader>
|
||
<PanelContent>
|
||
{ship ? (
|
||
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
|
||
// crew roster vs the display cap; SECTOR/TURN rows folded in from the old SHIP DATA
|
||
// placeholder (the rest of it duplicated these bars or had no game equivalent).
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
|
||
<Progress label="HULL INTEGRITY" value={Math.min(100, (ship.systems.find((s) => s.type === 'HULL')?.level ?? 0) * 20)} />
|
||
<Progress label="REACTOR OUTPUT" value={Math.min(100, (ship.systems.find((s) => s.type === 'REACTOR')?.level ?? 0) * 20)} />
|
||
<Progress label={`CREW ROSTER / ${CREW_CAPACITY}`} value={Math.round(((crewCount ?? 0) / CREW_CAPACITY) * 100)} />
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
{[
|
||
{ label: 'SECTOR', value: (system?.systemName ?? '—').toUpperCase() },
|
||
{ label: 'TURN', value: String(ship.turn) },
|
||
].map((item, i) => (
|
||
<div
|
||
key={item.label}
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '0.4rem 0',
|
||
borderBottom: i < 1 ? '1px solid var(--border)' : 'none',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.1em' }}>{item.label}</span>
|
||
<span style={{ fontSize: '0.78rem', color: 'var(--color-green)', textShadow: 'var(--text-glow-green)', letterSpacing: '0.04em', fontWeight: 600 }}>
|
||
{item.value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||
)}
|
||
</PanelContent>
|
||
</Panel>
|
||
</div>
|
||
|
||
{/* ── CENTER: GAME TEMPLATE TABS (visual only — no handlers, placeholder data) ── */}
|
||
<div
|
||
style={{
|
||
background: 'var(--background)',
|
||
padding: '1rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.875rem',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
|
||
the Panel root's default overflow:hidden would otherwise clip it */}
|
||
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||
<PanelHeader>
|
||
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
|
||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||
<Badge variant="ACTIVE">SYS NOMINAL</Badge>
|
||
</div>
|
||
</PanelHeader>
|
||
|
||
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
{/* Flex column at least pane-tall so a tab can fill it (#36). min-height, not height:
|
||
tall tabs grow the box to their content, which keeps the sticky tab bar's containing
|
||
block spanning the whole scroll range (a capped height releases the pin on deep
|
||
scrolls) and means items never overflow the box, so nothing gets shrink-squashed. */}
|
||
<Tabs
|
||
value={activeTab ?? 'world-map'}
|
||
onValueChange={(v) => {
|
||
setActiveTab(v)
|
||
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
|
||
}}
|
||
style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}
|
||
>
|
||
<TabsList
|
||
style={{
|
||
padding: '0 1rem',
|
||
background: 'var(--surface-raised)', // opaque — content scrolls under it (#31)
|
||
overflowX: 'auto',
|
||
position: 'sticky', // #31 — pinned in both modes: to the top of the OPERATIONS
|
||
top: 0, // scroll area (wide) and to the viewport when the body scrolls (narrow)
|
||
zIndex: 1,
|
||
}}
|
||
>
|
||
<TabsTrigger value="world-map">WORLD MAP</TabsTrigger>
|
||
<TabsTrigger value="local-system">LOCAL SYSTEM</TabsTrigger>
|
||
<TabsTrigger value="crew">CREW</TabsTrigger>
|
||
<TabsTrigger value="ship-systems">SHIP SYSTEMS</TabsTrigger>
|
||
<TabsTrigger value="skill-tree">SKILL TREE</TabsTrigger>
|
||
<TabsTrigger value="missions">MISSIONS</TabsTrigger>
|
||
<TabsTrigger value="event-log">EVENT LOG</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode:
|
||
flex column so the panel can fill it with pure flex sizing (a min-height-only
|
||
container breaks percentage resolution, so no height:'100%' anywhere in this chain). */}
|
||
<TabsContent
|
||
value="world-map"
|
||
style={{ margin: 0, padding: '1rem', ...(!narrow ? { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : {}) }}
|
||
>
|
||
<WorldMapPanel height={380} narrow={narrow} navAi={navAi} onJumped={refreshSystem} />
|
||
</TabsContent>
|
||
|
||
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
||
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||
<LocalSystemPanel system={system} onChanged={setSystem} />
|
||
</TabsContent>
|
||
|
||
{/* CREW TAB — live data from /api/crew */}
|
||
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||
<CrewPanel narrow={narrow} />
|
||
</TabsContent>
|
||
|
||
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
|
||
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
|
||
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} researchedSlugs={researchedSlugs} />
|
||
</TabsContent>
|
||
|
||
{/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */}
|
||
<TabsContent value="skill-tree" style={{ margin: 0, padding: '1rem' }}>
|
||
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||
</TabsContent>
|
||
|
||
{/* MISSIONS TAB — live data from /api/missions */}
|
||
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||
<MissionPanel />
|
||
</TabsContent>
|
||
|
||
{/* EVENT LOG TAB */}
|
||
<TabsContent value="event-log" style={{ margin: 0, padding: '1rem' }}>
|
||
<EventLogPanel />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</PanelContent>
|
||
</Panel>
|
||
</div>
|
||
|
||
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
|
||
<div
|
||
style={{
|
||
background: 'var(--background)',
|
||
padding: '1rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.875rem',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
<Panel notch="sm">
|
||
<PanelHeader>
|
||
<PanelTitle>CARGO HOLD // INVENTORY</PanelTitle>
|
||
{system && (
|
||
<div style={{ marginLeft: 'auto' }}>
|
||
<Badge variant={system.inventory.length > 0 ? 'ACTIVE' : 'OFFLINE'}>
|
||
{system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
</PanelHeader>
|
||
<PanelContent style={{ padding: 0 }}>
|
||
{!system ? (
|
||
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
|
||
</div>
|
||
) : system.inventory.length === 0 ? (
|
||
<div style={{ padding: '0.6rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>EMPTY — EXPLORE WORLDS TO FIND THINGS</span>
|
||
</div>
|
||
) : (
|
||
system.inventory.map((item) => (
|
||
<div key={`${item.kind}-${item.name}`} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem' }}>
|
||
<span style={{ fontSize: '0.62rem', color: 'var(--text-secondary)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||
{item.name} <span style={{ color: 'var(--text-muted)' }}>// {item.kind}</span>
|
||
</span>
|
||
<Badge variant="ACTIVE">×{item.qty}</Badge>
|
||
</div>
|
||
))
|
||
)}
|
||
</PanelContent>
|
||
</Panel>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── FOOTER ── */}
|
||
<footer
|
||
style={{
|
||
minHeight: '44px',
|
||
background: 'var(--surface)',
|
||
borderTop: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
flexWrap: 'wrap',
|
||
padding: '0.5rem 1.25rem',
|
||
gap: '0.5rem',
|
||
position: 'sticky',
|
||
bottom: 0,
|
||
zIndex: 50,
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||
INTERFACE BASE: SCIFICN MISSION CONTROL
|
||
</span>
|
||
<span style={{ color: 'var(--border)' }}>│</span>
|
||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||
CONTENT: PLACEHOLDER — NO GAME LOGIC YET
|
||
</span>
|
||
<span style={{ color: 'var(--border)' }}>│</span>
|
||
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||
SINGLE PLAYER // NO AUTH
|
||
</span>
|
||
|
||
<div style={{ flex: 1 }} />
|
||
|
||
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
||
</footer>
|
||
|
||
{/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it
|
||
stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes
|
||
the snapshots this shell owns) */}
|
||
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
|
||
</div>
|
||
)
|
||
}
|