Initial commit: Sudoku SPA

React + Vite sudoku app with 5 difficulty levels, dark mode,
celebration effects, localStorage save, and deploy script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Duran
2026-02-10 23:59:52 +01:00
co-authored by Claude Opus 4.6
commit 5c07425d1d
20 changed files with 3881 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import React from 'react'
import Cell from './Cell'
import { checkValue } from '../utils/sudoku'
function Board({ board, puzzle, solution, notes, selected, showErrors, celebrating, selectCell }) {
const selectedValue = selected && board[selected.row][selected.col]
return (
<div className="board">
{board.map((row, r) =>
row.map((value, c) => {
const isGiven = puzzle[r][c] !== 0
const isSelected = selected && selected.row === r && selected.col === c
const isHighlightedRow = selected && selected.row === r && !isSelected
const isHighlightedCol = selected && selected.col === c && !isSelected
const isHighlightedBox =
selected &&
!isSelected &&
Math.floor(selected.row / 3) === Math.floor(r / 3) &&
Math.floor(selected.col / 3) === Math.floor(c / 3)
const isSameNumber =
!isSelected &&
selectedValue !== 0 &&
value !== 0 &&
value === selectedValue
const isError =
showErrors &&
!isGiven &&
value !== 0 &&
!checkValue(solution, r, c, value)
const isCelebrating = celebrating.has(`${r},${c}`)
return (
<Cell
key={`${r}-${c}`}
value={value}
notes={notes[r][c]}
isGiven={isGiven}
isSelected={isSelected}
isHighlightedRow={isHighlightedRow}
isHighlightedCol={isHighlightedCol}
isHighlightedBox={isHighlightedBox}
isSameNumber={isSameNumber}
isError={isError}
isCelebrating={isCelebrating}
onClick={() => selectCell(r, c)}
/>
)
})
)}
</div>
)
}
export default React.memo(Board)
+29
View File
@@ -0,0 +1,29 @@
import React from 'react'
function Cell({ value, notes, isGiven, isSelected, isHighlightedRow, isHighlightedCol, isHighlightedBox, isSameNumber, isError, isCelebrating, onClick }) {
const classes = ['cell']
if (isGiven) classes.push('given')
if (isSelected) classes.push('selected')
if (isHighlightedRow) classes.push('highlighted-row')
if (isHighlightedCol) classes.push('highlighted-col')
if (isHighlightedBox) classes.push('highlighted-box')
if (isSameNumber) classes.push('same-number')
if (isError) classes.push('error')
if (isCelebrating) classes.push('celebrating')
return (
<div className={classes.join(' ')} onClick={onClick}>
{value !== 0 ? (
<span className="cell-value">{value}</span>
) : notes && notes.size > 0 ? (
<div className="cell-notes">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
<span key={n} className="note">{notes.has(n) ? n : ''}</span>
))}
</div>
) : null}
</div>
)
}
export default React.memo(Cell)
+87
View File
@@ -0,0 +1,87 @@
import React, { useEffect, useRef } from 'react'
const COLORS = ['#ffd700', '#ff6b6b', '#48dbfb', '#ff9ff3', '#54a0ff', '#5f27cd', '#01a3a4', '#f368e0', '#ff9f43', '#2ed573']
const PARTICLE_COUNT = 150
const DURATION = 4000
function Confetti({ active }) {
const canvasRef = useRef(null)
useEffect(() => {
if (!active) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
let animationId
let startTime
function resize() {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
const particles = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * canvas.width,
y: -20 - Math.random() * canvas.height * 0.5,
w: 4 + Math.random() * 6,
h: 6 + Math.random() * 10,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
vx: (Math.random() - 0.5) * 4,
vy: 1.5 + Math.random() * 3,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.15,
wobble: Math.random() * Math.PI * 2,
wobbleSpeed: 0.03 + Math.random() * 0.05,
}))
function animate(time) {
if (!startTime) startTime = time
const elapsed = time - startTime
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Fade out in the last second
const fadeStart = DURATION - 1000
const globalAlpha = elapsed > fadeStart ? 1 - (elapsed - fadeStart) / 1000 : 1
for (const p of particles) {
p.x += p.vx + Math.sin(p.wobble) * 0.5
p.y += p.vy
p.rotation += p.rotationSpeed
p.wobble += p.wobbleSpeed
p.vy += 0.02 // gravity
ctx.save()
ctx.globalAlpha = globalAlpha
ctx.translate(p.x, p.y)
ctx.rotate(p.rotation)
ctx.fillStyle = p.color
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h)
ctx.restore()
}
if (elapsed < DURATION) {
animationId = requestAnimationFrame(animate)
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
}
animationId = requestAnimationFrame(animate)
return () => {
cancelAnimationFrame(animationId)
window.removeEventListener('resize', resize)
}
}, [active])
if (!active) return null
return <canvas ref={canvasRef} className="confetti-canvas" />
}
export default Confetti
+87
View File
@@ -0,0 +1,87 @@
import React, { useState } from 'react'
const DIFFICULTIES = [
{ value: 'molt-facil', label: 'Molt fàcil' },
{ value: 'facil', label: 'Fàcil' },
{ value: 'mitja', label: 'Mitjà' },
{ value: 'dificil', label: 'Difícil' },
{ value: 'expert', label: 'Expert' },
]
function GameControls({ currentDifficulty, onNewGame, onClear, onUndo, onRedo, canUndo, canRedo, onOpenSettings }) {
const [difficulty, setDifficulty] = useState(currentDifficulty)
const [showConfirm, setShowConfirm] = useState(false)
function handleNewGame() {
setShowConfirm(false)
onNewGame(difficulty)
}
return (
<div className="game-controls">
<div className="controls-row">
<select
className="difficulty-select"
value={difficulty}
onChange={e => setDifficulty(e.target.value)}
>
{DIFFICULTIES.map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<button className="btn btn-primary" onClick={() => setShowConfirm(true)}>
Nova Partida
</button>
</div>
<div className="controls-row">
<button className="btn btn-secondary" onClick={onClear} title="Esborra">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/>
<line x1="18" y1="9" x2="12" y2="15"/>
<line x1="12" y1="9" x2="18" y2="15"/>
</svg>
Esborra
</button>
<button className="btn btn-secondary" onClick={onUndo} disabled={!canUndo} title="Desfés (Ctrl+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
Desfés
</button>
<button className="btn btn-secondary" onClick={onRedo} disabled={!canRedo} title="Refés (Ctrl+Shift+Z)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"/>
</svg>
Refés
</button>
<button className="btn btn-secondary btn-icon" onClick={onOpenSettings} title="Configuració">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
{showConfirm && (
<div className="modal-overlay" onClick={() => setShowConfirm(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Nova partida?</h2>
<p>Es perdrà el progrés de la partida actual.</p>
<div className="modal-actions">
<button className="btn btn-secondary" onClick={() => setShowConfirm(false)}>
Cancel·la
</button>
<button className="btn btn-primary" onClick={handleNewGame}>
Comença
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default React.memo(GameControls)
+27
View File
@@ -0,0 +1,27 @@
import React from 'react'
function NumberPad({ onNumber, numberCounts }) {
return (
<div className="number-pad">
<div className="number-buttons">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const remaining = 9 - numberCounts[n]
const disabled = remaining <= 0
return (
<button
key={n}
className={`num-btn${disabled ? ' disabled' : ''}`}
onClick={() => !disabled && onNumber(n)}
disabled={disabled}
>
<span className="num-btn-value">{n}</span>
{remaining > 0 && <span className="num-btn-count">{remaining}</span>}
</button>
)
})}
</div>
</div>
)
}
export default React.memo(NumberPad)
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
import Timer from './Timer'
const DIFFICULTY_LABELS = {
'molt-facil': 'Molt fàcil',
'facil': 'Fàcil',
'mitja': 'Mitjà',
'dificil': 'Difícil',
'expert': 'Expert',
}
function ScoreBoard({ difficulty, errors, timer, showErrors }) {
return (
<div className="score-board">
<div className="score-item">
<span className="score-label">Dificultat</span>
<span className="score-value">{DIFFICULTY_LABELS[difficulty]}</span>
</div>
{showErrors && (
<div className="score-item">
<span className="score-label">Errors</span>
<span className="score-value errors-count">{errors}</span>
</div>
)}
<div className="score-item">
<span className="score-label">Temps</span>
<span className="score-value"><Timer seconds={timer} /></span>
</div>
</div>
)
}
export default React.memo(ScoreBoard)
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
function SettingsModal({ showErrors, showEffects, darkMode, onToggleShowErrors, onToggleShowEffects, onToggleDarkMode, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Configuraci&oacute;</h2>
<div className="settings-list">
<label className="checkbox-label">
<input
type="checkbox"
checked={showErrors}
onChange={onToggleShowErrors}
/>
<span className="toggle-track" />
Mostra errors
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={showEffects}
onChange={onToggleShowEffects}
/>
<span className="toggle-track" />
Utilitza efectes especials
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={darkMode}
onChange={onToggleDarkMode}
/>
<span className="toggle-track" />
Mode fosc
</label>
</div>
<button className="btn btn-primary btn-block" onClick={onClose}>
Tanca
</button>
</div>
</div>
)
}
export default SettingsModal
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
function Timer({ seconds }) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return (
<span className="timer">
{String(mins).padStart(2, '0')}:{String(secs).padStart(2, '0')}
</span>
)
}
export default React.memo(Timer)
+40
View File
@@ -0,0 +1,40 @@
import React from 'react'
import Timer from './Timer'
const DIFFICULTY_LABELS = {
'molt-facil': 'Molt fàcil',
'facil': 'Fàcil',
'mitja': 'Mitjà',
'dificil': 'Difícil',
'expert': 'Expert',
}
function WinModal({ timer, errors, difficulty, onNewGame }) {
return (
<div className="modal-overlay">
<div className="modal">
<h2>Felicitats!</h2>
<p>Has completat el Sudoku!</p>
<div className="modal-stats">
<div className="modal-stat">
<span className="modal-stat-label">Dificultat</span>
<span className="modal-stat-value">{DIFFICULTY_LABELS[difficulty]}</span>
</div>
<div className="modal-stat">
<span className="modal-stat-label">Temps</span>
<span className="modal-stat-value"><Timer seconds={timer} /></span>
</div>
<div className="modal-stat">
<span className="modal-stat-label">Errors</span>
<span className="modal-stat-value">{errors}</span>
</div>
</div>
<button className="btn btn-primary btn-large" onClick={onNewGame}>
Nova Partida
</button>
</div>
</div>
)
}
export default WinModal