253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
import { SudokuPuzzle } from "@/lib/generators/sudoku";
|
||
import WinBanner from "./WinBanner";
|
||
|
||
interface Props {
|
||
puzzle: SudokuPuzzle;
|
||
date: string;
|
||
onSolve?: (elapsed: number) => void;
|
||
}
|
||
|
||
const MAX_CELL = 64;
|
||
const STORAGE_KEY = (d: string) => `sudoku-${d}`;
|
||
const DIAGONAL_ERROR = "repeating-linear-gradient(-45deg, rgba(220,38,38,0.55) 0px, rgba(220,38,38,0.55) 3px, rgba(220,38,38,0.2) 3px, rgba(220,38,38,0.2) 6px)";
|
||
|
||
function checkWin(grid: number[][], solution: number[][]): boolean {
|
||
for (let r = 0; r < 6; r++)
|
||
for (let c = 0; c < 6; c++)
|
||
if (grid[r][c] !== solution[r][c]) return false;
|
||
return true;
|
||
}
|
||
|
||
function getErrors(grid: number[][], given: number[][]): Set<string> {
|
||
const errors = new Set<string>();
|
||
for (let r = 0; r < 6; r++) {
|
||
for (let c = 0; c < 6; c++) {
|
||
const v = grid[r][c];
|
||
if (!v || given[r][c]) continue;
|
||
// Row
|
||
for (let cc = 0; cc < 6; cc++) if (cc !== c && grid[r][cc] === v) { errors.add(`${r},${c}`); errors.add(`${r},${cc}`); }
|
||
// Col
|
||
for (let rr = 0; rr < 6; rr++) if (rr !== r && grid[rr][c] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${c}`); }
|
||
// Box
|
||
const br = Math.floor(r / 2) * 2, bc = Math.floor(c / 3) * 3;
|
||
for (let dr = 0; dr < 2; dr++)
|
||
for (let dc = 0; dc < 3; dc++) {
|
||
const rr = br + dr, cc = bc + dc;
|
||
if ((rr !== r || cc !== c) && grid[rr][cc] === v) { errors.add(`${r},${c}`); errors.add(`${rr},${cc}`); }
|
||
}
|
||
}
|
||
}
|
||
return errors;
|
||
}
|
||
|
||
export default function SudokuBoard({ puzzle, date, onSolve }: Props) {
|
||
const { given, solution } = puzzle;
|
||
const size = 6;
|
||
const [CELL, setCELL] = useState(() =>
|
||
typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size))
|
||
);
|
||
|
||
const [grid, setGrid] = useState<number[][]>(() => {
|
||
if (typeof window !== "undefined") {
|
||
const s = localStorage.getItem(STORAGE_KEY(date));
|
||
if (s) return JSON.parse(s);
|
||
}
|
||
return given.map(r => [...r]);
|
||
});
|
||
|
||
const [selected, setSelected] = useState<[number, number] | null>(null);
|
||
const [won, setWon] = useState(false);
|
||
const [elapsed, setElapsed] = useState(0);
|
||
const [t0] = useState(() => Date.now());
|
||
const history = useRef<number[][][]>([]);
|
||
|
||
useEffect(() => {
|
||
const update = () => setCELL(Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)));
|
||
window.addEventListener("resize", update);
|
||
return () => window.removeEventListener("resize", update);
|
||
}, [size]);
|
||
|
||
useEffect(() => {
|
||
if (won) return;
|
||
const id = setInterval(() => setElapsed(Math.floor((Date.now() - t0) / 1000)), 500);
|
||
return () => clearInterval(id);
|
||
}, [won, t0]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(STORAGE_KEY(date), JSON.stringify(grid));
|
||
if (!won && checkWin(grid, solution)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); }
|
||
}, [grid, date, solution, won, onSolve]);
|
||
|
||
const errors = won ? new Set<string>() : getErrors(grid, given);
|
||
const filled = grid.flat().filter(Boolean).length;
|
||
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
||
|
||
const input = useCallback((val: number) => {
|
||
if (!selected || won) return;
|
||
const [r, c] = selected;
|
||
if (given[r][c]) return;
|
||
setGrid(prev => {
|
||
history.current.push(prev.map(row => [...row]));
|
||
const next = prev.map(row => [...row]);
|
||
next[r][c] = next[r][c] === val ? 0 : val;
|
||
return next;
|
||
});
|
||
}, [selected, won, given]);
|
||
|
||
const undo = useCallback(() => {
|
||
if (won || history.current.length === 0) return;
|
||
const prev = history.current.pop()!;
|
||
setGrid(prev);
|
||
}, [won]);
|
||
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
const n = parseInt(e.key);
|
||
if (n >= 1 && n <= 6) input(n);
|
||
if (e.key === "Backspace" || e.key === "Delete" || e.key === "0") input(0);
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "z") undo();
|
||
};
|
||
window.addEventListener("keydown", handler);
|
||
return () => window.removeEventListener("keydown", handler);
|
||
}, [input, undo]);
|
||
|
||
const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setSelected(null); setWon(false); };
|
||
|
||
const handleHint = useCallback(() => {
|
||
if (won) return;
|
||
// Find first empty non-given cell
|
||
for (let r = 0; r < 6; r++)
|
||
for (let c = 0; c < 6; c++)
|
||
if (!given[r][c] && grid[r][c] === 0) {
|
||
history.current.push(grid.map(row => [...row]));
|
||
setGrid(prev => {
|
||
const next = prev.map(row => [...row]);
|
||
next[r][c] = solution[r][c];
|
||
return next;
|
||
});
|
||
setSelected([r, c]);
|
||
return;
|
||
}
|
||
}, [won, given, grid, solution]);
|
||
|
||
const [selR, selC] = selected ?? [-1, -1];
|
||
|
||
// Box borders: thick between 2x3 boxes
|
||
const boxBorderB = (r: number) => (r === 1 || r === 3) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
|
||
const boxBorderR = (c: number) => (c === 2) ? "3px solid #1a1a1a" : "1px solid #d1d5db";
|
||
|
||
return (
|
||
<div className="flex flex-col items-center gap-4">
|
||
<div className="flex items-center gap-8 text-sm text-gray-500 font-medium">
|
||
<span>{filled} / 36</span>
|
||
<span className="tabular-nums">{fmt(elapsed)}</span>
|
||
</div>
|
||
|
||
{won && <WinBanner game="sudoku" date={date} elapsed={elapsed} />}
|
||
|
||
{/* Grid */}
|
||
<div style={{ border: "3px solid #1a1a1a", display: "inline-block", borderRadius: 4 }}>
|
||
{Array.from({ length: 6 }, (_, r) => (
|
||
<div key={r} style={{ display: "flex" }}>
|
||
{Array.from({ length: 6 }, (_, c) => {
|
||
const val = grid[r][c];
|
||
const isGiven = !!given[r][c];
|
||
const isSelected = r === selR && c === selC;
|
||
const isSameVal = selected && val && grid[selR]?.[selC] === val && !isSelected;
|
||
const isHighlighted = selected && !isSelected && (r === selR || c === selC || (Math.floor(r / 2) === Math.floor(selR / 2) && Math.floor(c / 3) === Math.floor(selC / 3)));
|
||
const err = errors.has(`${r},${c}`);
|
||
|
||
let bg = "#ffffff";
|
||
const bgImage: string | undefined = err ? DIAGONAL_ERROR : undefined;
|
||
if (isSelected) bg = "#ccfbf1";
|
||
else if (isSameVal) bg = "#99f6e4";
|
||
else if (isHighlighted) bg = "#f0fdfa";
|
||
|
||
const selectedBorder = isSelected ? "2px solid #0d9488" : undefined;
|
||
|
||
return (
|
||
<div
|
||
key={c}
|
||
onClick={() => !won && setSelected([r, c])}
|
||
style={{
|
||
width: CELL,
|
||
height: CELL,
|
||
backgroundColor: bg,
|
||
backgroundImage: bgImage,
|
||
borderBottom: selectedBorder ?? boxBorderB(r),
|
||
borderRight: selectedBorder ?? boxBorderR(c),
|
||
borderTop: isSelected ? "2px solid #10b981" : undefined,
|
||
borderLeft: isSelected ? "2px solid #10b981" : undefined,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: isGiven || won ? "default" : "pointer",
|
||
fontSize: CELL * 0.42,
|
||
fontWeight: isGiven ? 700 : 500,
|
||
color: err ? "#dc2626" : isGiven ? "#1a1a1a" : "#1d4ed8",
|
||
transition: "background-color 0.1s",
|
||
userSelect: "none",
|
||
position: "relative",
|
||
zIndex: isSelected ? 1 : 0,
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{val || ""}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Number pad */}
|
||
{!won && (
|
||
<div className="flex gap-2">
|
||
{[1, 2, 3, 4, 5, 6].map(n => (
|
||
<button
|
||
key={n}
|
||
onClick={() => input(n)}
|
||
className="w-10 h-10 rounded-lg border border-gray-300 text-gray-700 font-semibold text-base hover:bg-blue-50 hover:border-blue-300 transition-colors"
|
||
>
|
||
{n}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => input(0)}
|
||
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-xs hover:bg-gray-50 transition-colors"
|
||
title="Effacer"
|
||
>
|
||
✕
|
||
</button>
|
||
<button
|
||
onClick={undo}
|
||
disabled={history.current.length === 0}
|
||
className="w-10 h-10 rounded-lg border border-gray-200 text-gray-400 text-base hover:bg-gray-50 transition-colors disabled:opacity-30"
|
||
title="Annuler (Ctrl+Z)"
|
||
>
|
||
↺
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-gray-400 text-center max-w-[300px]">
|
||
Chiffres 1–6 : un seul par ligne, colonne et bloc 2×3. Clic + clavier ou pavé numérique.
|
||
</p>
|
||
|
||
<div className="flex gap-3">
|
||
{!won && (
|
||
<button onClick={handleHint}
|
||
className="px-4 py-2 rounded-xl border border-amber-200 text-amber-600 hover:bg-amber-50 text-sm transition-colors">
|
||
Indice
|
||
</button>
|
||
)}
|
||
<button onClick={reset} className="px-4 py-2 rounded-xl border border-gray-200 text-gray-500 hover:bg-gray-50 text-sm transition-colors">
|
||
Recommencer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|