1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/poker/PokerDevPanel.tsx
Z8MB1E 947a5c1bbf feat(ui): searchable combobox across data-driven dropdowns
- new ui/combobox primitive (popover plus cmdk): search input, scrolling
  list, trigger-width popover, per-option disabled and hint lines
- converts every data-driven single-value select site-wide (blueprints,
  factions, accounts, assignments, NPCs, structures, locker items, seats,
  missions, ranks, resources, mod lists, event targets, sprints)
- long multi-select checkbox menus (event log types, kanban labels) gain
  search inputs following the resource picker pattern
- static short enum selects stay as plain selects
2026-09-17 16:28:17 -04:00

321 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { Shield, Trash2 } from "lucide-react";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import {
adjustPokerStack,
setPokerBoardQueue,
setPokerHoleQueue,
} from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const RANK_OPTIONS: readonly { value: Rank; label: string }[] = [
{ value: 2, label: "2" },
{ value: 3, label: "3" },
{ value: 4, label: "4" },
{ value: 5, label: "5" },
{ value: 6, label: "6" },
{ value: 7, label: "7" },
{ value: 8, label: "8" },
{ value: 9, label: "9" },
{ value: 10, label: "10" },
{ value: 11, label: "Jack" },
{ value: 12, label: "Queen" },
{ value: 13, label: "King" },
{ value: 14, label: "Ace" },
];
const SUIT_OPTIONS: readonly { value: Suit; label: string }[] = [
{ value: "s", label: "Spades" },
{ value: "h", label: "Hearts" },
{ value: "d", label: "Diamonds" },
{ value: "c", label: "Clubs" },
];
function cardLabel(card: Card): string {
const rank =
RANK_OPTIONS.find((option) => option.value === card.rank)?.label ?? String(card.rank);
const suit = SUIT_OPTIONS.find((option) => option.value === card.suit)?.label ?? card.suit;
return `${rank} ${suit}`;
}
/**
* Builds a complete card from one picker interaction: an empty selection
* starts from the 2-of-spades baseline for the not-yet-chosen half, and any
* already-chosen half is preserved. Returning a full card (never null) is
* what lets a single pick land; the previous null-propagating version made
* first picks no-ops and hole-card queuing impossible.
*/
export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
if (part === "rank") {
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
}
return { rank: value?.rank ?? 2, suit: next as Suit };
}
function CardPicker({
label,
value,
onChange,
}: {
readonly label: string;
readonly value: Card | null;
readonly onChange: (card: Card | null) => void;
}) {
const setRank = (next: string) => onChange(applyCardPick(value, "rank", next));
const setSuit = (next: string) => onChange(applyCardPick(value, "suit", next));
return (
<div className="flex items-center gap-1.5">
<span className="w-10 text-xs text-muted-foreground">{label}</span>
<Combobox
className="h-8 w-20 text-xs"
value={value ? String(value.rank) : ""}
onValueChange={setRank}
placeholder="Rank"
searchPlaceholder="Rank..."
options={RANK_OPTIONS.map((option) => ({
value: String(option.value),
label: option.label,
}))}
/>
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="Suit" />
</SelectTrigger>
<SelectContent>
{SUIT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function PokerDevPanel({
tableId,
view,
}: {
readonly tableId: string;
readonly view: NonNullable<PokerSnapshot["you"]>;
}) {
const [open, setOpen] = useState(false);
const [boardQueue, setBoardQueue] = useState<Card[]>([]);
const [boardDraft, setBoardDraft] = useState<Card | null>(null);
const [seatId, setSeatId] = useState<string>("");
const [holeA, setHoleA] = useState<Card | null>(null);
const [holeB, setHoleB] = useState<Card | null>(null);
const [adjustSeat, setAdjustSeat] = useState<string>("");
const [adjustAmount, setAdjustAmount] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const run = async (action: () => Promise<{ success: boolean; error?: string }>) => {
const result = await action();
if (!result.success) setError(result.error ?? "The dev action failed.");
else setError(null);
};
const queueBoard = () => {
if (boardQueue.length === 0) return;
void run(async () => {
const result = await setPokerBoardQueue(tableId, boardQueue);
if (result.success) setBoardQueue([]);
return result;
});
};
const queueHole = () => {
if (seatId === "" || !holeA || !holeB) return;
void run(async () => {
const result = await setPokerHoleQueue(tableId, Number(seatId), [holeA, holeB]);
if (result.success) {
setHoleA(null);
setHoleB(null);
}
return result;
});
};
const adjust = (sign: 1 | -1) => {
if (adjustSeat === "") return;
const amount = Number(adjustAmount);
if (!Number.isInteger(amount) || amount <= 0) {
setError("Enter a positive whole number of chips.");
return;
}
void run(async () => {
const result = await adjustPokerStack(tableId, Number(adjustSeat), sign * amount);
if (result.success) setAdjustAmount("");
return result;
});
};
const boardSlotsLeft = 5 - view.communityCards.length - boardQueue.length;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Developer controls"
title="Developer controls"
>
<Shield aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85dvh] max-w-lg overflow-y-auto rounded-none">
<DialogHeader>
<DialogTitle>Developer controls</DialogTitle>
<DialogDescription>
Queue upcoming cards or adjust stacks. Queues apply at the next hand; board cards hit
the felt in order.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Board queue</h3>
<div className="flex flex-col gap-1.5">
<CardPicker label="Card" value={boardDraft} onChange={setBoardDraft} />
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={boardQueue.length >= boardSlotsLeft}
onClick={() => {
if (!boardDraft) return;
setBoardQueue((current) => [...current, boardDraft]);
setBoardDraft(null);
}}
>
Add to queue ({boardSlotsLeft} slot{boardSlotsLeft === 1 ? "" : "s"} left)
</Button>
</div>
{boardQueue.length > 0 && (
<ul className="flex flex-col gap-1">
{boardQueue.map((card, index) => (
<li
key={`${card.rank}-${card.suit}-${index}`}
className="flex items-center gap-2 text-xs"
>
<span className="font-mono text-muted-foreground">{index + 1}.</span>
<span>{cardLabel(card)}</span>
<button
type="button"
aria-label={`Remove ${cardLabel(card)}`}
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() =>
setBoardQueue((current) => current.filter((_, i) => i !== index))
}
>
<Trash2 className="size-3.5" />
</button>
</li>
))}
</ul>
)}
<Button
type="button"
size="sm"
className="w-fit"
onClick={queueBoard}
disabled={boardQueue.length === 0}
>
Set board queue
</Button>
</section>
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
<Combobox
className="h-8 text-xs"
value={seatId}
onValueChange={setSeatId}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: player.name,
}))}
/>
<div className="flex flex-col gap-1.5">
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
</div>
<Button
type="button"
size="sm"
className="w-fit"
onClick={queueHole}
disabled={seatId === "" || !holeA || !holeB}
>
Queue hand
</Button>
</section>
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
<Combobox
className="h-8 text-xs"
value={adjustSeat}
onValueChange={setAdjustSeat}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: `${player.name} (${player.stack.toLocaleString()})`,
}))}
/>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
step={1}
placeholder="Chips"
value={adjustAmount}
onChange={(event) => setAdjustAmount(event.target.value)}
className="h-8 w-28 text-xs"
/>
<Button type="button" variant="outline" size="sm" onClick={() => adjust(1)}>
Add
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => adjust(-1)}>
Subtract
</Button>
</div>
</section>
</DialogContent>
</Dialog>
);
}