"use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { ZipPuzzle, canMove } from "@/lib/generators/zip"; import WinBanner from "./WinBanner"; interface Props { puzzle: ZipPuzzle; date: string; onSolve?: (elapsed: number) => void; } const WALL_W = 5; const PATH_COLOR = "#f97316"; // orange — matches LinkedIn const PATH_DONE = "#10b981"; // green on win const PATH_BG = "#fde8d0"; // light salmon for path cells const CELL_1_BG = "#fddcbb"; // slightly deeper salmon for waypoint 1 cell const STORAGE_KEY = (d: string) => `zip-${d}`; function cellKey(r: number, c: number) { return `${r},${c}`; } const MAX_CELL = 52; export default function ZipBoard({ puzzle, date, onSolve }: Props) { const { size, path: solution, numberedCells, walls } = puzzle; const totalCells = size * size; const [CELL, setCELL] = useState(() => typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) ); const STEP = CELL; const [userPath, setUserPath] = useState<[number, number][]>(() => { if (typeof window !== "undefined") { const s = localStorage.getItem(STORAGE_KEY(date)); if (s) return JSON.parse(s); } return []; }); const pathHistory = useRef<[number, number][][]>([]); const [dragging, setDragging] = useState(false); const [won, setWon] = useState(false); const [elapsed, setElapsed] = useState(0); const [t0] = useState(() => Date.now()); const boardRef = useRef(null); 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]); const checkWin = useCallback((p: [number, number][]) => { if (p.length !== totalCells) return false; const waypointCells = Object.entries(numberedCells).sort((a, b) => a[1] - b[1]).map(([k]) => k); let wi = 0; for (const [r, c] of p) { if (wi < waypointCells.length && cellKey(r, c) === waypointCells[wi]) wi++; } return wi === waypointCells.length; }, [totalCells, numberedCells]); useEffect(() => { localStorage.setItem(STORAGE_KEY(date), JSON.stringify(userPath)); if (!won && checkWin(userPath)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } }, [userPath, date, won, checkWin, onSolve]); const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; const pathSet = new Set(userPath.map(([r, c]) => cellKey(r, c))); function getCellFromPoint(clientX: number, clientY: number): [number, number] | null { const board = boardRef.current; if (!board) return null; const rect = board.getBoundingClientRect(); const c = Math.floor((clientX - rect.left) / STEP); const r = Math.floor((clientY - rect.top) / STEP); if (r < 0 || r >= size || c < 0 || c >= size) return null; return [r, c]; } const undo = useCallback(() => { if (won || pathHistory.current.length === 0) return; setUserPath(pathHistory.current.pop()!); }, [won]); useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); undo(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [undo]); const startPath = useCallback((r: number, c: number) => { if (won) return; setDragging(true); const key = numberedCells[cellKey(r, c)]; if (key === 1) { pathHistory.current.push([...userPath]); setUserPath([[r, c]]); return; } const last = userPath[userPath.length - 1]; if (last && last[0] === r && last[1] === c) return; if (key) { pathHistory.current.push([...userPath]); setUserPath([[r, c]]); } }, [won, numberedCells, userPath]); const extendPath = useCallback((r: number, c: number) => { if (!dragging || won) return; setUserPath(prev => { if (!prev.length) return prev; const last = prev[prev.length - 1]; const key = cellKey(r, c); const dr = Math.abs(last[0] - r), dc = Math.abs(last[1] - c); if (dr + dc !== 1) return prev; if (!canMove(walls, last[0], last[1], r, c)) return prev; if (prev.length >= 2 && prev[prev.length - 2][0] === r && prev[prev.length - 2][1] === c) return prev.slice(0, -1); if (pathSet.has(key)) return prev; return [...prev, [r, c]]; }); }, [dragging, won, pathSet, walls]); const handleMouseDown = (e: React.MouseEvent, r: number, c: number) => { e.preventDefault(); startPath(r, c); }; const handleMouseMove = (e: React.MouseEvent) => { if (!dragging) return; const cell = getCellFromPoint(e.clientX, e.clientY); if (cell) extendPath(cell[0], cell[1]); }; const handleTouchStart = (e: React.TouchEvent, r: number, c: number) => { e.preventDefault(); startPath(r, c); }; const handleTouchMove = (e: React.TouchEvent) => { if (!dragging) return; const t = e.touches[0]; const cell = getCellFromPoint(t.clientX, t.clientY); if (cell) extendPath(cell[0], cell[1]); }; // Thick orange polyline with rounded joins — matches LinkedIn visual function renderPath() { if (userPath.length < 1) return null; const color = won ? PATH_DONE : PATH_COLOR; const pts = userPath.map(([r, c]) => `${c * STEP + CELL / 2},${r * STEP + CELL / 2}`).join(" "); return ( ); } const handleHint = () => { if (won) return; let matchLen = 0; for (let i = 0; i < Math.min(userPath.length, solution.length); i++) { if (userPath[i][0] === solution[i][0] && userPath[i][1] === solution[i][1]) matchLen = i + 1; else break; } if (matchLen < solution.length) setUserPath(solution.slice(0, matchLen + 1) as [number, number][]); }; const reset = () => { pathHistory.current = []; setUserPath([]); setWon(false); }; const boardSize = size * STEP; const waypointR = Math.round(CELL * 0.35); // radius of black circle return (
{userPath.length} / {totalCells} {fmt(elapsed)}
{won && }
setDragging(false)} onMouseLeave={() => setDragging(false)} onTouchMove={handleTouchMove} onTouchEnd={() => setDragging(false)} > {renderPath()} {Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => { const key = cellKey(r, c); const inPath = pathSet.has(key); const num = numberedCells[key]; const w = walls[r][c]; const isStart = num === 1; const bTop = (w & 8) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb"; const bBottom = (w & 2) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb"; const bLeft = (w & 4) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb"; const bRight = (w & 1) ? `${WALL_W}px solid #1a1a1a` : "1px solid #e5e7eb"; let bg = "#ffffff"; if (inPath) bg = won ? "#d1fae5" : PATH_BG; else if (isStart) bg = CELL_1_BG; return (
handleMouseDown(e, r, c)} onTouchStart={e => handleTouchStart(e, r, c)} style={{ position: "absolute", left: c * STEP, top: r * STEP, width: CELL, height: CELL, backgroundColor: bg, borderTop: bTop, borderBottom: bBottom, borderLeft: bLeft, borderRight: bRight, boxSizing: "border-box", display: "flex", alignItems: "center", justifyContent: "center", cursor: "crosshair", zIndex: num ? 10 : 2, transition: "background-color 0.08s", }} > {num && (
{num}
)}
); }) )}
{!won && ( <> )}

Glissez du 1 en passant par les chiffres dans l'ordre, en couvrant toutes les cases.

); }