Files
SpaceWanderer/components/mission-control.tsx
T

554 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { 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)
useEffect(() => {
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
}
}, [])
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>
</div>
)
}