Self-fetching MissionPanel (world-map pattern — refetches on each tab open, accept responses applied directly). One card per contract: type title + state badge, CONTRACT // #id line, objective, reward line, EXPLORE progress line. ACCEPT enabled only when AVAILABLE and nothing else is ACTIVE. Placeholder encounter content removed.
567 lines
21 KiB
TypeScript
567 lines
21 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 { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||
import { CrewPanel } from '@/components/crew-panel'
|
||
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
|
||
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data
|
||
// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab
|
||
// keeps placeholder content until its own issue lands. 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
|
||
}
|
||
|
||
function now(): string {
|
||
return new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||
}
|
||
|
||
// ── 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' },
|
||
]
|
||
|
||
const logLines: TerminalLine[] = [
|
||
{ type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() },
|
||
{ type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() },
|
||
{ type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() },
|
||
{ type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() },
|
||
{ type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() },
|
||
{ type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() },
|
||
]
|
||
|
||
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' }}>
|
||
<Terminal
|
||
lines={logLines}
|
||
title="SHIP LOG // PLACEHOLDER FEED"
|
||
height="22rem"
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</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>
|
||
)
|
||
}
|