"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 { const errors = new Set(); 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(() => { 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([]); 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() : 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 (
{filled} / 36 {fmt(elapsed)}
{won && } {/* Grid */}
{Array.from({ length: 6 }, (_, r) => (
{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 (
!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 || ""}
); })}
))}
{/* Number pad */} {!won && (
{[1, 2, 3, 4, 5, 6].map(n => ( ))}
)}

Chiffres 1–6 : un seul par ligne, colonne et bloc 2×3. Clic + clavier ou pavé numérique.

{!won && ( )}
); }