deadhead-space-freight-tycoon/apps/web/src/screens/Fleet.tsx

164 lines
5.6 KiB
TypeScript

import type { Dispatch, Ship } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim";
import { useState } from "react";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
import { locationName, shipModelName } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** All ships, their pilots and flights; idle ships at a bay can reposition (Step 9.6). */
export function Fleet() {
const state = useLoadedPlayer();
const [repositioning, setRepositioning] = useState<Ship | null>(null);
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Fleet</h1>
{state.ships.length === 0 ? (
<p className="text-sm text-slate-400">
No ships yet —{" "}
<Link to="/market" className="text-amber-400 underline">
visit the Market
</Link>
.
</p>
) : (
<ul className="space-y-2">
{state.ships.map((ship) => (
<ShipRow
key={ship.id}
ship={ship}
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
canReposition={
ship.status === "idle" &&
ship.pilotId !== null &&
state.bays.some((b) => b.locationId === ship.locationId) &&
state.bays.length > 1
}
onReposition={() => setRepositioning(ship)}
/>
))}
</ul>
)}
{repositioning && (
<RepositionPicker ship={repositioning} onClose={() => setRepositioning(null)} />
)}
</section>
);
}
function ShipRow(props: {
ship: Ship;
dispatch: Dispatch | undefined;
pilotName: string | null;
canReposition: boolean;
onReposition: () => void;
}) {
const { ship, dispatch, pilotName } = props;
const now = useClock((s) => s.now);
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
return (
<li className="rounded-lg border border-slate-800 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{ship.name}</span>
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
</div>
<div className="mt-1 text-sm text-slate-300">
{dispatch
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
: `At ${locationName(ship.locationId ?? "")}`}
{" · "}
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
</div>
{dispatch && (
<div className="mt-2">
<div
role="progressbar"
aria-label={`${ship.name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="h-2 overflow-hidden rounded bg-slate-800"
>
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
</div>
<div className="mt-1 text-right text-xs text-slate-400">
Arrives in <Countdown to={dispatch.arrivalAt} />
</div>
</div>
)}
{props.canReposition && (
<button
type="button"
onClick={props.onReposition}
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
>
Reposition
</button>
)}
</li>
);
}
/** Choose another bay to fly an idle ship to, empty (fuel only). */
function RepositionPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
const state = useLoadedPlayer();
const reposition = useGameAction("createDispatch");
const destinations = state.bays.filter((b) => b.locationId !== ship.locationId);
return (
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="reposition-title"
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
>
<h2 id="reposition-title" className="text-lg font-semibold">
Reposition {ship.name}
</h2>
<p className="text-sm text-slate-400">Fly empty to another bay. You pay fuel only.</p>
<ul className="mt-3 space-y-2">
{destinations.map((bay) => (
<li key={bay.id}>
<button
type="button"
disabled={reposition.isPending}
onClick={() =>
reposition.mutate(
{
kind: "reposition",
shipId: ship.id,
pilotId: ship.pilotId!,
destinationId: bay.locationId,
},
{ onSuccess: onClose },
)
}
className="w-full rounded-lg border border-slate-700 p-3 text-left"
>
{locationName(bay.locationId)}
</button>
</li>
))}
</ul>
{reposition.isError && (
<p role="alert" className="mt-3 text-sm text-red-400">
{errorMessage(reposition.error)}
</p>
)}
<button
type="button"
onClick={onClose}
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
>
Cancel
</button>
</div>
</div>
);
}