Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
252 lines
8.3 KiB
TypeScript
252 lines
8.3 KiB
TypeScript
"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)/minigames/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>
|
||
);
|
||
}
|