'use client' import { useCallback, useEffect, useState } from 'react' import { AdminPanel } from '@/components/admin-panel' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' 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 { NextTurnButton } from '@/components/next-turn-button' import { ShipSystemsPanel } from '@/components/ship-systems' import { SkillTreePanel } from '@/components/skill-tree' import { TaskStrip } from '@/components/task-strip' import { WorldMapPanel } from '@/components/world-map' import { CREW_CAPACITY } from '@/lib/game/ship-costs' import type { CrewState, SystemState } from '@/lib/game/save' import type { ShipState } from '@/lib/game/ship' import type { TechDto } from '@/lib/game/tech' import pkg from '@/package.json' // 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). 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( () => 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 } // Must match the TabsTrigger values in the render below — used to validate the tab persisted for // #27 (an unknown stored value falls back to the default). const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log'] const ACTIVE_TAB_KEY = 'sw-active-tab' export function MissionControl() { const narrow = useNarrow() const [tick, setTick] = useState(0) // 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(null) const [system, setSystem] = useState(null) const [techs, setTechs] = useState(null) const [crewCount, setCrewCount] = useState(null) // #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until // then so SSR and the first client render agree on the default tab (no hydration mismatch). const [activeTab, setActiveTab] = useState(null) // #38 — admin DB CRUD overlay, opened from the header. const [adminOpen, setAdminOpen] = useState(false) // #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can // re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory // on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research // completion) — a strict superset of refreshSystem, which stays for the jump callback. Same // failure policy as the mount fetch — keep the old state on error. const refreshAll = useCallback(() => { 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/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}`) 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 CrewState // { crew, tasks } — the endpoint wraps the roster (JC-7) }) .then((c) => { if (!cancelled) setCrewCount(c.crew.length) }) .catch(() => {}) return () => { cancelled = true } }, []) // The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates. useEffect(() => refreshAll(), [refreshAll]) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(id) }, []) // #27 — restore the persisted tab once on mount (validated against TAB_IDS). useEffect(() => { const stored = window.localStorage.getItem(ACTIVE_TAB_KEY) if (stored && TAB_IDS.includes(stored)) setActiveTab(stored) }, []) // #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this // the lifted system state stays on the previous system until a page refresh. Same failure // policy as the mount fetch — keep the old state on error. const refreshSystem = useCallback(() => { fetch('/api/system') .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as SystemState }) .then((s) => setSystem(s)) .catch(() => {}) }, []) 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)) const navAi = researchedSlugs.has('NAV_AI') // #35 — two-hop systems become jumpable chart fields return (
{/* ── HEADER ── */}
v{pkg.version} │

SPACEWANDERER

{!narrow && ( // SHIP STATUS // CREW MANIFEST )}
{!narrow && ( SHIP NOMINAL )} SECTOR SCAN {/* #38 — admin DB CRUD, no auth by design (see components/admin-panel.tsx) */} {timeStr}
{adminOpen && setAdminOpen(false)} />} {/* ── BODY ── */}
{/* STAT CARDS — placeholder ship readouts */}
{/* 3-COLUMN GRID */}
{/* ── LEFT: SHIP SYSTEMS + VESSEL METRICS (mirrors the SHIP SYSTEMS tab) ── */}
SHIP SYSTEMS
s.built) ? 'ACTIVE' : 'OFFLINE'}> {ship ? `${ship.systems.filter((s) => s.built).length} ONLINE` : '—'}
{ship ? ( ({ name: s.type, status: s.built ? ('ACTIVE' as const) : ('OFFLINE' as const), detail: s.built ? `LV ${s.level} · CAP ${s.capacity}` : undefined, }))} /> ) : (
LOADING…
)}
VESSEL METRICS {ship ? ( // Bars driven by real values: hull/reactor from system levels (level × 20, capped), // 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).
s.type === 'HULL')?.level ?? 0) * 20)} /> s.type === 'REACTOR')?.level ?? 0) * 20)} />
{[ { label: 'SECTOR', value: (system?.systemName ?? '—').toUpperCase() }, { label: 'TURN', value: String(ship.turn) }, ].map((item, i) => (
{item.label} {item.value}
))}
) : ( LOADING… )}
{/* ── CENTER: GAME TEMPLATE TABS (visual only — no handlers, placeholder data) ── */}
{/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) — the Panel root's default overflow:hidden would otherwise clip it */} OPERATIONS // SHIP COMMAND
SYS NOMINAL
{/* Flex column at least pane-tall so a tab can fill it (#36). min-height, not height: tall tabs grow the box to their content, which keeps the sticky tab bar's containing block spanning the whole scroll range (a capped height releases the pin on deep scrolls) and means items never overflow the box, so nothing gets shrink-squashed. */} { setActiveTab(v) window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes }} style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }} > WORLD MAP LOCAL SYSTEM CREW SHIP SYSTEMS SKILL TREE MISSIONS EVENT LOG {/* WORLD MAP TAB — live data from /api/world; fills the whole pane (#36). Wide mode: flex column so the panel can fill it with pure flex sizing (a min-height-only container breaks percentage resolution, so no height:'100%' anywhere in this chain). */} {/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */} {/* CREW TAB — live data from /api/crew */} {/* SHIP SYSTEMS TAB — live data from /api/ship (state owned by MissionControl) */} {/* SKILL TREE TAB — live data from /api/tech (state owned by MissionControl) */} {/* MISSIONS TAB — live data from /api/missions */} {/* EVENT LOG TAB */}
{/* ── RIGHT: CARGO HOLD (persistent — inventory lifted from /api/system) ── */}
CARGO HOLD // INVENTORY {system && (
0 ? 'ACTIVE' : 'OFFLINE'}> {system.inventory.reduce((n, i) => n + i.qty, 0)} UNITS
)}
{!system ? (
LOADING…
) : system.inventory.length === 0 ? (
EMPTY — EXPLORE WORLDS TO FIND THINGS
) : ( system.inventory.map((item) => (
{item.name} // {item.kind} ×{item.qty}
)) )}
{/* ── FOOTER ── */}
INTERFACE BASE: SCIFICN MISSION CONTROL │ CONTENT: PLACEHOLDER — NO GAME LOGIC YET │ SINGLE PLAYER // NO AUTH
SYS ONLINE
{/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes the snapshots this shell owns) */} {/* #44 — the active-tasks strip: fixed bottom-left (same mount convention as the button — outside the tab grid so it stays visible on every tab). Data: the shell's system snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on every shell refresh with no fetch of its own. */}
) }