Files
SpaceWanderer/components/mission-control.tsx
T
kamilkrzywda 1710e50b63
CI / build + unit + e2e (pull_request) Successful in 2m47s
fix(ui): pin the OPERATIONS tab bar while panel content scrolls (#31)
The TabsList is now position:sticky top:0 with a z-index above the panel
content. In wide mode it pins to the top of the OPERATIONS scroll area
(#21's center-pane scroller); in narrow mode, where the body scrolls and
there is no inner scroll container, it pins to the viewport. The existing
opaque background keeps scrolled content from showing through.
2026-09-29 10:22:14 +00:00

534 lines
21 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 { Badge } from '@/components/ui/badge'
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 { CrewDto, 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)
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 CrewDto[]
})
.then((c) => {
if (!cancelled) setCrewCount(c.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))
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>
<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>
{/* ── 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' }}>
<Tabs
value={activeTab ?? 'world-map'}
onValueChange={(v) => {
setActiveTab(v)
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
}}
>
<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 */}
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
<WorldMapPanel height={380} 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>
)
}