CI / build + unit + e2e (pull_request) Successful in 2m47s
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.
534 lines
21 KiB
TypeScript
534 lines
21 KiB
TypeScript
'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>
|
||
)
|
||
}
|