1
0
Fork 0
polaris-task-force/src/components/frontend/eod/CreateMinefieldDialog.tsx
2026-09-08 15:57:01 -04:00

252 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { Bomb, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { createMinefield } from "@/app/(frontend)/eod/custom/actions";
const MIN_SIZE = 9;
const MAX_SIZE = 500;
const PAINTER_MAX_CELLS = 2500;
export function CreateMinefieldDialog({
unlocked,
children,
}: {
unlocked: boolean;
children?: React.ReactNode;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [rows, setRows] = useState(9);
const [cols, setCols] = useState(9);
const [mines, setMines] = useState<Set<number>>(new Set());
const [exposeEdgeMines, setExposeEdgeMines] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const paintable = rows * cols <= PAINTER_MAX_CELLS;
const mineCount = mines.size;
const clampSize = (value: number) =>
Math.max(MIN_SIZE, Math.min(MAX_SIZE, Number.isFinite(value) ? value : MIN_SIZE));
const resetPainting = (nextRows: number, nextCols: number) => {
const next = new Set<number>();
for (const key of mines) {
const row = Math.floor(key / cols);
const col = key % cols;
if (row < nextRows && col < nextCols) next.add(row * nextCols + col);
}
setMines(next);
};
const toggleCell = (index: number) => {
setMines((current) => {
const next = new Set(current);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
const submit = async () => {
setError(null);
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > 100) {
setError("Name must be 1-100 characters.");
return;
}
if (mineCount < 1) {
setError("Paint at least one mine.");
return;
}
setSubmitting(true);
const positions = Array.from(mines).map((key) => ({
row: Math.floor(key / cols),
col: key % cols,
}));
const result = await createMinefield({
name: trimmed,
rows,
cols,
mines: positions,
exposeEdgeMines,
});
setSubmitting(false);
if (result.success) {
setOpen(false);
setName("");
setMines(new Set());
router.refresh();
} else {
setError(result.error ?? "Could not publish the minefield.");
}
};
if (!unlocked) return null;
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setError(null);
}}
>
<DialogTrigger asChild>
{children ?? (
<Button type="button">
<Bomb /> Author minefield
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[85dvh] overflow-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Author a custom minefield</DialogTitle>
<DialogDescription>
Pick a grid size, then paint charges. Published fields are instantly playable by every
operator. No safe first click is guaranteed.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="minefield-name" className="text-xs font-medium">
Name
</label>
<Input
id="minefield-name"
value={name}
maxLength={100}
onChange={(event) => setName(event.target.value)}
placeholder="Sector Ghost"
className="w-48"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="minefield-rows" className="text-xs font-medium">
Rows
</label>
<Input
id="minefield-rows"
type="number"
min={MIN_SIZE}
max={MAX_SIZE}
value={rows}
onChange={(event) => {
const next = clampSize(Number(event.target.value));
setRows(next);
resetPainting(next, cols);
}}
className="w-24"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="minefield-cols" className="text-xs font-medium">
Columns
</label>
<Input
id="minefield-cols"
type="number"
min={MIN_SIZE}
max={MAX_SIZE}
value={cols}
onChange={(event) => {
const next = clampSize(Number(event.target.value));
setCols(next);
resetPainting(rows, next);
}}
className="w-24"
/>
</div>
<span className="flex items-center gap-1.5 pb-2 text-sm font-medium tabular-nums">
<Bomb className="size-4 text-amber-400" />
{mineCount} mines
</span>
</div>
{paintable ? (
<div
className="grid max-h-[45dvh] min-w-max gap-0.5 overflow-auto rounded-lg border border-border bg-background p-2"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1.25rem))` }}
role="grid"
aria-label="Mine painter"
>
{Array.from({ length: rows * cols }, (_, index) => {
const painted = mines.has(index);
return (
<button
key={index}
type="button"
role="gridcell"
aria-label={`row ${Math.floor(index / cols) + 1} col ${(index % cols) + 1}${painted ? " mine" : ""}`}
className={cn(
"flex size-5 items-center justify-center rounded-sm border transition-colors",
painted
? "border-destructive bg-destructive text-destructive-foreground"
: "border-emerald-700/60 bg-emerald-900/40 hover:border-emerald-400 hover:bg-emerald-700",
)}
onClick={() => toggleCell(index)}
>
{painted ? <Bomb className="size-3" /> : null}
</button>
);
})}
</div>
) : (
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200">
Painting supports grids up to {Math.sqrt(PAINTER_MAX_CELLS)}×
{Math.sqrt(PAINTER_MAX_CELLS)} cells. Shrink the grid to author charges.
</p>
)}
<div className="flex items-start gap-2 rounded-lg border border-border p-3">
<Checkbox
id="expose-edge-mines"
checked={exposeEdgeMines}
onCheckedChange={(value) => setExposeEdgeMines(value === true)}
className="mt-0.5"
/>
<div className="flex flex-col gap-0.5">
<label htmlFor="expose-edge-mines" className="text-sm font-medium leading-none">
Reveal bordering charges on failed runs
</label>
<p className="text-xs text-muted-foreground">
Failed runs show mines that touch the swept area. Turn off to reveal none of the
remaining charges.
</p>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" disabled={submitting || !paintable} onClick={submit}>
{submitting ? <Loader2 className="animate-spin" /> : <Bomb />}
Publish minefield
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}