- 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
321 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|