Self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab open). One terminal line per event: T<turn> // <MESSAGE>, newest first, with the event's local time as the timestamp; new_game lines render as system lines. Placeholder feed + now() removed from mission-control.
548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import { 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 } 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.
|
||
|
||
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
|
||
}
|
||
|
||
// ── 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.
|
||
const [ship, setShip] = useState<ShipState | null>(null)
|
||
const [techs, setTechs] = useState<TechDto[] | 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/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)
|
||
}, [])
|
||
|
||
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',
|
||
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,
|
||
}}
|
||
>
|
||
◄ SHIP
|
||
</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,
|
||
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,
|
||
display: 'grid',
|
||
gridTemplateColumns: narrow ? '1fr' : '260px 1fr 240px',
|
||
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.
|
||
<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>
|
||
|
||
{/* ── 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',
|
||
}}
|
||
>
|
||
<Panel notch="md" style={{ flex: 1 }}>
|
||
<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 }}>
|
||
<Tabs defaultValue="world-map">
|
||
<TabsList
|
||
style={{
|
||
padding: '0 1rem',
|
||
background: 'var(--surface-raised)',
|
||
overflowX: 'auto',
|
||
}}
|
||
>
|
||
<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} />
|
||
</TabsContent>
|
||
|
||
{/* LOCAL SYSTEM TAB — live data from /api/system */}
|
||
<TabsContent value="local-system" style={{ margin: 0, padding: '1rem' }}>
|
||
<LocalSystemPanel />
|
||
</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: UPCOMING ENCOUNTERS + SHIP DATA ── */}
|
||
<div
|
||
style={{
|
||
background: 'var(--background)',
|
||
padding: '1rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.875rem',
|
||
overflowY: 'auto',
|
||
}}
|
||
>
|
||
<Panel notch="sm">
|
||
<PanelHeader>
|
||
<PanelTitle>UPCOMING ENCOUNTERS</PanelTitle>
|
||
<div style={{ marginLeft: 'auto' }}>
|
||
<Badge variant="SCANNING">{encounters.length} QUEUED</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}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|