feat(ui): NEXT TURN button (bottom-right) + turn counter; empty queue is a free skip (#44)
This commit is contained in:
@@ -13,6 +13,7 @@ import { CrewPanel } from '@/components/crew-panel'
|
|||||||
import { EventLogPanel } from '@/components/event-log'
|
import { EventLogPanel } from '@/components/event-log'
|
||||||
import { LocalSystemPanel } from '@/components/local-system'
|
import { LocalSystemPanel } from '@/components/local-system'
|
||||||
import { MissionPanel } from '@/components/missions'
|
import { MissionPanel } from '@/components/missions'
|
||||||
|
import { NextTurnButton } from '@/components/next-turn-button'
|
||||||
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'
|
||||||
@@ -62,7 +63,12 @@ export function MissionControl() {
|
|||||||
// #38 — admin DB CRUD overlay, opened from the header.
|
// #38 — admin DB CRUD overlay, opened from the header.
|
||||||
const [adminOpen, setAdminOpen] = useState(false)
|
const [adminOpen, setAdminOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
// #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
|
let cancelled = false
|
||||||
fetch('/api/ship')
|
fetch('/api/ship')
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
@@ -105,6 +111,9 @@ export function MissionControl() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates.
|
||||||
|
useEffect(() => refreshAll(), [refreshAll])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
const id = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
@@ -548,6 +557,11 @@ export function MissionControl() {
|
|||||||
|
|
||||||
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
<Badge variant="ACTIVE">SYS ONLINE</Badge>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* #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) */}
|
||||||
|
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from
|
||||||
|
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell
|
||||||
|
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an
|
||||||
|
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an
|
||||||
|
// in-flight request, which the handler ignores. `disabled` is never set, so the cva
|
||||||
|
// disabled:opacity-40 styling never applies either.
|
||||||
|
|
||||||
|
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
|
||||||
|
const [inFlight, setInFlight] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (inFlight) return // the only guard — a transient, never a game-state disable
|
||||||
|
setInFlight(true)
|
||||||
|
setError(null)
|
||||||
|
fetch('/api/turn', { method: 'POST' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
onTicked() // the same app-wide snapshot refresh the shell uses after every other action
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setInFlight(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: '1rem',
|
||||||
|
bottom: '1rem',
|
||||||
|
zIndex: 60, // above the header/footer's 50
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
|
||||||
|
TURN {turn ?? '—'}
|
||||||
|
</span>
|
||||||
|
<Button variant="EXEC" size="SM" onClick={tick}>
|
||||||
|
NEXT TURN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user