ui: real SHIP SYSTEMS tab (upgrade/expand/build buttons) + left column mirrors live ship state

This commit is contained in:
2026-09-28 23:43:03 +00:00
parent fce27ff6ca
commit de0cb45b09
3 changed files with 191 additions and 58 deletions
+66 -58
View File
@@ -13,12 +13,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
import { CrewPanel } from '@/components/crew-panel'
import { LocalSystemPanel } from '@/components/local-system'
import { ShipSystemsPanel } from '@/components/ship-systems'
import { WorldMapPanel } from '@/components/world-map'
import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship'
// 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
// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew);
// the other tabs keep placeholder content until their own issues land. Single player, no auth, by design.
// WORLD MAP, LOCAL SYSTEM, CREW and SHIP SYSTEMS tabs render live data (/api/world, /api/system,
// /api/crew, /api/ship); the other tabs keep placeholder content until their own issues land.
// Single player, no auth, by design.
function useNarrow(threshold = 900): boolean {
const [narrow, setNarrow] = useState(
@@ -38,22 +43,6 @@ function now(): string {
// ── Placeholder content (no game logic — real values land with the turn engine) ──
const shipSystems: SystemEntry[] = [
{ name: 'REACTOR', status: 'ACTIVE', detail: '94.2%' },
{ name: 'LIFE SUPPORT', status: 'ACTIVE' },
{ name: 'NAV COMPUTER', status: 'SCANNING' },
{ name: 'WEAPONS', status: 'OFFLINE' },
{ name: 'COMMS', status: 'ACTIVE' },
]
const shipSystemCards = [
{ name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'NAV COMPUTER', level: 'LV 3', status: 'SCANNING' as const, actions: ['EXPAND', 'UPGRADE'] },
{ name: 'WEAPONS', level: 'OFFLINE', status: 'OFFLINE' as const, actions: ['BUILD NEW'] },
{ name: 'COMMS ARRAY', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] },
]
const skillCards = [
{ name: 'ADVANCED FUSION CORE', unlocks: 'REACTOR LV 4 + OVERCLOCK', state: 'AVAILABLE' as const },
{ name: 'HULL PLATING SCHEMATICS', unlocks: 'BUILD ARMOR BAY MODULES', state: 'AVAILABLE' as const },
@@ -89,6 +78,36 @@ const logLines: TerminalLine[] = [
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.
const [ship, setShip] = useState<ShipState | null>(null)
const [crewCount, setCrewCount] = useState<number | 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/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)
@@ -257,11 +276,25 @@ export function MissionControl() {
<PanelHeader>
<PanelTitle>SHIP SYSTEMS</PanelTitle>
<div style={{ marginLeft: 'auto' }}>
<Badge variant="ACTIVE">4 ONLINE</Badge>
<Badge variant={ship && ship.systems.some((s) => s.built) ? 'ACTIVE' : 'OFFLINE'}>
{ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
</Badge>
</div>
</PanelHeader>
<StatusGrid systems={shipSystems} />
{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">
@@ -269,11 +302,17 @@ export function MissionControl() {
<PanelTitle>VESSEL METRICS</PanelTitle>
</PanelHeader>
<PanelContent>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
<Progress label="HULL INTEGRITY" value={87} />
<Progress label="REACTOR OUTPUT" value={94} />
<Progress label="CREW ROSTER / 6" value={50} />
</div>
{ship ? (
// Bars driven by real values: hull/reactor from system levels (level × 20, capped),
// crew roster vs the display cap.
<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>
) : (
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING…</span>
)}
</PanelContent>
</Panel>
</div>
@@ -330,40 +369,9 @@ export function MissionControl() {
<CrewPanel narrow={narrow} />
</TabsContent>
{/* SHIP SYSTEMS TAB */}
{/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */}
<TabsContent value="ship-systems" style={{ margin: 0, padding: '1rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{shipSystemCards.map((system) => (
<Card key={system.name}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{system.name}</CardTitle>
<Badge variant={system.status} className="ml-auto">
{system.level}
</Badge>
</div>
</CardHeader>
<CardContent>
<p style={{ fontSize: '0.65rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
MODULE BAY // PLACEHOLDER READOUT
</p>
</CardContent>
<CardFooter className="gap-2">
{system.actions.map((action) => (
<Button key={action} variant="OUTLINE" size="SM">
{action}
</Button>
))}
</CardFooter>
</Card>
))}
</div>
<ShipSystemsPanel ship={ship} narrow={narrow} onChanged={setShip} />
</TabsContent>
{/* SKILL TREE TAB */}