feat(ui): MISSIONS tab renders the live mission board
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.
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { StatCard } from '@/components/ui/stat-card'
|
import { StatCard } from '@/components/ui/stat-card'
|
||||||
@@ -11,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
import { Terminal, type TerminalLine } from '@/components/ui/terminal'
|
||||||
import { CrewPanel } from '@/components/crew-panel'
|
import { CrewPanel } from '@/components/crew-panel'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
|
import { MissionPanel } from '@/components/missions'
|
||||||
import { ShipSystemsPanel } from '@/components/ship-systems'
|
import { ShipSystemsPanel } from '@/components/ship-systems'
|
||||||
import { SkillTreePanel } from '@/components/skill-tree'
|
import { SkillTreePanel } from '@/components/skill-tree'
|
||||||
import { WorldMapPanel } from '@/components/world-map'
|
import { WorldMapPanel } from '@/components/world-map'
|
||||||
@@ -21,9 +21,9 @@ import type { TechDto } from '@/lib/game/tech'
|
|||||||
|
|
||||||
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
|
// 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
|
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The
|
||||||
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS and SKILL TREE tabs render live data (/api/world,
|
// WORLD MAP, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data
|
||||||
// /api/system, /api/crew, /api/ship, /api/tech); the other tabs keep placeholder content until
|
// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab
|
||||||
// their own issues land. Single player, no auth, by design.
|
// keeps placeholder content until its own issue lands. Single player, no auth, by design.
|
||||||
|
|
||||||
function useNarrow(threshold = 900): boolean {
|
function useNarrow(threshold = 900): boolean {
|
||||||
const [narrow, setNarrow] = useState(
|
const [narrow, setNarrow] = useState(
|
||||||
@@ -386,62 +386,9 @@ export function MissionControl() {
|
|||||||
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
<SkillTreePanel techs={techs} ship={ship} narrow={narrow} onResearched={setTechs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* MISSIONS TAB */}
|
{/* MISSIONS TAB — live data from /api/missions */}
|
||||||
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="missions" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<div
|
<MissionPanel />
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
|
||||||
>
|
|
||||||
<Alert variant="STATUS">
|
|
||||||
<AlertTitle>ENCOUNTER #E-102 — DERELICT FREIGHTER</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Placeholder briefing. Real encounter resolution lands with the turn engine.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: narrow ? '1fr' : '1fr 1fr',
|
|
||||||
gap: '0.625rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: 'DESTINATION', value: 'SECTOR 7G // DRIFT LANE' },
|
|
||||||
{ label: 'ETA', value: 'T-07:14 (PLACEHOLDER)' },
|
|
||||||
{ label: 'THREAT LEVEL', value: 'LOW — UNMANNED' },
|
|
||||||
{ label: 'CARGO MANIFEST', value: 'UNKNOWN' },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.55rem',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
letterSpacing: '0.12em',
|
|
||||||
marginBottom: '0.35rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* EVENT LOG TAB */}
|
{/* EVENT LOG TAB */}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import type { MissionDto } from '@/lib/game/missions'
|
||||||
|
|
||||||
|
// MISSIONS tab — live data. Fetches /api/missions on mount (Radix Tabs unmounts inactive tabs, so it
|
||||||
|
// refetches whenever the tab is opened); accept responses are applied directly (no polling). ACCEPT is
|
||||||
|
// enabled only when the contract is AVAILABLE and no other mission is ACTIVE — mirroring what
|
||||||
|
// POST /api/missions/{id}/accept enforces.
|
||||||
|
|
||||||
|
export function MissionPanel() {
|
||||||
|
const [missions, setMissions] = useState<MissionDto[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null) // one action at a time — turn-based game
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/missions')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as MissionDto[]
|
||||||
|
})
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setMissions(m)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const accept = useCallback(async (mission: MissionDto) => {
|
||||||
|
setBusyId(mission.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/missions/${mission.id}/accept`, { method: 'POST' })
|
||||||
|
const data = (await res.json()) as MissionDto[] & { error?: string }
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
setMissions(data) // response is the fresh board — apply directly
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!missions && !error) {
|
||||||
|
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' }}>LOADING MISSIONS…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = (missions ?? []).find((m) => m.state === 'ACTIVE')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{error && (
|
||||||
|
<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' }}>{error.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}>
|
||||||
|
{(missions ?? []).map((mission) => (
|
||||||
|
<MissionCard key={mission.id} mission={mission} busy={busyId === mission.id} blockedByActive={!!active && active.id !== mission.id} onAccept={accept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissionCard({
|
||||||
|
mission,
|
||||||
|
busy,
|
||||||
|
blockedByActive,
|
||||||
|
onAccept,
|
||||||
|
}: {
|
||||||
|
mission: MissionDto
|
||||||
|
busy: boolean
|
||||||
|
blockedByActive: boolean
|
||||||
|
onAccept: (mission: MissionDto) => void
|
||||||
|
}) {
|
||||||
|
const acceptDisabled = busy || mission.state !== 'AVAILABLE' || blockedByActive
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<CardTitle>{mission.type}</CardTitle>
|
||||||
|
<Badge variant={mission.state === 'ACTIVE' ? 'ACTIVE' : mission.state === 'AVAILABLE' ? 'SCANNING' : 'OFFLINE'} className="ml-auto">
|
||||||
|
{mission.state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>CONTRACT // #{mission.id}</p>
|
||||||
|
<p style={{ fontSize: '0.65rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>{mission.description.toUpperCase()}</p>
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.08em' }}>
|
||||||
|
REWARD {mission.rewardQty}× {mission.rewardKind === 'resource' ? 'RESOURCES' : 'PARTS'}
|
||||||
|
</p>
|
||||||
|
{mission.type === 'EXPLORE' && (
|
||||||
|
<p style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||||
|
PROGRESS // {mission.progress}/{mission.progressTarget}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button variant="OUTLINE" size="SM" disabled={acceptDisabled} onClick={() => onAccept(mission)}>
|
||||||
|
ACCEPT
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user