From 57bf0092aa8b8777440b187a7f160012d6827a86 Mon Sep 17 00:00:00 2001 From: Reverdin Agent Date: Sat, 23 May 2026 01:05:21 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20commit=20=E2=80=94=20puzzle-?= =?UTF-8?q?trainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Confetti.tsx | 76 + DailyPageShell.tsx | 91 + Dockerfile | 16 + ErrorBoundary.tsx | 50 + LevelGrid.tsx | 196 + LevelsPageShell.tsx | 101 + NavLink.tsx | 25 + PatchesBoard.tsx | 476 ++ QueensBoard.tsx | 555 +++ StreakBadge.tsx | 19 + SudokuBoard.tsx | 253 ++ TangoBoard.tsx | 359 ++ WinBanner.tsx | 237 + ZipBoard.tsx | 302 ++ app/apple-icon.tsx | 25 + app/archive/page.tsx | 241 + app/comment-jouer/page.tsx | 98 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 212 + app/icon.tsx | 34 + app/layout.tsx | 89 + app/levels/page.tsx | 81 + app/mentions-legales/page.tsx | 45 + app/opengraph-image.tsx | 69 + app/page.tsx | 503 +++ app/patches/[date]/page.tsx | 31 + app/patches/level/[n]/page.tsx | 98 + app/patches/levels/page.tsx | 7 + app/patches/page.tsx | 27 + app/queens/[date]/page.tsx | 31 + app/queens/level/[n]/page.tsx | 125 + app/queens/levels/page.tsx | 7 + app/queens/page.tsx | 27 + app/settings/page.tsx | 207 + app/sitemap.ts | 27 + app/stats/page.tsx | 215 + app/sudoku/[date]/page.tsx | 31 + app/sudoku/level/[n]/page.tsx | 98 + app/sudoku/levels/page.tsx | 7 + app/sudoku/page.tsx | 27 + app/tango/[date]/page.tsx | 31 + app/tango/level/[n]/page.tsx | 98 + app/tango/levels/page.tsx | 7 + app/tango/page.tsx | 27 + app/zip/[date]/page.tsx | 31 + app/zip/level/[n]/page.tsx | 98 + app/zip/levels/page.tsx | 7 + app/zip/page.tsx | 27 + archive/page.tsx | 241 + bot-output/2026-05-11.json | 980 +++++ bot-output/2026-05-12.json | 980 +++++ bot-output/2026-05-17.json | 980 +++++ bot-output/2026-05-18.json | 992 +++++ bot-output/2026-05-19.json | 992 +++++ bot-output/2026-05-20.json | 980 +++++ bot-output/2026-05-21.json | 980 +++++ bot-output/2026-05-22.json | 980 +++++ components/BottomNav.tsx | 89 + components/Confetti.tsx | 76 + components/DailyPageShell.tsx | 132 + components/ErrorBoundary.tsx | 50 + components/LevelGrid.tsx | 196 + components/LevelsPageShell.tsx | 101 + components/NavLink.tsx | 25 + components/PatchesBoard.tsx | 357 ++ components/QueensBoard.tsx | 555 +++ components/RuleOverlay.tsx | 141 + components/StreakBadge.tsx | 19 + components/SudokuBoard.tsx | 253 ++ components/TangoBoard.tsx | 357 ++ components/WinBanner.tsx | 296 ++ components/ZipBoard.tsx | 302 ++ daily-bot.ts | 214 + docker-compose.yml | 16 + favicon.ico | Bin 0 -> 25931 bytes file.svg | 1 + generators/patches.ts | 192 + generators/queens.ts | 769 ++++ generators/sudoku.ts | 93 + generators/tango.ts | 184 + generators/zip.ts | 254 ++ globals.css | 141 + globe.svg | 1 + layout.tsx | 86 + levels.ts | 80 + lib/generators/patches.ts | 192 + lib/generators/queens.ts | 769 ++++ lib/generators/sudoku.ts | 93 + lib/generators/tango.ts | 215 + lib/generators/zip.ts | 254 ++ lib/levels.ts | 80 + lib/progress.ts | 98 + lib/rng.ts | 29 + lib/rules.ts | 61 + lib/session.ts | 43 + lib/stats.ts | 87 + manifest.json | 22 + mentions-legales/page.tsx | 45 + next-env.d.ts | 6 + next.config.ts | 32 + next.svg | 1 + opengraph-image.tsx | 69 + package-lock.json | 6585 ++++++++++++++++++++++++++++ package.json | 26 + page.tsx | 215 + patches/[date]/page.tsx | 31 + patches/level/[n]/page.tsx | 98 + patches/levels/page.tsx | 7 + patches/page.tsx | 27 + postcss.config.mjs | 7 + progress.ts | 98 + public/file.svg | 1 + public/globe.svg | 1 + public/manifest.json | 22 + public/next.svg | 1 + public/robots.txt | 4 + public/splash/splash-1080x2340.png | Bin 0 -> 144521 bytes public/splash/splash-1170x2532.png | Bin 0 -> 166805 bytes public/splash/splash-1179x2556.png | Bin 0 -> 168807 bytes public/splash/splash-1290x2796.png | Bin 0 -> 197727 bytes public/splash/splash-750x1334.png | Bin 0 -> 62971 bytes public/sw.js | 48 + public/vercel.svg | 1 + public/window.svg | 1 + queens/[date]/page.tsx | 31 + queens/level/[n]/page.tsx | 125 + queens/levels/page.tsx | 7 + queens/page.tsx | 27 + rng.ts | 28 + robots.txt | 4 + sitemap.ts | 27 + stats.ts | 46 + sudoku/[date]/page.tsx | 31 + sudoku/level/[n]/page.tsx | 98 + sudoku/levels/page.tsx | 7 + sudoku/page.tsx | 27 + tango/[date]/page.tsx | 31 + tango/level/[n]/page.tsx | 98 + tango/levels/page.tsx | 7 + tango/page.tsx | 27 + tsconfig.json | 34 + tsconfig.tsbuildinfo | 1 + vercel.svg | 1 + window.svg | 1 + zip/[date]/page.tsx | 31 + zip/level/[n]/page.tsx | 98 + zip/levels/page.tsx | 7 + zip/page.tsx | 27 + 148 files changed, 28619 insertions(+) create mode 100644 Confetti.tsx create mode 100644 DailyPageShell.tsx create mode 100644 Dockerfile create mode 100644 ErrorBoundary.tsx create mode 100644 LevelGrid.tsx create mode 100644 LevelsPageShell.tsx create mode 100644 NavLink.tsx create mode 100644 PatchesBoard.tsx create mode 100644 QueensBoard.tsx create mode 100644 StreakBadge.tsx create mode 100644 SudokuBoard.tsx create mode 100644 TangoBoard.tsx create mode 100644 WinBanner.tsx create mode 100644 ZipBoard.tsx create mode 100644 app/apple-icon.tsx create mode 100644 app/archive/page.tsx create mode 100644 app/comment-jouer/page.tsx create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/icon.tsx create mode 100644 app/layout.tsx create mode 100644 app/levels/page.tsx create mode 100644 app/mentions-legales/page.tsx create mode 100644 app/opengraph-image.tsx create mode 100644 app/page.tsx create mode 100644 app/patches/[date]/page.tsx create mode 100644 app/patches/level/[n]/page.tsx create mode 100644 app/patches/levels/page.tsx create mode 100644 app/patches/page.tsx create mode 100644 app/queens/[date]/page.tsx create mode 100644 app/queens/level/[n]/page.tsx create mode 100644 app/queens/levels/page.tsx create mode 100644 app/queens/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/sitemap.ts create mode 100644 app/stats/page.tsx create mode 100644 app/sudoku/[date]/page.tsx create mode 100644 app/sudoku/level/[n]/page.tsx create mode 100644 app/sudoku/levels/page.tsx create mode 100644 app/sudoku/page.tsx create mode 100644 app/tango/[date]/page.tsx create mode 100644 app/tango/level/[n]/page.tsx create mode 100644 app/tango/levels/page.tsx create mode 100644 app/tango/page.tsx create mode 100644 app/zip/[date]/page.tsx create mode 100644 app/zip/level/[n]/page.tsx create mode 100644 app/zip/levels/page.tsx create mode 100644 app/zip/page.tsx create mode 100644 archive/page.tsx create mode 100644 bot-output/2026-05-11.json create mode 100644 bot-output/2026-05-12.json create mode 100644 bot-output/2026-05-17.json create mode 100644 bot-output/2026-05-18.json create mode 100644 bot-output/2026-05-19.json create mode 100644 bot-output/2026-05-20.json create mode 100644 bot-output/2026-05-21.json create mode 100644 bot-output/2026-05-22.json create mode 100644 components/BottomNav.tsx create mode 100644 components/Confetti.tsx create mode 100644 components/DailyPageShell.tsx create mode 100644 components/ErrorBoundary.tsx create mode 100644 components/LevelGrid.tsx create mode 100644 components/LevelsPageShell.tsx create mode 100644 components/NavLink.tsx create mode 100644 components/PatchesBoard.tsx create mode 100644 components/QueensBoard.tsx create mode 100644 components/RuleOverlay.tsx create mode 100644 components/StreakBadge.tsx create mode 100644 components/SudokuBoard.tsx create mode 100644 components/TangoBoard.tsx create mode 100644 components/WinBanner.tsx create mode 100644 components/ZipBoard.tsx create mode 100644 daily-bot.ts create mode 100644 docker-compose.yml create mode 100644 favicon.ico create mode 100644 file.svg create mode 100644 generators/patches.ts create mode 100644 generators/queens.ts create mode 100644 generators/sudoku.ts create mode 100644 generators/tango.ts create mode 100644 generators/zip.ts create mode 100644 globals.css create mode 100644 globe.svg create mode 100644 layout.tsx create mode 100644 levels.ts create mode 100644 lib/generators/patches.ts create mode 100644 lib/generators/queens.ts create mode 100644 lib/generators/sudoku.ts create mode 100644 lib/generators/tango.ts create mode 100644 lib/generators/zip.ts create mode 100644 lib/levels.ts create mode 100644 lib/progress.ts create mode 100644 lib/rng.ts create mode 100644 lib/rules.ts create mode 100644 lib/session.ts create mode 100644 lib/stats.ts create mode 100644 manifest.json create mode 100644 mentions-legales/page.tsx create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 next.svg create mode 100644 opengraph-image.tsx create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 page.tsx create mode 100644 patches/[date]/page.tsx create mode 100644 patches/level/[n]/page.tsx create mode 100644 patches/levels/page.tsx create mode 100644 patches/page.tsx create mode 100644 postcss.config.mjs create mode 100644 progress.ts create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/manifest.json create mode 100644 public/next.svg create mode 100644 public/robots.txt create mode 100644 public/splash/splash-1080x2340.png create mode 100644 public/splash/splash-1170x2532.png create mode 100644 public/splash/splash-1179x2556.png create mode 100644 public/splash/splash-1290x2796.png create mode 100644 public/splash/splash-750x1334.png create mode 100644 public/sw.js create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 queens/[date]/page.tsx create mode 100644 queens/level/[n]/page.tsx create mode 100644 queens/levels/page.tsx create mode 100644 queens/page.tsx create mode 100644 rng.ts create mode 100644 robots.txt create mode 100644 sitemap.ts create mode 100644 stats.ts create mode 100644 sudoku/[date]/page.tsx create mode 100644 sudoku/level/[n]/page.tsx create mode 100644 sudoku/levels/page.tsx create mode 100644 sudoku/page.tsx create mode 100644 tango/[date]/page.tsx create mode 100644 tango/level/[n]/page.tsx create mode 100644 tango/levels/page.tsx create mode 100644 tango/page.tsx create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo create mode 100644 vercel.svg create mode 100644 window.svg create mode 100644 zip/[date]/page.tsx create mode 100644 zip/level/[n]/page.tsx create mode 100644 zip/levels/page.tsx create mode 100644 zip/page.tsx diff --git a/Confetti.tsx b/Confetti.tsx new file mode 100644 index 0000000..1729d13 --- /dev/null +++ b/Confetti.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const COLORS = ["#f97316", "#10b981", "#3b82f6", "#f59e0b", "#ec4899", "#8b5cf6", "#14b8a6"]; + +interface Piece { + id: number; + color: string; + left: number; + delay: number; + duration: number; + size: number; + rotation: number; +} + +function makePieces(n: number): Piece[] { + return Array.from({ length: n }, (_, i) => ({ + id: i, + color: COLORS[i % COLORS.length], + left: Math.random() * 100, + delay: Math.random() * 1.2, + duration: 2.2 + Math.random() * 1.4, + size: 6 + Math.random() * 8, + rotation: Math.random() * 360, + })); +} + +export default function Confetti() { + const [pieces] = useState(() => makePieces(48)); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const t = setTimeout(() => setVisible(false), 4000); + return () => clearTimeout(t); + }, []); + + if (!visible) return null; + + return ( +
+ + {pieces.map(p => ( +
0.5 ? 1 : 0.4), + backgroundColor: p.color, + borderRadius: Math.random() > 0.5 ? "50%" : 2, + // @ts-expect-error css var + "--rot": `${p.rotation}deg`, + animation: `confetti-fall ${p.duration}s ease-in ${p.delay}s both, confetti-sway ${p.duration * 0.6}s ease-in-out ${p.delay}s infinite`, + willChange: "transform", + }} + /> + ))} +
+ ); +} diff --git a/DailyPageShell.tsx b/DailyPageShell.tsx new file mode 100644 index 0000000..455f18b --- /dev/null +++ b/DailyPageShell.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { loadStats } from "@/lib/stats"; +import { GameId, GAME_META } from "@/lib/levels"; + +interface Props { + game: GameId; + date: string; + dateLabel: string; + children: React.ReactNode; +} + +function fmt(s: number) { + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +/** + * Wraps a daily puzzle page with: + * - Already-solved banner (if player already solved today) + * - Live date header + * - "Entraîne-toi" link to /game/levels + */ +export default function DailyPageShell({ game, date, dateLabel, children }: Props) { + const [solvedToday, setSolvedToday] = useState(false); + const [stats, setStats] = useState | null>(null); + const { accent } = GAME_META[game]; + + useEffect(() => { + const s = loadStats(game); + setStats(s); + if (s.lastDate === date) { + setSolvedToday(true); + } + }, [game, date]); + + return ( +
+ {/* Header */} +
+

{GAME_META[game].name}

+

{dateLabel}

+
+ + {/* Already-solved banner */} + {solvedToday && stats && ( +
+
+ + + +
+
+

Déjà résolu aujourd'hui

+ {stats.bestTime > 0 && ( +

Meilleur temps : {fmt(stats.bestTime)}

+ )} +
+ + Niveaux → + +
+ )} + + {/* Board */} + {children} + + {/* Footer links */} +
+ + Archives + + · + + Entraînement + +
+
+ ); +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40ac64b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/ErrorBoundary.tsx b/ErrorBoundary.tsx new file mode 100644 index 0000000..5c30904 --- /dev/null +++ b/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import Link from "next/link"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: { componentStack: string }) { + console.error("[ErrorBoundary]", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + return ( +
+

Une erreur inattendue s'est produite.

+ + + ← Accueil + +
+ ); + } + return this.props.children; + } +} diff --git a/LevelGrid.tsx b/LevelGrid.tsx new file mode 100644 index 0000000..5e9862a --- /dev/null +++ b/LevelGrid.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { GameId, TOTAL_LEVELS, levelMeta, GAME_META } from "@/lib/levels"; +import { GameProgress } from "@/lib/progress"; + +interface Props { + game: GameId; + progress: GameProgress; + currentLevel?: number; +} + +const DIFF_COLORS: Record = { + 1: { bg: "#f0fdf4", border: "#86efac", label: "Facile", color: "#16a34a" }, + 2: { bg: "#fefce8", border: "#fde047", label: "Normal", color: "#ca8a04" }, + 3: { bg: "#fff7ed", border: "#fdba74", label: "Intermédiaire", color: "#ea580c" }, + 4: { bg: "#fef2f2", border: "#fca5a5", label: "Difficile", color: "#dc2626" }, + 5: { bg: "#faf5ff", border: "#d8b4fe", label: "Expert", color: "#9333ea" }, +}; + +function fmt(s: number): string { + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +function LevelCell({ + game, + level, + done, + isCurrent, + accent, + diffInfo, + bestTime, +}: { + game: GameId; + level: number; + done: boolean; + isCurrent: boolean; + accent: string; + diffInfo: typeof DIFF_COLORS[1]; + bestTime: number; +}) { + return ( + + {done ? ( + + + + ) : ( + level + )} + + ); +} + +export default function LevelGrid({ game, progress, currentLevel }: Props) { + const accent = GAME_META[game].accent; + const levels = Array.from({ length: TOTAL_LEVELS }, (_, i) => i + 1); + + // Group levels by difficulty + const groups: { diff: number; levels: number[] }[] = []; + let currentDiff = -1; + for (const level of levels) { + const d = levelMeta(game, level).difficulty; + if (d !== currentDiff) { + groups.push({ diff: d, levels: [level] }); + currentDiff = d; + } else { + groups[groups.length - 1].levels.push(level); + } + } + + // Completion stats + const completedCount = Object.keys(progress).length; + + return ( +
+ {/* Difficulty legend */} +
+ {[1, 2, 3, 4, 5].map(d => { + const { bg, border, label, color } = DIFF_COLORS[d]; + return ( +
+ + {label} +
+ ); + })} +
+ + {/* Difficulty groups */} +
+ {groups.map(({ diff, levels: groupLevels }) => { + const diffInfo = DIFF_COLORS[diff]; + const groupDone = groupLevels.filter(l => !!progress[l]).length; + + return ( +
+ {/* Group header */} +
+ + {diffInfo.label} + + + {groupDone}/{groupLevels.length} + +
+
+ + {/* Grid for this difficulty group */} +
+ {groupLevels.map(level => { + const record = progress[level]; + const done = !!record; + const isCurrent = level === currentLevel; + const meta = levelMeta(game, level); + + return ( + + ); + })} +
+
+ ); + })} +
+ + {/* Footer stats */} +

+ {completedCount} / {TOTAL_LEVELS} niveaux complétés + {completedCount > 0 && ( + + ({Math.round((completedCount / TOTAL_LEVELS) * 100)}%) + + )} +

+
+ ); +} diff --git a/LevelsPageShell.tsx b/LevelsPageShell.tsx new file mode 100644 index 0000000..13425b1 --- /dev/null +++ b/LevelsPageShell.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import LevelGrid from "@/components/LevelGrid"; +import { GameProgress, getGameProgress, nextLevel, allStats } from "@/lib/progress"; +import { GameId, GAME_META, TOTAL_LEVELS } from "@/lib/levels"; + +interface Props { + game: GameId; +} + +function fmt(s: number) { + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +export default function LevelsPageShell({ game }: Props) { + const [progress, setProgress] = useState({}); + const [next, setNext] = useState(1); + const { name, accent } = GAME_META[game]; + + useEffect(() => { + const refresh = () => { + setProgress(getGameProgress(game)); + setNext(nextLevel(game)); + }; + refresh(); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", refresh); + return () => { + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", refresh); + }; + }, [game]); + + const stats = allStats(); + const gameStats = stats[game]; + const completedCount = Object.keys(progress).length; + const pct = Math.round((completedCount / TOTAL_LEVELS) * 100); + + return ( +
+ + {/* Header */} +
+
+
+ + + {name} + +
+

{name} — Niveaux

+

100 puzzles · difficulté progressive

+
+ + {/* Completion ring */} +
+ + {completedCount} + + / {TOTAL_LEVELS} + {pct > 0 && ( + {pct}% + )} +
+
+ + {/* Stats strip */} + {gameStats && gameStats.bestTime > 0 && ( +
+
+ Meilleur temps + {fmt(gameStats.bestTime)} +
+
+ Prochain + Niv. {next} +
+
+ Complétés + {completedCount} +
+
+ )} + + {/* CTA */} + + {completedCount === 0 ? "Commencer" : "Continuer"} — Niveau {next} + + + + {/* Grid */} + +
+ ); +} diff --git a/NavLink.tsx b/NavLink.tsx new file mode 100644 index 0000000..832f716 --- /dev/null +++ b/NavLink.tsx @@ -0,0 +1,25 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function NavLink({ href, label, symbol }: { href: string; label: string; symbol?: string }) { + const pathname = usePathname(); + const active = pathname === href || (href !== "/" && pathname.startsWith(href)); + return ( + + {symbol && ( + + {symbol} + + )} + {label} + + ); +} diff --git a/PatchesBoard.tsx b/PatchesBoard.tsx new file mode 100644 index 0000000..9d7560b --- /dev/null +++ b/PatchesBoard.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { PatchesPuzzle, Region } from "@/lib/generators/patches"; +import WinBanner from "./WinBanner"; + +interface Props { + puzzle: PatchesPuzzle; + date: string; + onSolve?: (elapsed: number) => void; +} + +const MAX_CELL = 72; +const STORAGE_KEY = (d: string) => `patches-${d}`; + +/** Tiny pixel-art preview of the polyomino shape. */ +function ShapePreview({ + relCells, previewRows, previewCols, color, cellPx, +}: { + relCells: [number, number][]; + previewRows: number; + previewCols: number; + color: string; + cellPx: number; +}) { + const filled = new Set(relCells.map(([r, c]) => `${r},${c}`)); + const gap = 1; + return ( +
+ {Array.from({ length: previewRows }, (_, r) => + Array.from({ length: previewCols }, (_, c) => ( +
+ )) + )} +
+ ); +} + +function checkWin(userGrid: number[][], puzzle: PatchesPuzzle): boolean { + const { size, grid } = puzzle; + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (userGrid[r][c] !== grid[r][c]) return false; + return true; +} + +export default function PatchesBoard({ puzzle, date, onSolve }: Props) { + const { size, regions, grid } = puzzle; + + const [CELL, setCELL] = useState(() => + typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) + ); + + const hintCellSet = useMemo( + () => new Set(regions.map(reg => `${reg.hintCell[0]},${reg.hintCell[1]}`)), + [regions] + ); + const regionById = useMemo( + () => new Map(regions.map(reg => [reg.id, reg])), + [regions] + ); + const hintCellToRegion = useMemo(() => { + const m = new Map(); + for (const reg of regions) m.set(`${reg.hintCell[0]},${reg.hintCell[1]}`, reg); + return m; + }, [regions]); + + const initGrid = useCallback((): number[][] => { + if (typeof window !== "undefined") { + const s = localStorage.getItem(STORAGE_KEY(date)); + if (s) return JSON.parse(s); + } + const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1)); + for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id; + return g; + }, [date, regions, size]); + + const [userGrid, setUserGrid] = useState(initGrid); + const [activeId, setActiveId] = useState(null); // active region for painting + const [won, setWon] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [t0] = useState(() => Date.now()); + + const history = useRef([]); + + // Painting state + const painting = useRef(false); + const paintMode = useRef<"add" | "erase">("add"); + const boardRef = useRef(null); + const userGridSnap = useRef(userGrid); + useEffect(() => { userGridSnap.current = userGrid; }, [userGrid]); + + 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(userGrid)); + if (!won && checkWin(userGrid, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } + }, [userGrid, date, puzzle, won, onSolve, t0]); + + const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; + + function getCellFromPoint(clientX: number, clientY: number): [number, number] | null { + if (!boardRef.current) return null; + const rect = boardRef.current.getBoundingClientRect(); + const r = Math.floor((clientY - rect.top) / CELL); + const c = Math.floor((clientX - rect.left) / CELL); + return (r >= 0 && r < size && c >= 0 && c < size) ? [r, c] : null; + } + + /** Paint or erase a single cell for the active region. */ + const paintCell = useCallback((r: number, c: number) => { + if (activeId === null || won) return; + const key = `${r},${c}`; + // Don't erase hint cells + if (hintCellSet.has(key)) return; + + setUserGrid(prev => { + const next = prev.map(row => [...row]); + if (paintMode.current === "erase") { + if (next[r][c] === activeId) next[r][c] = -1; + } else { + // Only paint if empty or belongs to a different non-hint region (replace) + if (next[r][c] === -1 || (next[r][c] !== activeId && !hintCellSet.has(key))) { + // Remove from old region if any + next[r][c] = activeId; + } else if (next[r][c] === activeId) { + // Toggle off + next[r][c] = -1; + } + } + return next; + }); + }, [activeId, won, hintCellSet]); + + const stopPainting = useCallback(() => { + painting.current = false; + }, []); + + useEffect(() => { + window.addEventListener("mouseup", stopPainting); + window.addEventListener("touchend", stopPainting); + return () => { + window.removeEventListener("mouseup", stopPainting); + window.removeEventListener("touchend", stopPainting); + }; + }, [stopPainting]); + + const undo = useCallback(() => { + if (won || history.current.length === 0) return; + setUserGrid(history.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 handleCellMouseDown = useCallback((r: number, c: number, e: React.MouseEvent) => { + if (won) return; + e.preventDefault(); + const key = `${r},${c}`; + const isHint = hintCellSet.has(key); + + if (isHint) { + // Click hint cell → activate/deactivate region + const reg = hintCellToRegion.get(key); + if (reg) setActiveId(prev => prev === reg.id ? null : reg.id); + return; + } + + // Non-hint cell: need active region + if (activeId === null) { + // Try to detect which region the cell belongs to and erase it + const cur = userGridSnap.current[r][c]; + if (cur !== -1) { + history.current.push(userGridSnap.current.map(row => [...row])); + setUserGrid(prev => { + const next = prev.map(row => [...row]); + for (let rr = 0; rr < size; rr++) + for (let cc = 0; cc < size; cc++) + if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1; + return next; + }); + } + return; + } + + // Determine paint mode: if cell already belongs to active region → erase, else → add + const cur = userGridSnap.current[r][c]; + paintMode.current = cur === activeId ? "erase" : "add"; + painting.current = true; + history.current.push(userGridSnap.current.map(row => [...row])); + paintCell(r, c); + }, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!painting.current || won || activeId === null) return; + const cell = getCellFromPoint(e.clientX, e.clientY); + if (cell) paintCell(cell[0], cell[1]); + }, [won, activeId, paintCell]); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (won) return; + e.preventDefault(); + const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY); + if (!cell) return; + const [r, c] = cell; + const key = `${r},${c}`; + const isHint = hintCellSet.has(key); + + if (isHint) { + const reg = hintCellToRegion.get(key); + if (reg) setActiveId(prev => prev === reg.id ? null : reg.id); + return; + } + + if (activeId === null) { + const cur = userGridSnap.current[r][c]; + if (cur !== -1) { + setUserGrid(prev => { + const next = prev.map(row => [...row]); + for (let rr = 0; rr < size; rr++) + for (let cc = 0; cc < size; cc++) + if (next[rr][cc] === cur && !hintCellSet.has(`${rr},${cc}`)) next[rr][cc] = -1; + return next; + }); + } + return; + } + + const cur = userGridSnap.current[r][c]; + paintMode.current = cur === activeId ? "erase" : "add"; + painting.current = true; + paintCell(r, c); + }, [won, hintCellSet, hintCellToRegion, activeId, size, paintCell]); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!painting.current || won || activeId === null) return; + e.preventDefault(); + const cell = getCellFromPoint(e.touches[0].clientX, e.touches[0].clientY); + if (cell) paintCell(cell[0], cell[1]); + }, [won, activeId, paintCell]); + + const handleHint = useCallback(() => { + if (won) return; + // Find first region not correctly placed, reveal one correct cell + for (const reg of regions) { + const correct = reg.cells.every(([r, c]) => userGridSnap.current[r][c] === reg.id); + if (!correct) { + // Activate this region and fill one missing correct cell + setActiveId(reg.id); + for (const [r, c] of reg.cells) { + if (userGridSnap.current[r][c] !== reg.id) { + setUserGrid(prev => { + const next = prev.map(row => [...row]); + next[r][c] = reg.id; + return next; + }); + return; + } + } + } + } + }, [won, regions]); + + const reset = () => { + history.current = []; + const g: number[][] = Array.from({ length: size }, () => Array(size).fill(-1)); + for (const reg of regions) g[reg.hintCell[0]][reg.hintCell[1]] = reg.id; + setUserGrid(g); + setActiveId(null); + setWon(false); + }; + + const filled = userGrid.flat().filter(v => v !== -1).length; + + // How many cells each region needs (for progress indicator inside hint cell) + const regionCorrectCount = useMemo(() => { + const m = new Map(); + for (const reg of regions) { + const correct = reg.cells.filter(([r, c]) => userGrid[r][c] === reg.id).length; + m.set(reg.id, correct); + } + return m; + }, [userGrid, regions]); + + return ( +
+
+ {filled} / {size * size} + {fmt(elapsed)} +
+ + {won && } + + {/* Board */} +
{ painting.current = false; }} + onMouseLeave={() => { painting.current = false; }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={() => { painting.current = false; }} + > + {Array.from({ length: size }, (_, r) => + Array.from({ length: size }, (_, c) => { + const key = `${r},${c}`; + const isHint = hintCellSet.has(key); + const assignedId = userGrid[r][c]; + const region = assignedId !== -1 ? regionById.get(assignedId) : undefined; + const hintRegion = isHint ? hintCellToRegion.get(key) : undefined; + const isActive = hintRegion ? hintRegion.id === activeId : (region?.id === activeId && activeId !== null); + + // Background + let bg: string; + if (region) { + bg = region.color; + } else if (isHint && hintRegion) { + bg = hintRegion.color; + } else { + bg = "#f3f4f6"; + } + + // Opacity: if cell belongs to active region and is not a hint → show at 80% + const opacity = !isHint && assignedId === activeId && activeId !== null ? 0.75 : 1; + + // Seamless borders within same region + const sameAs = (dr: number, dc: number) => { + const nr = r + dr, nc = c + dc; + if (nr < 0 || nr >= size || nc < 0 || nc >= size) return false; + return userGrid[nr][nc] === assignedId && assignedId !== -1; + }; + const seamless = `1px solid ${bg}`; + const bTop = sameAs(-1, 0) ? seamless : "1px solid #9ca3af"; + const bBottom = sameAs( 1, 0) ? seamless : "1px solid #9ca3af"; + const bLeft = sameAs( 0,-1) ? seamless : "1px solid #9ca3af"; + const bRight = sameAs( 0, 1) ? seamless : "1px solid #9ca3af"; + + // Hint cell: active ring + const outline = isHint && activeId === hintRegion?.id + ? "3px dashed rgba(255,255,255,0.9)" + : undefined; + + // Hint cell preview size: fit shape in ~65% of cell + const maxDim = hintRegion ? Math.max(hintRegion.previewRows, hintRegion.previewCols) : 1; + const miniCellPx = Math.max(4, Math.floor((CELL * 0.6) / maxDim)); + + return ( +
handleCellMouseDown(r, c, e)} + style={{ + width: CELL, + height: CELL, + backgroundColor: bg, + opacity, + borderTop: bTop, + borderBottom: bBottom, + borderLeft: bLeft, + borderRight: bRight, + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 2, + transition: "background-color 0.06s, opacity 0.06s", + position: "relative", + outline, + outlineOffset: "-3px", + cursor: isHint ? "pointer" : "crosshair", + zIndex: isHint ? 1 : 0, + }} + > + {isHint && hintRegion && ( + <> + + + {regionCorrectCount.get(hintRegion.id) ?? 0}/{hintRegion.size} + + + )} +
+ ); + }) + )} +
+ + {activeId !== null && !won && ( +

+ Région active — peignez les {regionById.get(activeId)?.size} cases +

+ )} + +

+ Cliquez sur une icône pour activer sa région, puis peignez les cases. La forme en aperçu indique la disposition à reproduire. +

+ +
+ {!won && ( + <> + + + + )} + +
+
+ ); +} diff --git a/QueensBoard.tsx b/QueensBoard.tsx new file mode 100644 index 0000000..e00f6bf --- /dev/null +++ b/QueensBoard.tsx @@ -0,0 +1,555 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { QueensPuzzle } from "@/lib/generators/queens"; +import WinBanner from "./WinBanner"; + +interface Props { + puzzle: QueensPuzzle; + date: string; + onSolve?: (elapsed: number) => void; +} + +const REGION_COLORS = [ + { bg: "#c8b8e8", border: "#9870c8" }, + { bg: "#f5c898", border: "#d4986a" }, + { bg: "#a8c8f0", border: "#6098d8" }, + { bg: "#b8d8b0", border: "#78b870" }, + { bg: "#f5a898", border: "#d86858" }, + { bg: "#d8d8d8", border: "#a8a8a8" }, + { bg: "#d8e888", border: "#b0c840" }, + { bg: "#c8b8a8", border: "#a89878" }, + { bg: "#f0b8c8", border: "#d880a0" }, + { bg: "#a8e0d8", border: "#60b8b0" }, +]; + +type CellState = "empty" | "queen" | "mark"; + +interface HintInfo { + explanation: string; + // cells highlighted in blue (the "why" context) + focusCells: Set; + // cells highlighted in green (queen to place) or red (cells to eliminate) + actionCells: Set; + action: + | { kind: "queen"; r: number; c: number } + | { kind: "marks"; cells: [number, number][] }; +} + +function CrownIcon({ size }: { size: number }) { + return ( + + + + ); +} + +function XIcon({ size }: { size: number }) { + return ( + + + + + ); +} + +function checkWin(board: CellState[][], puzzle: QueensPuzzle): boolean { + const { size, regions } = puzzle; + const queens: [number, number][] = []; + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (board[r][c] === "queen") queens.push([r, c]); + + if (queens.length !== size) return false; + const rows = new Set(), cols = new Set(), regs = new Set(); + for (const [r, c] of queens) { + if (rows.has(r) || cols.has(c) || regs.has(regions[r][c])) return false; + rows.add(r); cols.add(c); regs.add(regions[r][c]); + } + for (let i = 0; i < queens.length; i++) + for (let j = i + 1; j < queens.length; j++) + if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) return false; + return true; +} + +function getErrors(board: CellState[][], puzzle: QueensPuzzle): Set { + const { size, regions } = puzzle; + const errors = new Set(); + const queens: [number, number][] = []; + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (board[r][c] === "queen") queens.push([r, c]); + + const byRow = new Map(), byCol = new Map(), byReg = new Map(); + for (const [r, c] of queens) { + const reg = regions[r][c]; + byRow.set(r, [...(byRow.get(r) || []), c]); + byCol.set(c, [...(byCol.get(c) || []), r]); + byReg.set(reg, [...(byReg.get(reg) || []), r * size + c]); + } + for (const [r, cs] of byRow) if (cs.length > 1) cs.forEach(c => errors.add(`${r},${c}`)); + for (const [c, rs] of byCol) if (rs.length > 1) rs.forEach(r => errors.add(`${r},${c}`)); + for (const [, cells] of byReg) if (cells.length > 1) cells.forEach(idx => errors.add(`${Math.floor(idx / size)},${idx % size}`)); + for (let i = 0; i < queens.length; i++) + for (let j = i + 1; j < queens.length; j++) + if (Math.abs(queens[i][0] - queens[j][0]) <= 1 && Math.abs(queens[i][1] - queens[j][1]) <= 1) { + errors.add(`${queens[i][0]},${queens[i][1]}`); + errors.add(`${queens[j][0]},${queens[j][1]}`); + } + return errors; +} + +// Logical hint finder: detects which rule applies and explains the reasoning +function findLogicalHint(board: CellState[][], puzzle: QueensPuzzle): HintInfo | null { + const { size, regions } = puzzle; + + const queens: [number, number][] = []; + const queenRows = new Set(); + const queenCols = new Set(); + const queenRegs = new Set(); + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (board[r][c] === "queen") { + queens.push([r, c]); + queenRows.add(r); + queenCols.add(c); + queenRegs.add(regions[r][c]); + } + + // A cell is "possible" if no placed queen or user mark excludes it + function isPossible(r: number, c: number): boolean { + if (board[r][c] === "queen" || board[r][c] === "mark") return false; + if (queenRows.has(r) || queenCols.has(c) || queenRegs.has(regions[r][c])) return false; + for (const [qr, qc] of queens) + if (Math.abs(r - qr) <= 1 && Math.abs(c - qc) <= 1) return false; + return true; + } + + const possSet = new Set(); + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (isPossible(r, c)) possSet.add(`${r},${c}`); + + // Group all cells by region + const byRegion = new Map(); + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) { + const reg = regions[r][c]; + if (!byRegion.has(reg)) byRegion.set(reg, []); + byRegion.get(reg)!.push([r, c]); + } + + // Rule 1: A region has exactly one possible cell → forced queen + for (const [reg, cells] of byRegion) { + if (queenRegs.has(reg)) continue; + const poss = cells.filter(([r, c]) => possSet.has(`${r},${c}`)); + if (poss.length === 1) { + const [r, c] = poss[0]; + return { + explanation: "Cette zone colorée (en bleu) n'a plus qu'une seule case disponible (en vert). Toutes les autres ont été éliminées par les couronnes déjà posées. La couronne de cette zone doit obligatoirement s'y trouver.", + focusCells: new Set(cells.map(([r, c]) => `${r},${c}`)), + actionCells: new Set([`${r},${c}`]), + action: { kind: "queen", r, c }, + }; + } + } + + // Rule 2: A row has exactly one possible cell → forced queen + for (let r = 0; r < size; r++) { + if (queenRows.has(r)) continue; + const poss: [number, number][] = []; + for (let c = 0; c < size; c++) if (possSet.has(`${r},${c}`)) poss.push([r, c]); + if (poss.length === 1) { + const [pr, pc] = poss[0]; + return { + explanation: `La ligne ${r + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette ligne n'a pas d'autre choix.`, + focusCells: new Set(Array.from({ length: size }, (_, c) => `${r},${c}`)), + actionCells: new Set([`${pr},${pc}`]), + action: { kind: "queen", r: pr, c: pc }, + }; + } + } + + // Rule 3: A column has exactly one possible cell → forced queen + for (let c = 0; c < size; c++) { + if (queenCols.has(c)) continue; + const poss: [number, number][] = []; + for (let r = 0; r < size; r++) if (possSet.has(`${r},${c}`)) poss.push([r, c]); + if (poss.length === 1) { + const [pr, pc] = poss[0]; + return { + explanation: `La colonne ${c + 1} (en bleu) n'a plus qu'une seule case disponible (en vert). La couronne qui doit occuper cette colonne n'a pas d'autre choix.`, + focusCells: new Set(Array.from({ length: size }, (_, r) => `${r},${c}`)), + actionCells: new Set([`${pr},${pc}`]), + action: { kind: "queen", r: pr, c: pc }, + }; + } + } + + // Rules 4+: Generalized naked subset — N regions confined to same N rows or cols + // N=1: locked region, N=2: naked pair, N=3: naked triple, N=4: naked quad + function* genCombinations(arr: T[], n: number, start = 0): Generator { + if (n === 0) { yield []; return; } + for (let i = start; i <= arr.length - n; i++) + for (const rest of genCombinations(arr, n - 1, i + 1)) + yield [arr[i], ...rest]; + } + + const unsolved = [...byRegion.entries()].filter(([reg, cells]) => + !queenRegs.has(reg) && cells.some(([r, c]) => possSet.has(`${r},${c}`)) + ); + for (let n = 1; n <= unsolved.length - 1; n++) { + for (const combo of genCombinations(unsolved, n)) { + const regSet = new Set(combo.map(([reg]) => reg)); + const possCells = combo.flatMap(([, cells]) => cells.filter(([r, c]) => possSet.has(`${r},${c}`))); + if (possCells.length === 0) continue; + + // Row subset + const rowSet = new Set(possCells.map(([r]) => r)); + if (rowSet.size === n) { + const elimCells: [number, number][] = []; + for (const lr of rowSet) + for (let c = 0; c < size; c++) + if (possSet.has(`${lr},${c}`) && !regSet.has(regions[lr][c])) + elimCells.push([lr, c]); + if (elimCells.length > 0) { + const rowNames = [...rowSet].sort((a, b) => a - b).map(r => `ligne ${r + 1}`).join(" et "); + return { + explanation: n === 1 + ? `Toutes les cases disponibles de la zone bleue se trouvent sur la ${rowNames}. La couronne de cette zone occupera forcément cette ligne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.` + : `Ces ${n} zones (en bleu) ne peuvent se placer que sur les ${rowNames}. Ces lignes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`, + focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)), + actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)), + action: { kind: "marks", cells: elimCells }, + }; + } + } + + // Col subset + const colSet = new Set(possCells.map(([, c]) => c)); + if (colSet.size === n) { + const elimCells: [number, number][] = []; + for (const lc of colSet) + for (let r = 0; r < size; r++) + if (possSet.has(`${r},${lc}`) && !regSet.has(regions[r][lc])) + elimCells.push([r, lc]); + if (elimCells.length > 0) { + const colNames = [...colSet].sort((a, b) => a - b).map(c => `colonne ${c + 1}`).join(" et "); + return { + explanation: n === 1 + ? `Toutes les cases disponibles de la zone bleue se trouvent dans la ${colNames}. La couronne de cette zone occupera forcément cette colonne — aucune autre zone ne peut donc y placer de couronne. Les cases rouges sont éliminées.` + : `Ces ${n} zones (en bleu) ne peuvent se placer que dans les ${colNames}. Ces colonnes leur sont réservées — les autres zones ne peuvent plus y placer de couronne. Les cases rouges sont éliminées.`, + focusCells: new Set(possCells.map(([r, c]) => `${r},${c}`)), + actionCells: new Set(elimCells.map(([r, c]) => `${r},${c}`)), + action: { kind: "marks", cells: elimCells }, + }; + } + } + } + } + + return null; +} + +const STORAGE_KEY = (date: string) => `queens-v2-${date}`; +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)"; + +const MAX_CELL = 52; + +export default function QueensBoard({ puzzle, date, onSolve }: Props) { + const { size, regions } = puzzle; + const [CELL, setCELL] = useState(() => + typeof window === "undefined" ? MAX_CELL : Math.min(MAX_CELL, Math.floor((window.innerWidth - 32) / size)) + ); + + const [board, setBoard] = useState(() => { + if (typeof window !== "undefined") { + const s = localStorage.getItem(STORAGE_KEY(date)); + if (s) return JSON.parse(s); + } + return Array.from({ length: size }, () => Array(size).fill("empty")); + }); + + const [won, setWon] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [t0] = useState(() => Date.now()); + const [hintInfo, setHintInfo] = useState(null); + + const history = useRef([]); + const drag = useRef<{ + action: "mark" | "clear"; + origin: string; + originApplied: boolean; + lastCell: string; + moved: boolean; + } | null>(null); + const boardSnap = useRef(board); + const gridRef = useRef(null); + + useEffect(() => { boardSnap.current = board; }, [board]); + + 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(board)); + if (!won && checkWin(board, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } + }, [board, date, puzzle, won, onSolve, t0]); + + useEffect(() => { + const stop = () => { drag.current = null; }; + window.addEventListener("pointerup", stop); + return () => window.removeEventListener("pointerup", stop); + }, []); + + const errors = won ? new Set() : getErrors(board, puzzle); + const queensPlaced = board.flat().filter(c => c === "queen").length; + const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; + + const applyDragToCell = useCallback((r: number, c: number) => { + if (!drag.current || won) return; + const { action } = drag.current; + setBoard(prev => { + const cur = prev[r][c]; + if (action === "mark" && cur === "empty") { + const next = prev.map(row => [...row]); next[r][c] = "mark"; return next; + } + if (action === "clear" && cur === "mark") { + const next = prev.map(row => [...row]); next[r][c] = "empty"; return next; + } + return prev; + }); + }, [won]); + + const undo = useCallback(() => { + if (won || history.current.length === 0) return; + setBoard(history.current.pop()!); + setHintInfo(null); + }, [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 handlePointerDown = useCallback((r: number, c: number, e: React.PointerEvent) => { + if (won) return; + e.preventDefault(); + history.current.push(boardSnap.current.map(row => [...row])); + setHintInfo(null); + const cur = boardSnap.current[r][c]; + const action: "mark" | "clear" = cur === "mark" ? "clear" : "mark"; + drag.current = { action, origin: `${r},${c}`, originApplied: false, lastCell: `${r},${c}`, moved: false }; + }, [won]); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!drag.current || won) return; + if (!gridRef.current) return; + const rect = gridRef.current.getBoundingClientRect(); + const c = Math.floor((e.clientX - rect.left) / CELL); + const r = Math.floor((e.clientY - rect.top) / CELL); + if (r < 0 || r >= size || c < 0 || c >= size) return; + const key = `${r},${c}`; + if (key === drag.current.lastCell) return; + + // On first move to a different cell, also apply action to origin + if (!drag.current.originApplied) { + const [or, oc] = drag.current.origin.split(",").map(Number); + applyDragToCell(or, oc); + drag.current.originApplied = true; + } + drag.current.moved = true; + drag.current.lastCell = key; + applyDragToCell(r, c); + }, [won, applyDragToCell, size, CELL]); + + const handlePointerUp = useCallback((r: number, c: number) => { + if (!drag.current || won) return; + if (!drag.current.moved) { + setBoard(prev => { + const next = prev.map(row => [...row]); + next[r][c] = next[r][c] === "empty" ? "mark" : next[r][c] === "mark" ? "queen" : "empty"; + return next; + }); + } + drag.current = null; + }, [won]); + + const reset = () => { history.current = []; setBoard(Array.from({ length: size }, () => Array(size).fill("empty"))); setWon(false); setHintInfo(null); }; + + const handleHint = () => { + if (won) return; + if (hintInfo) { setHintInfo(null); return; } // toggle off + const hint = findLogicalHint(boardSnap.current, puzzle); + if (hint) { + setHintInfo(hint); + } + }; + + const applyHint = () => { + if (!hintInfo) return; + if (hintInfo.action.kind === "queen") { + const { r, c } = hintInfo.action; + setBoard(prev => { + const next = prev.map(row => [...row]); + next[r][c] = "queen"; + return next; + }); + } else { + const { cells } = hintInfo.action; + setBoard(prev => { + const next = prev.map(row => [...row]); + for (const [r, c] of cells) if (next[r][c] === "empty") next[r][c] = "mark"; + return next; + }); + } + setHintInfo(null); + }; + + return ( +
+
+ {queensPlaced} / {size} reines + {fmt(elapsed)} +
+ + {won && } + + {/* Hint explanation panel */} + {hintInfo && ( +
+

{hintInfo.explanation}

+
+ {hintInfo.actionCells.size > 0 && ( + + )} + +
+
+ )} + + {/* Board */} +
+ {Array.from({ length: size }, (_, r) => + Array.from({ length: size }, (_, c) => { + const reg = regions[r][c]; + const state = board[r][c]; + const err = errors.has(`${r},${c}`); + const color = REGION_COLORS[reg % REGION_COLORS.length]; + const key = `${r},${c}`; + + const bTop = r > 0 && regions[r - 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; + const bLeft = c > 0 && regions[r][c - 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; + const bBottom = r < size - 1 && regions[r + 1][c] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; + const bRight = c < size - 1 && regions[r][c + 1] === reg ? "1px solid rgba(0,0,0,0.08)" : "2px solid #1a1a1a"; + + // Hint highlighting overrides normal bg + let bg = color.bg; + const bgImage = err ? DIAGONAL_ERROR : undefined; + if (!err && hintInfo) { + if (hintInfo.action.kind === "queen" && hintInfo.actionCells.has(key)) { + bg = "#bbf7d0"; // green: forced queen + } else if (hintInfo.action.kind === "marks" && hintInfo.actionCells.has(key)) { + bg = "#fecaca"; // red: cells to eliminate + } else if (hintInfo.focusCells.has(key)) { + bg = "#bfdbfe"; // blue: context cells + } + } + + return ( +
handlePointerDown(r, c, e)} + onPointerUp={() => handlePointerUp(r, c)} + style={{ + width: CELL, height: CELL, + backgroundColor: bg, + backgroundImage: bgImage, + borderTop: bTop, borderLeft: bLeft, borderBottom: bBottom, borderRight: bRight, + cursor: won ? "default" : "pointer", + display: "flex", alignItems: "center", justifyContent: "center", + userSelect: "none", + transition: "background-color 0.12s", + }} + > + {state === "queen" && ( + + + + )} + {state === "mark" && ( + + + + )} +
+ ); + }) + )} +
+ +
+ 1 clic = ✕ · 2 clics = couronne · glisser = ✕ en série +
+ +
+ {!won && ( + + )} + + +
+ +

+ Une couronne par ligne, par colonne et par zone colorée. Les couronnes ne peuvent pas se toucher. +

+
+ ); +} diff --git a/StreakBadge.tsx b/StreakBadge.tsx new file mode 100644 index 0000000..82cb032 --- /dev/null +++ b/StreakBadge.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { loadStats } from "@/lib/stats"; + +export default function StreakBadge({ game }: { game: string }) { + const [streak, setStreak] = useState(0); + + useEffect(() => { + setStreak(loadStats(game).streak); + }, [game]); + + if (streak === 0) return null; + return ( + + 🔥{streak} + + ); +} diff --git a/SudokuBoard.tsx b/SudokuBoard.tsx new file mode 100644 index 0000000..c0badb7 --- /dev/null +++ b/SudokuBoard.tsx @@ -0,0 +1,253 @@ +"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 && ( + + )} + +
+
+ ); +} diff --git a/TangoBoard.tsx b/TangoBoard.tsx new file mode 100644 index 0000000..811ef0a --- /dev/null +++ b/TangoBoard.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { TangoPuzzle, Cell } from "@/lib/generators/tango"; +import WinBanner from "./WinBanner"; + +interface Props { + puzzle: TangoPuzzle; + date: string; + onSolve?: (elapsed: number) => void; +} + +const MAX_CELL = 68; +const CONSTRAINT_SIZE = 22; // badge diameter + +function SunIcon({ size }: { size: number }) { + return ( + + + + ); +} + +function MoonIcon({ size }: { size: number }) { + return ( + + + + + + + + + + ); +} + +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: Cell[][], puzzle: TangoPuzzle): boolean { + const { size, hEdges, vEdges } = puzzle; + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (!grid[r][c]) return false; + for (let i = 0; i < size; i++) { + let rs = 0, rm = 0, cs = 0, cm = 0; + for (let j = 0; j < size; j++) { + if (grid[i][j] === "sun") rs++; else rm++; + if (grid[j][i] === "sun") cs++; else cm++; + } + if (rs !== size / 2 || rm !== size / 2 || cs !== size / 2 || cm !== size / 2) return false; + } + for (let i = 0; i < size; i++) + for (let j = 0; j <= size - 3; j++) { + if (grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) return false; + if (grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) return false; + } + for (let r = 0; r < size; r++) + for (let c = 0; c < size - 1; c++) { + const e = hEdges[r][c]; if (!e) continue; + const same = grid[r][c] === grid[r][c + 1]; + if ((e === "=" && !same) || (e === "x" && same)) return false; + } + for (let r = 0; r < size - 1; r++) + for (let c = 0; c < size; c++) { + const e = vEdges[r][c]; if (!e) continue; + const same = grid[r][c] === grid[r + 1][c]; + if ((e === "=" && !same) || (e === "x" && same)) return false; + } + return true; +} + +function getErrors(grid: Cell[][], puzzle: TangoPuzzle): Set { + const { size, hEdges, vEdges } = puzzle; + const err = new Set(); + for (let i = 0; i < size; i++) { + let rs = 0, rm = 0, cs = 0, cm = 0; + for (let j = 0; j < size; j++) { + if (grid[i][j] === "sun") rs++; if (grid[i][j] === "moon") rm++; + if (grid[j][i] === "sun") cs++; if (grid[j][i] === "moon") cm++; + } + if (rs > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "sun") err.add(`${i},${j}`); + if (rm > size / 2) for (let j = 0; j < size; j++) if (grid[i][j] === "moon") err.add(`${i},${j}`); + if (cs > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "sun") err.add(`${j},${i}`); + if (cm > size / 2) for (let j = 0; j < size; j++) if (grid[j][i] === "moon") err.add(`${j},${i}`); + } + for (let i = 0; i < size; i++) + for (let j = 0; j <= size - 3; j++) { + if (grid[i][j] && grid[i][j] === grid[i][j + 1] && grid[i][j] === grid[i][j + 2]) { + err.add(`${i},${j}`); err.add(`${i},${j + 1}`); err.add(`${i},${j + 2}`); + } + if (grid[j][i] && grid[j][i] === grid[j + 1][i] && grid[j][i] === grid[j + 2][i]) { + err.add(`${j},${i}`); err.add(`${j + 1},${i}`); err.add(`${j + 2},${i}`); + } + } + // Edge constraint violations + for (let r = 0; r < size; r++) + for (let c = 0; c < size - 1; c++) { + const e = hEdges[r][c]; + if (!e || !grid[r][c] || !grid[r][c + 1]) continue; + const same = grid[r][c] === grid[r][c + 1]; + if ((e === "=" && !same) || (e === "x" && same)) { + err.add(`${r},${c}`); err.add(`${r},${c + 1}`); + } + } + for (let r = 0; r < size - 1; r++) + for (let c = 0; c < size; c++) { + const e = vEdges[r][c]; + if (!e || !grid[r][c] || !grid[r + 1][c]) continue; + const same = grid[r][c] === grid[r + 1][c]; + if ((e === "=" && !same) || (e === "x" && same)) { + err.add(`${r},${c}`); err.add(`${r + 1},${c}`); + } + } + return err; +} + +const STORAGE_KEY = (d: string) => `tango-${d}`; + +export default function TangoBoard({ puzzle, date, onSolve }: Props) { + const { size, given, hEdges, vEdges } = puzzle; + 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 history = useRef([]); + const errorTimerRef = useRef | null>(null); + const [shownErrors, setShownErrors] = useState>(new Set()); + const [won, setWon] = useState(false); + const [elapsed, setElapsed] = useState(0); + const [t0] = useState(() => Date.now()); + + 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, puzzle)) { setWon(true); onSolve?.(Math.floor((Date.now() - t0) / 1000)); } + }, [grid, date, puzzle, won, onSolve, t0]); + + + 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 undo = useCallback(() => { + if (won || history.current.length === 0) return; + setGrid(history.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 cycleCell = useCallback((r: number, c: number) => { + if (won || given[r][c] !== null) return; + setGrid(prev => { + history.current.push(prev.map(row => [...row])); + const next = prev.map(row => [...row]); + next[r][c] = next[r][c] === null ? "sun" : next[r][c] === "sun" ? "moon" : null; + return next; + }); + }, [won, given]); + + const reset = () => { history.current = []; setGrid(given.map(r => [...r])); setWon(false); }; + + const handleHint = () => { + if (won) return; + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (!grid[r][c] && !given[r][c]) { + setGrid(prev => { + const next = prev.map(row => [...row]); + next[r][c] = puzzle.solution[r][c]; + return next; + }); + return; + } + }; + + const totalW = size * CELL; + const iconSize = Math.round(CELL * 0.55); + + return ( +
+
+ {filled} / {size * size} + {fmt(elapsed)} +
+ + {won && } + + {/* Board: positioned cells + constraint symbols on borders */} +
+ {/* Cells */} +
+ {Array.from({ length: size }, (_, r) => + Array.from({ length: size }, (_, c) => { + const val = grid[r][c]; + const isGiven = given[r][c] !== null; + const err = shownErrors.has(`${r},${c}`); + + let bg = "#ffffff"; + if (isGiven) bg = "#e5e7eb"; // gray for given cells + + const bRight = c < size - 1 ? "1px solid #d1d5db" : "none"; + const bBottom = r < size - 1 ? "1px solid #d1d5db" : "none"; + + return ( +
cycleCell(r, c)} + style={{ + width: CELL, height: CELL, + backgroundColor: bg, + backgroundImage: err ? DIAGONAL_ERROR : undefined, + borderRight: bRight, + borderBottom: bBottom, + cursor: (isGiven || won) ? "default" : "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + userSelect: "none", + transition: "background-color 0.1s", + }} + > + + {val === "sun" && } + {val === "moon" && } + +
+ ); + }) + )} +
+ + {/* Horizontal constraint symbols: between (r,c) and (r,c+1) — on the vertical border */} + {Array.from({ length: size }, (_, r) => + Array.from({ length: size - 1 }, (_, c) => { + const e = hEdges[r][c]; + if (!e) return null; + return ( +
+ {e === "=" ? "=" : "×"} +
+ ); + }) + )} + + {/* Vertical constraint symbols: between (r,c) and (r+1,c) — on the horizontal border */} + {Array.from({ length: size - 1 }, (_, r) => + Array.from({ length: size }, (_, c) => { + const e = vEdges[r][c]; + if (!e) return null; + return ( +
+ {e === "=" ? "=" : "×"} +
+ ); + }) + )} +
+ +
+ {!won && ( + <> + + + + + )} +
+ +
+ 1 clic = ☀ · 2 clics = ☽ · 3 clics = effacer + Autant de soleils que de lunes par ligne et colonne. Pas plus de 2 identiques consécutifs. +
+
+ ); +} diff --git a/WinBanner.tsx b/WinBanner.tsx new file mode 100644 index 0000000..db0f14f --- /dev/null +++ b/WinBanner.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import Link from "next/link"; +import Confetti from "./Confetti"; +import { loadStats, recordSolve, GameStats } from "@/lib/stats"; + +interface Props { + game: string; + date: string; + elapsed: number; +} + +function fmt(s: number) { + return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`; +} + +/** Add one calendar day to a YYYY-MM-DD string, using local-timezone arithmetic. */ +function addOneDay(dateStr: string): string { + const [y, m, d] = dateStr.split("-").map(Number); + const next = new Date(y, m - 1, d + 1); + return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}-${String(next.getDate()).padStart(2, "0")}`; +} + +/** Subtract one calendar day from a YYYY-MM-DD string. */ +function subOneDay(dateStr: string): string { + const [y, m, d] = dateStr.split("-").map(Number); + const prev = new Date(y, m - 1, d - 1); + return `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, "0")}-${String(prev.getDate()).padStart(2, "0")}`; +} + +/** Find the first unsolved date after fromDate, skipping already-solved ones. */ +function nextUnsolvedDate(game: string, fromDate: string): string { + const { solvedDates } = loadStats(game); + const solved = new Set(solvedDates ?? []); + let d = addOneDay(fromDate); + for (let i = 0; i < 365; i++) { + if (!solved.has(d)) return d; + d = addOneDay(d); + } + return d; +} + +function ShareButton({ game, elapsed }: { game: string; elapsed: number }) { + const [copied, setCopied] = useState(false); + + const handleShare = useCallback(async () => { + const text = `J'ai résolu le puzzle ${game} en ${fmt(elapsed)} sur Puzzle Trainer ! 🎉`; + const url = typeof window !== "undefined" ? window.location.href : ""; + + if (navigator.share) { + try { + await navigator.share({ text, url }); + return; + } catch { + // user cancelled or not supported + } + } + // Fallback: copy to clipboard + try { + await navigator.clipboard.writeText(`${text}\n${url}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { /* ignore */ } + }, [game, elapsed]); + + return ( + + ); +} + +function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+
{icon}
+ {value} + {label} +
+ ); +} + +export default function WinBanner({ game, date, elapsed }: Props) { + const [stats, setStats] = useState(null); + const [isPersonalRecord, setIsPersonalRecord] = useState(false); + + const isDailyDate = /^\d{4}-\d{2}-\d{2}$/.test(date); + const levelMatch = date.match(/^level-\w+-(\d+)$/); + const currentLevel = levelMatch ? parseInt(levelMatch[1]) : null; + + useEffect(() => { + if (!isDailyDate) return; + const prevStats = loadStats(game); + const s = recordSolve(game, date, elapsed); + setStats(s); + // Personal record: had a previous best, and new time is better + if (prevStats.total > 0 && prevStats.bestTime > 0 && elapsed < prevStats.bestTime) { + setIsPersonalRecord(true); + } + }, [game, date, elapsed, isDailyDate]); + + const displayStats = stats ?? loadStats(game); + + const nextHref = isDailyDate + ? `/${game}/${nextUnsolvedDate(game, date)}` + : currentLevel !== null + ? `/${game}/level/${currentLevel + 1}` + : null; + const nextLabel = isDailyDate ? "Grille suivante" : `Niveau ${(currentLevel ?? 0) + 1}`; + + const prevHref = isDailyDate + ? `/${game}/${subOneDay(date)}` + : currentLevel !== null && currentLevel > 1 + ? `/${game}/level/${currentLevel - 1}` + : null; + const prevLabel = isDailyDate ? "Hier" : currentLevel !== null ? `Niveau ${currentLevel - 1}` : ""; + + const levelsHref = `/${game}/levels`; + + return ( + <> + + + {/* Main banner */} +
+ + {/* Trophy + title */} +
+ + + + Résolu ! + {isPersonalRecord && ( + + PR + + )} +
+ + {/* Time */} +
+ {fmt(elapsed)} +
+ + {/* Stats row (daily only) */} + {isDailyDate && ( +
+ + + + + } + label="Meilleur" + value={displayStats.bestTime > 0 ? fmt(displayStats.bestTime) : "--:--"} + /> + + + + } + label="Série" + value={`${displayStats.streak}j`} + /> + + + + } + label="Total" + value={String(displayStats.total)} + /> +
+ )} + + {/* Share button */} + {isDailyDate && ( + + )} +
+ + {/* Navigation */} +
+ {prevHref && ( + + + {prevLabel} + + )} + + {nextHref && ( + + {nextLabel} + + + )} + + {isDailyDate && ( + + Entraînement + + )} +
+ + ); +} diff --git a/ZipBoard.tsx b/ZipBoard.tsx new file mode 100644 index 0000000..f2f4994 --- /dev/null +++ b/ZipBoard.tsx @@ -0,0 +1,302 @@ +"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. +

+
+ ); +} diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx new file mode 100644 index 0000000..11a66ee --- /dev/null +++ b/app/apple-icon.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ 🧩 +
+ ), + { ...size } + ); +} diff --git a/app/archive/page.tsx b/app/archive/page.tsx new file mode 100644 index 0000000..f4d6eba --- /dev/null +++ b/app/archive/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useMemo, useState, useEffect, Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { todayISO } from "@/lib/rng"; + +const GAMES = [ + { + key: "queens", + label: "Queens", + symbol: "♛", + href: (d: string) => `/queens/${d}`, + storageKey: (d: string) => `queens-${d}`, + isWon: (s: string) => { + try { return (JSON.parse(s) as string[][]).flat().filter(c => c === "queen").length >= 6; } catch { return false; } + }, + }, + { + key: "tango", + label: "Tango", + symbol: "☀", + href: (d: string) => `/tango/${d}`, + storageKey: (d: string) => `tango-${d}`, + isWon: (s: string) => { + try { return (JSON.parse(s) as (string | null)[][]).flat().every(c => c !== null); } catch { return false; } + }, + }, + { + key: "zip", + label: "Zip", + symbol: "∞", + href: (d: string) => `/zip/${d}`, + storageKey: (d: string) => `zip-${d}`, + isWon: (s: string) => { + try { return (JSON.parse(s) as unknown[]).length === 25; } catch { return false; } + }, + }, + { + key: "sudoku", + label: "Sudoku", + symbol: "#", + href: (d: string) => `/sudoku/${d}`, + storageKey: (d: string) => `sudoku-${d}`, + isWon: (s: string) => { + try { return (JSON.parse(s) as number[][]).flat().every(n => n > 0); } catch { return false; } + }, + }, + { + key: "patches", + label: "Patches", + symbol: "▦", + href: (d: string) => `/patches/${d}`, + storageKey: (d: string) => `patches-${d}`, + isWon: (s: string) => { + try { + const p = JSON.parse(s); + // patches stores the placed pieces array + return Array.isArray(p) && p.length > 0; + } catch { return false; } + }, + }, +]; + +function getPastDates(n: number): string[] { + const dates: string[] = []; + const d = new Date(); + for (let i = 0; i < n; i++) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + dates.push(`${y}-${m}-${day}`); + d.setDate(d.getDate() - 1); + } + return dates; +} + +function fmtDate(iso: string, today: string): string { + if (iso === today) return "Aujourd'hui"; + const [y, m, d] = iso.split("-").map(Number); + const date = new Date(y, m - 1, d); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if (iso === getPastDates(2)[1]) return "Hier"; + return date.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" }); +} + +function GameChip({ game, date, won }: { game: typeof GAMES[0]; date: string; won: boolean | undefined }) { + return ( + + {game.symbol} + {game.label} + {won && ( + + + + )} + + ); +} + +function ArchiveContent() { + const params = useSearchParams(); + const filter = params.get("game") ?? "all"; + const [progress, setProgress] = useState>({}); + const today = todayISO(); + const dates = useMemo(() => getPastDates(90), []); + + useEffect(() => { + const rec: Record = {}; + for (const game of GAMES) { + for (const date of dates) { + const s = localStorage.getItem(game.storageKey(date)); + if (s) { + rec[`${game.key}-${date}`] = game.isWon(s); + } + } + } + setProgress(rec); + }, [dates]); + + const visibleGames = filter === "all" ? GAMES : GAMES.filter(g => g.key === filter); + + // Compute completion summary per visible game + const summary = useMemo(() => { + return visibleGames.map(game => { + const solved = dates.filter(d => progress[`${game.key}-${d}`] === true).length; + return { key: game.key, label: game.label, solved }; + }); + }, [visibleGames, dates, progress]); + + return ( +
+ {/* Header */} +
+

Archives

+

90 derniers jours

+
+ + {/* Filter tabs */} +
+ {[{ k: "all", l: "Tous", sym: "" }, ...GAMES.map(g => ({ k: g.key, l: g.label, sym: g.symbol }))].map(({ k, l, sym }) => ( + + {sym && {sym}} + {l} + + ))} +
+ + {/* Completion summary chips */} + {Object.keys(progress).length > 0 && ( +
+ {summary.map(({ key, label, solved }) => ( +
+ {solved} + / 90 + {label} +
+ ))} +
+ )} + + {/* Date rows */} +
+ {dates.map(date => { + const isToday = date === today; + const dateLabel = fmtDate(date, today); + const solvedAll = visibleGames.every(g => progress[`${g.key}-${date}`] === true); + const solvedCount = visibleGames.filter(g => progress[`${g.key}-${date}`] === true).length; + + return ( +
+ {/* Date label */} +
+ + {dateLabel} + + {isToday && ( + Aujourd'hui + )} +
+ + {/* Game chips */} +
+ {visibleGames.map(game => ( + + ))} +
+ + {/* Right: completion indicator */} + {solvedCount > 0 && ( +
+ {solvedCount}/{visibleGames.length} +
+ )} +
+ ); + })} +
+
+ ); +} + +export default function ArchivePage() { + return ( + + {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ }> + +
+ ); +} diff --git a/app/comment-jouer/page.tsx b/app/comment-jouer/page.tsx new file mode 100644 index 0000000..d18014a --- /dev/null +++ b/app/comment-jouer/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import Link from "next/link"; +import { GAME_META, GAMES, GameId } from "@/lib/levels"; +import { GAME_RULES } from "@/lib/rules"; + +function GameSection({ game }: { game: GameId }) { + const { name, accent, symbol } = GAME_META[game]; + const rules = GAME_RULES[game]; + + return ( +
+
+ + {symbol} + +
+

{name}

+
+

{rules.subtitle}

+ · +

{rules.duration}

+
+
+
+ +
+ {rules.howToPlay.map((rule, i) => ( +
+ + {i + 1} + +

{rule}

+
+ ))} +
+ + {rules.tip && ( +
+ 💡 +

{rules.tip}

+
+ )} + + + Jouer à {name} + + +
+ ); +} + +export default function HowToPlayPage() { + return ( +
+ {/* Header */} +
+ + + +
+

Comment jouer

+

Les règles de chaque puzzle

+
+
+ + {/* Intro */} +
+

+ Puzzle Trainer propose 5 puzzles logiques renouvelés chaque jour. Chaque puzzle peut aussi être pratiqué + en mode entraînement avec 100 niveaux de difficulté progressive. +

+
+ + {/* Per-game sections */} +
+ {GAMES.map(game => ( + + ))} +
+ + + ← Retour aux puzzles du jour + +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..30f6c26 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,212 @@ +@import "tailwindcss"; + +/* ── Design tokens ──────────────────────────────────────────────────────────── */ +:root { + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-full: 9999px; + + --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04); + --shadow-hover: 0 4px 12px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.05); + --shadow-win: 0 8px 24px 0 rgb(34 197 94 / 0.18); + + --transition-base: 150ms ease; + --transition-smooth: 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ── PWA / iOS native feel ──────────────────────────────────────────────────── */ +html, body { + /* Full height accounting for iOS dynamic viewport */ + height: 100%; + min-height: -webkit-fill-available; +} + +body { + /* Prevent bounce scroll on iOS (native app feel) */ + overscroll-behavior: none; + /* Prevent text selection on interactive elements */ + -webkit-touch-callout: none; +} + +/* Tap targets: no highlight flash on iOS */ +* { -webkit-tap-highlight-color: transparent; } + +/* Game grids: no text selection, instant touch response */ +.game-grid, [data-game-grid] { + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; +} + +/* ── Reset & base ───────────────────────────────────────────────────────────── */ +* { box-sizing: border-box; } + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; + color-scheme: light; +} + +body { + background: #f8fafc; + color: #09090b; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; +} + +/* ── Focus visible ──────────────────────────────────────────────────────────── */ +:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; + border-radius: 4px; +} + +/* ── Scrollbar ──────────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #9ca3af; } + +/* ── Page fade-in ───────────────────────────────────────────────────────────── */ +@keyframes fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +main > div { animation: fadeUp 0.25s ease both; } + +/* ── Win banner animation ───────────────────────────────────────────────────── */ +@keyframes popIn { + 0% { transform: scale(0.85); opacity: 0; } + 60% { transform: scale(1.04); } + 100% { transform: scale(1); opacity: 1; } +} + +.win-banner { + animation: popIn 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +/* ── Cell flash (win feedback) ──────────────────────────────────────────────── */ +@keyframes cellWin { + 0% { background-color: inherit; } + 40% { background-color: #bbf7d0; } + 100% { background-color: inherit; } +} + +.cell-win { animation: cellWin 0.6s ease both; } + +/* ── Shake animation (error) ────────────────────────────────────────────────── */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-3px); } + 80% { transform: translateX(3px); } +} + +.shake { animation: shake 0.35s ease; } + +/* ── Skeleton loading ───────────────────────────────────────────────────────── */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton { + background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%); + background-size: 200% 100%; + animation: shimmer 1.4s ease infinite; + border-radius: var(--radius-md); +} + +/* ── Progress bar ───────────────────────────────────────────────────────────── */ +.progress-track { + height: 5px; + border-radius: var(--radius-full); + background: #e2e8f0; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: var(--radius-full); + transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ── Timer display ──────────────────────────────────────────────────────────── */ +.timer-mono { + font-variant-numeric: tabular-nums; + letter-spacing: 0.05em; +} + +/* ── Pulse ring (current level) ─────────────────────────────────────────────── */ +@keyframes pulseRing { + 0%, 100% { box-shadow: 0 0 0 0px currentColor; } + 50% { box-shadow: 0 0 0 3px currentColor; } +} + +.level-current { animation: pulseRing 1.8s ease-in-out infinite; } + +/* ── Solved card overlay ────────────────────────────────────────────────────── */ +.card-solved { + position: relative; +} +.card-solved::after { + content: "✓"; + position: absolute; + top: 6px; + right: 8px; + font-size: 11px; + font-weight: 700; + color: #16a34a; + opacity: 0.8; +} + +/* ── Dark mode ──────────────────────────────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + html { color-scheme: dark; } + + body { + background: #0f1117; + color: #f1f5f9; + } + + /* Cards & panels */ + .bg-white { background: #1e2130 !important; } + .border-gray-100 { border-color: #2a2f42 !important; } + .border-gray-200 { border-color: #363b52 !important; } + .bg-gray-50 { background: #181c27 !important; } + .bg-gray-100 { background: #252a3a !important; } + + /* Text */ + .text-gray-900 { color: #f1f5f9 !important; } + .text-gray-800 { color: #e2e8f0 !important; } + .text-gray-700 { color: #cbd5e1 !important; } + .text-gray-600 { color: #94a3b8 !important; } + .text-gray-500 { color: #64748b !important; } + .text-gray-400 { color: #475569 !important; } + .text-gray-300 { color: #374151 !important; } + + /* Header */ + header { background: rgba(15, 17, 23, 0.92) !important; border-color: #1e2130 !important; } + + /* Footer */ + footer { background: #0f1117 !important; border-color: #1e2130 !important; } + + /* Skeleton */ + .skeleton { + background: linear-gradient(90deg, #1e2130 25%, #252a3a 50%, #1e2130 75%) !important; + } + + /* Progress track */ + .progress-track { background: #252a3a !important; } + + /* Scrollbar */ + ::-webkit-scrollbar-thumb { background: #363b52; } + ::-webkit-scrollbar-thumb:hover { background: #475569; } + + /* Bottom nav */ + nav.fixed.bottom-0 { background: rgba(15, 17, 23, 0.95) !important; border-color: #1e2130 !important; } +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 0000000..a5a1d60 --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,34 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 512, height: 512 }; +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+
+ 🧩 +
+
+ ), + { ...size } + ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..fd0d5be --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,89 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; +import BottomNav from "@/components/BottomNav"; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + userScalable: false, + themeColor: "#111827", +}; + +export const metadata: Metadata = { + title: { default: "Puzzle Trainer", template: "%s — Puzzle Trainer" }, + description: "5 puzzles logiques chaque jour. Queens, Tango, Zip, Sudoku, Patches.", + applicationName: "Puzzle Trainer", + appleWebApp: { + capable: true, + title: "Puzzle Trainer", + statusBarStyle: "black-translucent", + }, + openGraph: { + title: "Puzzle Trainer", + description: "5 puzzles logiques chaque jour.", + url: "https://puzzles.reverdin.eu", + siteName: "Puzzle Trainer", + locale: "fr_FR", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Puzzle Trainer", + description: "5 puzzles logiques chaque jour.", + }, + metadataBase: new URL("https://puzzles.reverdin.eu"), + manifest: "/manifest.json", + formatDetection: { telephone: false }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {/* iOS standalone — critical meta tags */} + + + + + + {/* iOS splash screens — key iPhone sizes */} + + + + + + + {/* Service worker registration */} +