Files
SpaceWanderer/components/crew-panel.tsx
T
2026-10-04 03:22:51 +00:00

118 lines
4.6 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { CrewDto, CrewState } from '@/lib/game/save'
// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
// Spec §8: the three combat traits that carry no duration effect yet — their effect lands
// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY).
// The label is additive — the original name always appears in the text unchanged.
const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT'])
const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait)
interface CrewPanelProps {
narrow?: boolean
}
export function CrewPanel({ narrow }: CrewPanelProps) {
const [roster, setRoster] = useState<CrewDto[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
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((r) => {
if (!cancelled) setRoster(r.crew)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [])
if (error && !roster) {
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' }}>
CREW MANIFEST OFFLINE // {error.toUpperCase()}
</span>
</div>
)
}
if (!roster) {
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 CREW MANIFEST…</span>
</div>
)
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
gap: '0.75rem',
}}
>
{roster.map((member) => (
<Card key={member.id}>
<CardHeader>
<div style={{ display: 'flex', alignItems: 'center' }}>
<CardTitle>{member.name}</CardTitle>
<Badge variant="ACTIVE" className="ml-auto">
ACTIVE
</Badge>
</div>
<p
style={{
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{member.role}
</p>
{/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
<p style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
</p>
{/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
{member.title !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>
{member.title}
</p>
)}
</CardHeader>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-green)' }}>+ </span>
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitGood)}</span>
</div>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-red)' }}>- </span>
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitBad)}</span>
</div>
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
ORIGIN // {member.origin.toUpperCase()}
</p>
</CardContent>
</Card>
))}
</div>
)
}