ui: LOCAL SYSTEM tab renders real bodies with explore buttons + cargo hold (real data from /api/system)

This commit is contained in:
2026-09-28 22:13:19 +00:00
parent 8153007668
commit 82d41b87be
2 changed files with 192 additions and 96 deletions
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { ExploreResult, SystemState } from '@/lib/game/save'
import type { FoundItem } from '@/lib/game/world'
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
// EXPLORE button per unexplored row. Fetches /api/system on mount; explore responses are
// applied directly (no polling). Turn-based: one scan costs a turn, each body once.
export function LocalSystemPanel() {
const [state, setState] = useState<SystemState | null>(null)
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [exploringId, setExploringId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
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) setState(s)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [])
const explore = useCallback(async (planetId: number) => {
setExploringId(planetId)
try {
const res = await fetch('/api/explore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ planetId }),
})
const body = (await res.json()) as ExploreResult & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setState(body) // fresh state straight from the response — no refetch needed
setLastOutcome(body.outcome)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setExploringId(null)
}
}, [])
if (error && !state) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
SYSTEM SCAN OFFLINE // {error.toUpperCase()}
</span>
</div>
)
}
if (!state) {
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>SCANNING SYSTEM…</span>
</div>
)
}
const badgeFor = (explored: boolean, hazard: boolean) =>
explored ? { label: 'SCANNED', variant: 'ACTIVE' as const } : hazard ? { label: 'HAZARD', variant: 'WARNING' as const } : { label: 'UNEXPLORED', variant: 'SCANNING' as const }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
EXPLORABLE WORLDS // {state.systemName.toUpperCase()} — TURN {state.turn}
</div>
{state.planets.map((row, i) => {
const depth = row.parent !== null ? 1 : 0
const badge = badgeFor(row.explored, row.hazard)
return (
<div
key={row.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
padding: depth ? '0.6rem 0.75rem 0.6rem 1.75rem' : '0.6rem 0.75rem',
borderBottom: i < state.planets.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
style={{
fontSize: '0.68rem',
color: depth ? 'var(--text-muted)' : 'var(--text-secondary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{row.name}
</div>
<div style={{ fontSize: '0.57rem', color: 'var(--text-muted)', letterSpacing: '0.06em', marginTop: '2px' }}>
{depth ? `MOON OF ${state.planets.find((p) => p.id === row.parent)?.name ?? '?'} // ${row.type}` : row.type}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Badge variant={badge.variant}>{badge.label}</Badge>
{!row.explored && (
<Button
variant="OUTLINE"
size="SM"
disabled={exploringId !== null}
onClick={() => void explore(row.id)}
>
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // EXPLORE`}
</Button>
)}
</div>
</div>
)
})}
</div>
{lastOutcome !== null && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
SCAN RESULT //{' '}
{lastOutcome.length > 0 ? lastOutcome.map((i) => `${i.qty}× ${i.name.toUpperCase()}`).join(', ') : 'NOTHING FOUND'}
</span>
</div>
)}
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
CARGO HOLD // INVENTORY
</div>
{state.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>
) : (
state.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>
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>SCAN FAILED // {error.toUpperCase()}</span>
</div>
)}
</div>
)
}
+5 -96
View File
@@ -11,12 +11,13 @@ 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 { Terminal, type TerminalLine } from '@/components/ui/terminal'
import { LocalSystemPanel } from '@/components/local-system'
import { WorldMapPanel } from '@/components/world-map'
// 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 tab renders live data from /api/world; the other tabs keep placeholder content
// until their own issues land. Single player, no auth, by design.
// WORLD MAP and LOCAL SYSTEM tabs render live data (/api/world, /api/system); 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(
@@ -44,20 +45,6 @@ const shipSystems: SystemEntry[] = [
{ name: 'COMMS', status: 'ACTIVE' },
]
// LOCAL SYSTEM — explorable worlds of the current system (regenerated per save after each jump, later).
const explorablePlanets = [
{
name: 'AURELLE', type: 'TERRESTRIAL // OCEAN CONTINENTS', state: 'UNEXPLORED' as const,
moons: [{ name: 'PALEMOON', type: 'ICE MOON // FROST PLAINS', state: 'UNEXPLORED' as const }],
},
{
name: 'BRIMSTONE', type: 'GAS GIANT // CLOUD DECKS', state: 'SCANNED' as const,
moons: [{ name: 'ASHFALL', type: 'VOLCANIC MOON // SULFUR VEINS', state: 'UNEXPLORED' as const }],
},
{ name: 'CINDERROCK', type: 'ROCKY // IRON RICH', state: 'UNEXPLORED' as const, moons: [] },
{ name: 'DRIFTBAND', type: 'ASTEROID FIELD // HAZARD', state: 'HAZARD' as const, moons: [] },
]
const crew = [
{ name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' },
{ name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' },
@@ -339,87 +326,9 @@ export function MissionControl() {
<WorldMapPanel height={380} />
</TabsContent>
{/* LOCAL SYSTEM TAB */}
{/* LOCAL SYSTEM TAB — live data from /api/system */}
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
<div
style={{
border: '1px solid var(--border)',
background: 'var(--surface)',
overflow: 'hidden',
}}
>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
EXPLORABLE WORLDS // FIND THINGS HERE (PLACEHOLDER)
</div>
{explorablePlanets.flatMap((planet) => [
{ name: planet.name, type: planet.type, state: planet.state, depth: 0 },
...planet.moons.map((moon) => ({
name: moon.name,
type: moon.type,
state: moon.state,
depth: 1,
})),
]).map((row, i, rows) => (
<div
key={row.name}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
padding: row.depth
? '0.6rem 0.75rem 0.6rem 1.75rem'
: '0.6rem 0.75rem',
borderBottom:
i < rows.length - 1 ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
style={{
fontSize: '0.68rem',
color: row.depth ? 'var(--text-muted)' : 'var(--text-secondary)',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}
>
{row.name}
</div>
<div
style={{
fontSize: '0.57rem',
color: 'var(--text-muted)',
letterSpacing: '0.06em',
marginTop: '2px',
}}
>
{row.type}
</div>
</div>
<Badge
variant={
row.state === 'UNEXPLORED'
? 'SCANNING'
: row.state === 'HAZARD'
? 'WARNING'
: 'ACTIVE'
}
>
{row.state}
</Badge>
</div>
))}
</div>
<LocalSystemPanel />
</TabsContent>
{/* CREW TAB */}