feat(ui): layout pass — CARGO HOLD to right column, wider panes, drop placeholders

CARGO HOLD // INVENTORY moves out of the LOCAL SYSTEM tab into a persistent
right-column panel; UPCOMING ENCOUNTERS removed; SHIP DATA folded into VESSEL
METRICS (SECTOR + TURN kept with real values, filler dropped). Grid goes
260px/1fr/240px -> 320px/1fr/360px. Cargo state uses the lifted-state pattern:
MissionControl owns /api/system and LocalSystemPanel reports each scan's
response up via onChanged (ShipSystemsPanel style) — no polling.
This commit is contained in:
2026-09-29 06:48:20 +00:00
parent fb11a8ef81
commit 85627201f8
2 changed files with 108 additions and 216 deletions
+71 -130
View File
@@ -15,14 +15,15 @@ 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 } from '@/lib/game/save'
import type { CrewDto, SystemState } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship'
import type { TechDto } from '@/lib/game/tech'
// 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). The
// All seven tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech,
// /api/missions, /api/events). Single player, no auth, by design.
// 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(
@@ -36,31 +37,15 @@ function useNarrow(threshold = 900): boolean {
return narrow
}
// ── Placeholder content (no game logic — real values land with the turn engine) ──
const encounters = [
{ name: 'DERELICT FREIGHTER', when: 'T-07:14', id: '#E-102' },
{ name: 'PIRATE SKIRMISH', when: 'T-22:40', id: '#E-103' },
{ name: 'ANOMALY FIELD', when: 'T-48:12', id: '#E-104' },
]
const shipData = [
{ label: 'TOTAL MODULES', value: '34' },
{ label: 'HULL INTEGRITY', value: '87%' },
{ label: 'REACTOR OUTPUT', value: '94.2%' },
{ label: 'CREW ACTIVE', value: '3 / 6' },
{ label: 'SECTOR', value: '7G // DRIFT LANE' },
{ label: 'DRIFT VELOCITY', value: '0.4 KM/S' },
{ label: 'TURN', value: '12' },
]
export function MissionControl() {
const narrow = useNarrow()
const [tick, setTick] = useState(0)
// Ship state is owned here (not in the tab panel) so the left column mirrors ship actions
// immediately; the SHIP SYSTEMS tab applies each action's response via setShip. Crew count
// feeds the roster bar — refetched on page load, like the other panels.
// 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)
@@ -75,6 +60,15 @@ export function MissionControl() {
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}`)
@@ -249,7 +243,7 @@ export function MissionControl() {
style={{
flex: 1,
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
gap: '1px',
background: 'var(--border)',
}}
@@ -297,11 +291,34 @@ export function MissionControl() {
<PanelContent>
{ship ? (
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
// crew roster vs the display cap.
// 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>
@@ -352,9 +369,9 @@ export function MissionControl() {
<WorldMapPanel height={380} />
</TabsContent>
{/* LOCAL SYSTEM TAB — live data from /api/system */}
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
<LocalSystemPanel />
<LocalSystemPanel system={system} onChanged={setSystem} />
</TabsContent>
{/* CREW TAB — live data from /api/crew */}
@@ -386,7 +403,7 @@ export function MissionControl() {
</Panel>
</div>
{/* ── RIGHT: UPCOMING ENCOUNTERS + SHIP DATA ── */}
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
<div
style={{
background: 'var(--background)',
@@ -399,110 +416,34 @@ export function MissionControl() {
>
<Panel notch="sm">
<PanelHeader>
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
<div style={{ marginLeft: 'auto' }}>
<Badge variant="SCANNING">{encounters.length} QUEUED</Badge>
</div>
<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 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{encounters.map((enc, i) => {
const isNext = i === 0
const isLast = i === encounters.length - 1
return (
<div
key={enc.id}
style={{
padding: '0.6rem 0.75rem',
borderBottom: isLast ? 'none' : '1px solid var(--border)',
background: isNext ? 'var(--surface-raised)' : 'transparent',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '0.5rem',
}}
>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div
style={{
fontSize: '0.68rem',
color: isNext ? 'var(--text-secondary)' : 'var(--text-muted)',
letterSpacing: '0.04em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textTransform: 'uppercase',
}}
>
{enc.name}
</div>
<div
style={{
fontSize: '0.57rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
marginTop: '2px',
}}
>
{enc.when} // {enc.id}
</div>
</div>
<Badge variant={isNext ? 'WARNING' : 'SCANNING'}>
{isNext ? 'NEXT' : 'SCHED'}
</Badge>
</div>
</div>
)
})}
</div>
</PanelContent>
</Panel>
<Panel notch="sm">
<PanelHeader>
<PanelTitle>SHIP DATA</PanelTitle>
</PanelHeader>
<PanelContent>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{shipData.map((item, i) => (
<div
key={item.label}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.4rem 0',
borderBottom:
i < shipData.length - 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}
{!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>
))}
</div>
))
)}
</PanelContent>
</Panel>
</div>