Initial commit: Sudoku game with accounts, history, leaderboard, themes and i18n

Node/Express + SQLite backend, vanilla JS frontend, deployed via Docker Compose.
This commit is contained in:
2026-08-27 21:24:38 +02:00
commit 00e8dcb7d2
16 changed files with 2405 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
JWT_SECRET=change-me-to-a-long-random-value

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
data/
server/node_modules/
*.log

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY server/package.json ./
RUN npm install --omit=dev
COPY server/ ./
EXPOSE 3000
CMD ["node", "server.js"]

117
README.md Normal file
View File

@@ -0,0 +1,117 @@
# 🧩 Sudoku
*[English version below / Version anglaise plus bas](#-sudoku-english)*
Un jeu de Sudoku complet, jouable dans le navigateur, avec comptes utilisateurs, historique de parties, classement, thèmes de couleur et support multilingue (FR/EN) — le tout servi par un unique conteneur Docker (Node.js/Express + SQLite).
![Capture d'écran](docs/screenshot.png)
## Fonctionnalités
- **Génération de grilles** à 4 niveaux de difficulté (facile, moyen, difficile, expert), avec solution unique garantie
- **Cycle de jeu explicite** : Démarrer / Pause / Stop (aucune partie ne se lance automatiquement)
- **Aide au jeu** : notes (crayon), indices, annulation, détection des erreurs en temps réel
- **Comptes utilisateurs** : inscription (pseudo, email, mot de passe — sans validation par email), connexion, mode invité toujours disponible
- **Historique personnel** des parties (temps, erreurs, points, résultat)
- **Classement** : meilleurs temps par difficulté et classement général par points cumulés
- **Garde-fou anti-triche** : temps minimum plausible par difficulté avant qu'un score ne soit validé
- **4 thèmes de couleur** : Bleu, Émeraude, Ambre, Noir & blanc + rouge
- **Français / Anglais**, avec bascule instantanée et mémorisation du choix
- **Guide du débutant** intégré, accessible sans compte
## Stack technique
- Frontend : HTML/CSS/JS vanilla (aucune dépendance de build)
- Backend : Node.js + Express
- Base de données : SQLite (fichier persistant, monté en bind mount)
- Authentification : cookie JWT httpOnly, mots de passe hashés (bcrypt)
- Déploiement : Docker / Docker Compose
## Démarrage rapide
```bash
git clone https://gitea.harmonia-it.fr/snoopy/sudoku.git
cd sudoku
cp .env.example .env
# Éditez .env et remplacez JWT_SECRET par une valeur aléatoire longue,
# par exemple avec : openssl rand -hex 32
docker compose up -d --build
```
L'application est alors disponible sur `http://localhost:8090` (port configurable dans `docker-compose.yml`).
## Structure du projet
```
sudoku/
├── app/ # Frontend statique (HTML/CSS/JS), monté en bind mount (lecture seule)
├── server/ # Backend Node/Express (API + service des fichiers statiques)
├── data/ # Base SQLite persistante (bind mount, ignoré par git)
├── docs/ # Ressources documentaires (capture d'écran, etc.)
├── Dockerfile
├── docker-compose.yml
└── .env.example # Modèle de configuration (copier en .env)
```
## Licence
Projet personnel, libre d'utilisation et de modification.
---
# 🧩 Sudoku (English)
A full-featured Sudoku game playable in the browser, with user accounts, game history, leaderboards, color themes, and multilingual support (FR/EN) — all served by a single Docker container (Node.js/Express + SQLite).
![Screenshot](docs/screenshot.png)
## Features
- **Puzzle generation** with 4 difficulty levels (easy, medium, hard, expert), each guaranteed to have a unique solution
- **Explicit game lifecycle**: Start / Pause / Stop (no game ever starts automatically)
- **Gameplay aids**: pencil notes, hints, undo, real-time mistake detection
- **User accounts**: sign up (username, email, password — no email verification), log in, guest mode always available
- **Personal history** of games played (time, mistakes, points, result)
- **Leaderboards**: best times per difficulty and an overall ranking by cumulative points
- **Anti-cheat safeguard**: a minimum plausible solve time per difficulty before a score is accepted
- **4 color themes**: Blue, Emerald, Amber, Black & white + red
- **French / English**, switchable instantly with the choice remembered
- **Built-in beginner's guide**, accessible without an account
## Tech stack
- Frontend: vanilla HTML/CSS/JS (no build step required)
- Backend: Node.js + Express
- Database: SQLite (persistent file, bind-mounted)
- Authentication: httpOnly JWT cookie, hashed passwords (bcrypt)
- Deployment: Docker / Docker Compose
## Quick start
```bash
git clone https://gitea.harmonia-it.fr/snoopy/sudoku.git
cd sudoku
cp .env.example .env
# Edit .env and replace JWT_SECRET with a long random value,
# e.g. using: openssl rand -hex 32
docker compose up -d --build
```
The app will then be available at `http://localhost:8090` (port configurable in `docker-compose.yml`).
## Project structure
```
sudoku/
├── app/ # Static frontend (HTML/CSS/JS), bind-mounted (read-only)
├── server/ # Node/Express backend (API + static file serving)
├── data/ # Persistent SQLite database (bind mount, git-ignored)
├── docs/ # Documentation assets (screenshot, etc.)
├── Dockerfile
├── docker-compose.yml
└── .env.example # Configuration template (copy to .env)
```
## License
Personal project, free to use and modify.

535
app/game.js Normal file
View File

@@ -0,0 +1,535 @@
(function () {
"use strict";
const SIZE = 9;
const BOX = 3;
const DIFFICULTY_CLUES = { easy: 42, medium: 34, hard: 28, expert: 24 };
const DECORATIVE = [
[5,3,4,6,7,8,9,1,2],
[6,7,2,1,9,5,3,4,8],
[1,9,8,3,4,2,5,6,7],
[8,5,9,7,6,1,4,2,3],
[4,2,6,8,5,3,7,9,1],
[7,1,3,9,2,4,8,5,6],
[9,6,1,5,3,7,2,8,4],
[2,8,7,4,1,9,6,3,5],
[3,4,5,2,8,6,1,7,9],
];
const STATE = { IDLE: 'idle', RUNNING: 'running', PAUSED: 'paused', FINISHED: 'finished' };
let dom = {};
let callbacks = { onWin: null, onAbandon: null };
let state = STATE.IDLE;
let solution = [], puzzle = [], given = [], board = [], notes = [];
let selected = null;
let notesMode = false;
let history = [];
let mistakes = 0;
let timerSeconds = 0;
let timerHandle = null;
let currentDifficulty = 'medium';
// ---------- Sudoku generation ----------
function emptyGrid(){ return Array.from({length:SIZE}, () => Array(SIZE).fill(0)); }
function shuffled(arr){
const a = arr.slice();
for(let i=a.length-1;i>0;i--){
const j = Math.floor(Math.random()*(i+1));
[a[i],a[j]]=[a[j],a[i]];
}
return a;
}
function isValidPlacement(grid, row, col, val){
for(let i=0;i<SIZE;i++){
if(grid[row][i]===val) return false;
if(grid[i][col]===val) return false;
}
const br = row - row%BOX, bc = col - col%BOX;
for(let r=0;r<BOX;r++){
for(let c=0;c<BOX;c++){
if(grid[br+r][bc+c]===val) return false;
}
}
return true;
}
function fillGrid(grid){
for(let idx=0; idx<SIZE*SIZE; idx++){
const row = Math.floor(idx/SIZE), col = idx%SIZE;
if(grid[row][col]!==0) continue;
for(const val of shuffled([1,2,3,4,5,6,7,8,9])){
if(isValidPlacement(grid,row,col,val)){
grid[row][col]=val;
if(fillGrid(grid)) return true;
grid[row][col]=0;
}
}
return false;
}
return true;
}
function countSolutions(grid, limit){
let count = 0;
function backtrack(){
if(count>=limit) return;
let row=-1, col=-1;
outer:
for(let r=0;r<SIZE;r++){
for(let c=0;c<SIZE;c++){
if(grid[r][c]===0){ row=r; col=c; break outer; }
}
}
if(row===-1){ count++; return; }
for(let val=1; val<=9; val++){
if(isValidPlacement(grid,row,col,val)){
grid[row][col]=val;
backtrack();
grid[row][col]=0;
if(count>=limit) return;
}
}
}
backtrack();
return count;
}
function generatePuzzle(clueCount){
const full = emptyGrid();
fillGrid(full);
const puz = full.map(r=>r.slice());
const cells = shuffled(Array.from({length:81}, (_,i)=>i));
let removed = 0;
const toRemove = 81 - clueCount;
for(const idx of cells){
if(removed>=toRemove) break;
const row = Math.floor(idx/SIZE), col = idx%SIZE;
if(puz[row][col]===0) continue;
const backup = puz[row][col];
puz[row][col] = 0;
const testGrid = puz.map(r=>r.slice());
const solutions = countSolutions(testGrid, 2);
if(solutions !== 1){
puz[row][col] = backup;
} else {
removed++;
}
}
return { full, puzzle: puz };
}
// ---------- Rendering ----------
function renderDecorative(){
dom.board.innerHTML = '';
dom.board.classList.remove('blurred');
for(let r=0;r<SIZE;r++){
for(let c=0;c<SIZE;c++){
const cell = document.createElement('div');
cell.className = 'cell given';
const boxIndex = Math.floor(r/BOX) + Math.floor(c/BOX);
if(boxIndex % 2 === 1) cell.classList.add('alt-box');
cell.textContent = DECORATIVE[r][c];
dom.board.appendChild(cell);
}
}
dom.board.classList.add('blurred');
}
function renderBoard(){
dom.board.innerHTML = '';
for(let r=0;r<SIZE;r++){
for(let c=0;c<SIZE;c++){
const cell = document.createElement('div');
cell.className = 'cell';
const boxIndex = Math.floor(r/BOX) + Math.floor(c/BOX);
if(boxIndex % 2 === 1) cell.classList.add('alt-box');
cell.dataset.row = r;
cell.dataset.col = c;
const val = board[r][c];
if(val !== 0){
cell.textContent = val;
if(given[r][c]) cell.classList.add('given');
if(!given[r][c] && solution[r][c] !== val) cell.classList.add('error');
} else if(notes[r][c].size > 0){
const notesGrid = document.createElement('div');
notesGrid.className = 'notes';
for(let n=1;n<=9;n++){
const span = document.createElement('span');
span.textContent = notes[r][c].has(n) ? n : '';
notesGrid.appendChild(span);
}
cell.appendChild(notesGrid);
}
cell.addEventListener('click', () => selectCell(r,c));
dom.board.appendChild(cell);
}
}
applySelectionStyles();
}
function applySelectionStyles(){
const cellEls = dom.board.querySelectorAll('.cell');
cellEls.forEach(el => el.classList.remove('peer','selected','same-value'));
if(!selected) return;
const [sr, sc] = selected;
const selVal = board[sr][sc];
cellEls.forEach(el => {
const r = +el.dataset.row, c = +el.dataset.col;
const sameRow = r===sr, sameCol = c===sc;
const sameBox = Math.floor(r/BOX)===Math.floor(sr/BOX) && Math.floor(c/BOX)===Math.floor(sc/BOX);
if(sameRow || sameCol || sameBox) el.classList.add('peer');
if(r===sr && c===sc) el.classList.add('selected');
if(selVal!==0 && board[r][c]===selVal) el.classList.add('same-value');
});
}
function renderKeypad(){
dom.keypad.innerHTML = '';
const counts = Array(10).fill(0);
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++){
if(board[r][c]!==0) counts[board[r][c]]++;
}
for(let n=1;n<=9;n++){
const btn = document.createElement('button');
btn.textContent = n;
if(counts[n]>=9) btn.classList.add('count-done');
btn.addEventListener('click', () => inputNumber(n));
dom.keypad.appendChild(btn);
}
}
function updateNotesBtn(){
dom.notesBtn.classList.toggle('active', notesMode);
}
function formatTime(s){
const m = Math.floor(s/60).toString().padStart(2,'0');
const sec = (s%60).toString().padStart(2,'0');
return `${m}:${sec}`;
}
function updateUiForState(){
dom.difficulty.disabled = (state === STATE.RUNNING || state === STATE.PAUSED);
dom.startOverlay.hidden = state !== STATE.IDLE;
dom.pauseOverlay.hidden = state !== STATE.PAUSED;
dom.gameActions.hidden = !(state === STATE.RUNNING || state === STATE.PAUSED);
dom.keypad.style.visibility = (state === STATE.RUNNING) ? 'visible' : 'hidden';
dom.pauseBtn.hidden = state !== STATE.RUNNING;
dom.board.classList.toggle('blurred', state === STATE.PAUSED);
}
// ---------- Timer ----------
function startTimerTick(){
clearInterval(timerHandle);
timerHandle = setInterval(() => {
timerSeconds++;
dom.timer.textContent = formatTime(timerSeconds);
}, 1000);
}
function stopTimerTick(){
clearInterval(timerHandle);
timerHandle = null;
}
// ---------- History (undo) ----------
function pushHistory(){
history.push({
board: board.map(r=>r.slice()),
notes: notes.map(row=>row.map(s=>new Set(s))),
mistakes
});
if(history.length>200) history.shift();
}
function undo(){
if(state !== STATE.RUNNING || history.length===0) return;
const prev = history.pop();
board = prev.board;
notes = prev.notes;
mistakes = prev.mistakes;
dom.mistakes.textContent = mistakes;
renderBoard();
}
// ---------- Interaction ----------
function selectCell(r,c){
if(state !== STATE.RUNNING) return;
selected = [r,c];
applySelectionStyles();
}
function inputNumber(n){
if(state !== STATE.RUNNING || !selected) return;
const [r,c] = selected;
if(given[r][c]) return;
pushHistory();
if(notesMode){
if(board[r][c]!==0) { history.pop(); return; }
if(notes[r][c].has(n)) notes[r][c].delete(n);
else notes[r][c].add(n);
renderBoard();
return;
}
board[r][c] = n;
notes[r][c].clear();
clearPeerNotes(r,c,n);
if(solution[r][c] !== n){
mistakes++;
dom.mistakes.textContent = mistakes;
}
renderBoard();
renderKeypad();
checkWin();
}
function clearPeerNotes(row,col,n){
for(let i=0;i<SIZE;i++){
notes[row][i].delete(n);
notes[i][col].delete(n);
}
const br = row - row%BOX, bc = col - col%BOX;
for(let r=0;r<BOX;r++) for(let c=0;c<BOX;c++) notes[br+r][bc+c].delete(n);
}
function eraseCell(){
if(state !== STATE.RUNNING || !selected) return;
const [r,c] = selected;
if(given[r][c]) return;
pushHistory();
board[r][c] = 0;
notes[r][c].clear();
renderBoard();
renderKeypad();
}
function giveHint(){
if(state !== STATE.RUNNING) return;
if(!selected || board[selected[0]][selected[1]] === solution[selected[0]][selected[1]]){
const empties = [];
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++)
if(!given[r][c] && board[r][c] !== solution[r][c]) empties.push([r,c]);
if(empties.length===0) return;
selected = empties[Math.floor(Math.random()*empties.length)];
}
const [hr,hc] = selected;
pushHistory();
board[hr][hc] = solution[hr][hc];
notes[hr][hc].clear();
clearPeerNotes(hr,hc,solution[hr][hc]);
renderBoard();
renderKeypad();
const el = dom.board.querySelector(`.cell[data-row="${hr}"][data-col="${hc}"]`);
if(el){ el.classList.add('hint'); setTimeout(()=>el.classList.remove('hint'), 900); }
checkWin();
}
function checkWin(){
for(let r=0;r<SIZE;r++) for(let c=0;c<SIZE;c++){
if(board[r][c] !== solution[r][c]) return;
}
state = STATE.FINISHED;
stopTimerTick();
clearSavedState();
updateUiForState();
if(typeof callbacks.onWin === 'function'){
callbacks.onWin({ difficulty: currentDifficulty, timeSeconds: timerSeconds, mistakes });
}
}
// ---------- Persistence (in-progress game only) ----------
function saveInProgress(){
if(state !== STATE.RUNNING && state !== STATE.PAUSED) return;
try{
localStorage.setItem('sudoku-inprogress', JSON.stringify({
difficulty: currentDifficulty,
solution, puzzle, given, board,
notes: notes.map(row => row.map(s => Array.from(s))),
mistakes, timerSeconds
}));
}catch(e){}
}
function loadInProgress(){
try{
const raw = localStorage.getItem('sudoku-inprogress');
if(!raw) return false;
const data = JSON.parse(raw);
currentDifficulty = data.difficulty || 'medium';
solution = data.solution;
puzzle = data.puzzle;
given = data.given;
board = data.board;
notes = data.notes.map(row => row.map(arr => new Set(arr)));
mistakes = data.mistakes || 0;
timerSeconds = data.timerSeconds || 0;
history = [];
selected = null;
dom.difficulty.value = currentDifficulty;
dom.mistakes.textContent = mistakes;
dom.timer.textContent = formatTime(timerSeconds);
renderBoard();
renderKeypad();
state = STATE.PAUSED;
updateUiForState();
return true;
}catch(e){
return false;
}
}
function clearSavedState(){
try{ localStorage.removeItem('sudoku-inprogress'); }catch(e){}
}
// ---------- Public lifecycle ----------
function start(difficulty){
currentDifficulty = difficulty || dom.difficulty.value;
timerSeconds = 0;
mistakes = 0;
history = [];
selected = null;
notesMode = false;
updateNotesBtn();
dom.mistakes.textContent = '0';
dom.timer.textContent = '00:00';
dom.msg.textContent = '';
const clueCount = DIFFICULTY_CLUES[currentDifficulty] || 34;
const { full, puzzle: puz } = generatePuzzle(clueCount);
solution = full;
puzzle = puz;
given = puz.map(r => r.map(v => v!==0));
board = puz.map(r => r.slice());
notes = Array.from({length:SIZE}, ()=>Array.from({length:SIZE}, ()=>new Set()));
state = STATE.RUNNING;
renderBoard();
renderKeypad();
updateUiForState();
startTimerTick();
saveInProgress();
}
function pause(){
if(state !== STATE.RUNNING) return;
state = STATE.PAUSED;
stopTimerTick();
updateUiForState();
saveInProgress();
}
function resume(){
if(state !== STATE.PAUSED) return;
state = STATE.RUNNING;
startTimerTick();
updateUiForState();
selected = null;
applySelectionStyles();
}
function stop(){
if(state !== STATE.RUNNING && state !== STATE.PAUSED) return;
const ok = window.confirm(window.I18N ? window.I18N.t('confirm.stop') : 'Abandonner la partie en cours ?');
if(!ok) return;
stopTimerTick();
clearSavedState();
const snapshot = { difficulty: currentDifficulty, timeSeconds: timerSeconds, mistakes };
state = STATE.IDLE;
renderDecorative();
updateUiForState();
if(snapshot.timeSeconds >= 1 && typeof callbacks.onAbandon === 'function'){
callbacks.onAbandon(snapshot);
}
}
function reset(){
stopTimerTick();
clearSavedState();
state = STATE.IDLE;
selected = null;
renderDecorative();
updateUiForState();
}
function getState(){ return state; }
function init(cb){
callbacks = cb || {};
dom = {
board: document.getElementById('board'),
keypad: document.getElementById('keypad'),
timer: document.getElementById('timer'),
mistakes: document.getElementById('mistakes'),
difficulty: document.getElementById('difficulty'),
msg: document.getElementById('msg'),
boardWrap: document.getElementById('boardWrap'),
startOverlay: document.getElementById('startOverlay'),
startBtn: document.getElementById('startBtn'),
pauseOverlay: document.getElementById('pauseOverlay'),
resumeBtn: document.getElementById('resumeBtn'),
gameActions: document.getElementById('gameActions'),
pauseBtn: document.getElementById('pauseBtn'),
stopBtn: document.getElementById('stopBtn'),
undoBtn: document.getElementById('undoBtn'),
eraseBtn: document.getElementById('eraseBtn'),
notesBtn: document.getElementById('notesBtn'),
hintBtn: document.getElementById('hintBtn'),
};
dom.startBtn.addEventListener('click', () => start(dom.difficulty.value));
dom.resumeBtn.addEventListener('click', resume);
dom.pauseBtn.addEventListener('click', pause);
dom.stopBtn.addEventListener('click', stop);
dom.undoBtn.addEventListener('click', undo);
dom.eraseBtn.addEventListener('click', eraseCell);
dom.hintBtn.addEventListener('click', giveHint);
dom.notesBtn.addEventListener('click', () => { notesMode = !notesMode; updateNotesBtn(); });
document.addEventListener('keydown', (e) => {
if(state !== STATE.RUNNING || !selected) return;
if(e.key>='1' && e.key<='9'){ inputNumber(parseInt(e.key,10)); return; }
if(e.key==='Backspace' || e.key==='Delete' || e.key==='0'){ eraseCell(); return; }
let [r,c] = selected;
if(e.key==='ArrowUp') r = Math.max(0, r-1);
else if(e.key==='ArrowDown') r = Math.min(8, r+1);
else if(e.key==='ArrowLeft') c = Math.max(0, c-1);
else if(e.key==='ArrowRight') c = Math.min(8, c+1);
else return;
e.preventDefault();
selectCell(r,c);
});
window.addEventListener('beforeunload', saveInProgress);
setInterval(saveInProgress, 5000);
if(!loadInProgress()){
state = STATE.IDLE;
renderDecorative();
}
updateUiForState();
}
window.SudokuGame = { init, start, pause, resume, stop, reset, getState, STATE };
})();

258
app/i18n.js Normal file
View File

@@ -0,0 +1,258 @@
(function () {
"use strict";
const DICTS = {
fr: {
auth: {
login: "Se connecter",
register: "S'inscrire",
logout: "Déconnexion",
loginTab: "Connexion",
registerTab: "Inscription",
identifierLabel: "Pseudo ou email",
passwordLabel: "Mot de passe",
submitLogin: "Se connecter",
pseudoLabel: "Pseudo",
emailLabel: "Email",
submitRegister: "Créer mon compte",
benefit1: "📜 Historique de toutes vos parties",
benefit2: "⏱️ Suivi de votre vitesse de résolution",
benefit3: "🏆 Apparition au classement des joueurs",
},
banner: {
html: "Vous jouez en <b>anonyme</b>. Créez un compte gratuit pour sauvegarder votre historique, suivre votre vitesse de résolution et grimper au classement !",
},
topbar: { difficulty: "Difficulté", time: "Temps", mistakes: "Erreurs" },
difficulty: { easy: "Facile", medium: "Moyen", hard: "Difficile", expert: "Expert" },
board: { ready: "Prêt à jouer ?", start: "▶ Démarrer", paused: "⏸ En pause", resume: "▶ Reprendre" },
actions: {
pause: "⏸ Pause", stop: "⏹ Stop", undo: "↩️ Annuler", erase: "🧹 Effacer",
notes: "✏️ Notes", hint: "💡 Indice", history: "📜 Historique",
leaderboard: "🏆 Classement", rules: "📖 Règles",
},
confirm: { stop: "Abandonner la partie en cours ?" },
win: {
title: "🎉 Bravo !",
subtitle: "Grille {difficulty} résolue.",
time: "TEMPS",
mistakesLabel: "ERREURS",
pointsSaved: "+{points} points enregistrés",
saveError: "Erreur d'enregistrement : {msg}",
ctaText: "Connectez-vous pour sauvegarder ce score et apparaître au classement.",
ctaButton: "Créer un compte gratuit",
replay: "Rejouer",
},
msg: {
welcome: "Bienvenue, {pseudo} !",
accountCreated: "Compte créé, bienvenue {pseudo} !",
loggedOut: "Vous êtes déconnecté.",
loginRequiredHistory: "Connectez-vous pour consulter votre historique.",
scoreSaved: "Score enregistré : +{points} points !",
scoreSaveError: "Impossible d'enregistrer le score : {msg}",
},
history: {
title: "📜 Historique", loading: "Chargement…",
empty: "Aucune partie enregistrée pour le moment.", error: "Erreur : {msg}",
colDate: "Date", colDifficulty: "Difficulté", colTime: "Temps",
colMistakes: "Erreurs", colResult: "Résultat", colPoints: "Points",
},
leaderboard: {
title: "🏆 Classement", tabTimes: "Meilleurs temps", tabPoints: "Points",
emptyTimes: "Aucun score pour cette difficulté.", emptyPoints: "Aucun classement disponible.",
colRank: "#", colPlayer: "Joueur", colTime: "Temps", colMistakes: "Erreurs",
colPoints: "Points", colWins: "Victoires", error: "Erreur : {msg}",
},
rules: {
title: "📖 Règles & guide du débutant",
objectiveTitle: "Objectif",
objectiveText: "Remplissez la grille de 9×9 cases avec des chiffres de 1 à 9.",
goldenRulesTitle: "Les 3 règles d'or",
rule1: "Chaque ligne doit contenir les chiffres de 1 à 9, sans répétition.",
rule2: "Chaque colonne doit contenir les chiffres de 1 à 9, sans répétition.",
rule3: "Chaque bloc de 3×3 doit contenir les chiffres de 1 à 9, sans répétition.",
howToTitle: "Comment jouer sur ce site",
how1: "Cliquez sur une case vide puis sur un chiffre (ou utilisez les touches 1-9 du clavier).",
how2: "✏️ Notes : activez ce mode pour noter les chiffres possibles dans une case, utile pour explorer les hypothèses.",
how3: "💡 Indice : révèle la bonne valeur dans la case sélectionnée (ou une case au hasard).",
how4: "↩️ Annuler : revient sur votre dernière action.",
how5: "⏸ Pause / ⏹ Stop : mettez la partie en pause ou abandonnez-la à tout moment.",
tipsTitle: "Astuces pour débutants",
tip1: "Repérez d'abord les chiffres qui apparaissent le plus souvent dans la grille de départ.",
tip2: "Pour une case donnée, éliminez les chiffres déjà présents dans sa ligne, sa colonne et son bloc.",
tip3: "Si un chiffre ne peut aller qu'à un seul endroit dans un bloc, une ligne ou une colonne, placez-le.",
tip4: "Utilisez les notes pour les cases à plusieurs possibilités, puis réduisez au fur et à mesure.",
tip5: "Ne devinez pas au hasard : chaque grille a une solution logique unique.",
},
footer: { text: "Fait maison • servi depuis un conteneur Docker" },
settings: { language: "Langue", theme: "Thème" },
theme: { blue: "Bleu", green: "Émeraude", amber: "Ambre", mono: "Noir & blanc" },
errors: {
INVALID_PSEUDO: "Pseudo invalide (3 à 20 caractères, lettres/chiffres/_).",
INVALID_EMAIL: "Adresse email invalide.",
INVALID_PASSWORD: "Le mot de passe doit contenir au moins 6 caractères.",
PSEUDO_TAKEN: "Ce pseudo est déjà utilisé.",
EMAIL_TAKEN: "Cette adresse email est déjà utilisée.",
REGISTER_FAILED: "Erreur lors de l'inscription.",
MISSING_CREDENTIALS: "Identifiant et mot de passe requis.",
INVALID_CREDENTIALS: "Identifiants incorrects.",
AUTH_REQUIRED: "Authentification requise.",
RATE_LIMITED: "Trop de tentatives, réessayez plus tard.",
INVALID_DIFFICULTY: "Difficulté invalide.",
INVALID_TIME: "Temps invalide.",
INVALID_MISTAKES: "Nombre d'erreurs invalide.",
TIME_TOO_SHORT: "Temps trop court pour cette difficulté.",
UNKNOWN: "Une erreur est survenue.",
},
},
en: {
auth: {
login: "Log in",
register: "Sign up",
logout: "Log out",
loginTab: "Login",
registerTab: "Sign up",
identifierLabel: "Username or email",
passwordLabel: "Password",
submitLogin: "Log in",
pseudoLabel: "Username",
emailLabel: "Email",
submitRegister: "Create my account",
benefit1: "📜 History of all your games",
benefit2: "⏱️ Track your solving speed",
benefit3: "🏆 Appear on the player leaderboard",
},
banner: {
html: "You're playing as a <b>guest</b>. Create a free account to save your history, track your solving speed and climb the leaderboard!",
},
topbar: { difficulty: "Difficulty", time: "Time", mistakes: "Mistakes" },
difficulty: { easy: "Easy", medium: "Medium", hard: "Hard", expert: "Expert" },
board: { ready: "Ready to play?", start: "▶ Start", paused: "⏸ Paused", resume: "▶ Resume" },
actions: {
pause: "⏸ Pause", stop: "⏹ Stop", undo: "↩️ Undo", erase: "🧹 Erase",
notes: "✏️ Notes", hint: "💡 Hint", history: "📜 History",
leaderboard: "🏆 Leaderboard", rules: "📖 Rules",
},
confirm: { stop: "Abandon the current game?" },
win: {
title: "🎉 Well done!",
subtitle: "{difficulty} grid solved.",
time: "TIME",
mistakesLabel: "MISTAKES",
pointsSaved: "+{points} points saved",
saveError: "Save error: {msg}",
ctaText: "Log in to save this score and appear on the leaderboard.",
ctaButton: "Create a free account",
replay: "Play again",
},
msg: {
welcome: "Welcome, {pseudo}!",
accountCreated: "Account created, welcome {pseudo}!",
loggedOut: "You are logged out.",
loginRequiredHistory: "Log in to view your history.",
scoreSaved: "Score saved: +{points} points!",
scoreSaveError: "Could not save the score: {msg}",
},
history: {
title: "📜 History", loading: "Loading…",
empty: "No games recorded yet.", error: "Error: {msg}",
colDate: "Date", colDifficulty: "Difficulty", colTime: "Time",
colMistakes: "Mistakes", colResult: "Result", colPoints: "Points",
},
leaderboard: {
title: "🏆 Leaderboard", tabTimes: "Best times", tabPoints: "Points",
emptyTimes: "No scores for this difficulty.", emptyPoints: "No leaderboard available.",
colRank: "#", colPlayer: "Player", colTime: "Time", colMistakes: "Mistakes",
colPoints: "Points", colWins: "Wins", error: "Error: {msg}",
},
rules: {
title: "📖 Rules & beginner's guide",
objectiveTitle: "Objective",
objectiveText: "Fill the 9×9 grid with numbers from 1 to 9.",
goldenRulesTitle: "The 3 golden rules",
rule1: "Each row must contain the numbers 1 to 9, without repetition.",
rule2: "Each column must contain the numbers 1 to 9, without repetition.",
rule3: "Each 3×3 block must contain the numbers 1 to 9, without repetition.",
howToTitle: "How to play on this site",
how1: "Click an empty cell then a number (or use the 1-9 keyboard keys).",
how2: "✏️ Notes: turn this on to jot down candidate numbers in a cell — handy for exploring possibilities.",
how3: "💡 Hint: reveals the correct value in the selected cell (or a random one).",
how4: "↩️ Undo: reverts your last action.",
how5: "⏸ Pause / ⏹ Stop: pause the game or abandon it at any time.",
tipsTitle: "Tips for beginners",
tip1: "Start by spotting the numbers that appear most often in the starting grid.",
tip2: "For a given cell, eliminate numbers already present in its row, column and block.",
tip3: "If a number can only go in one spot within a block, row or column, place it there.",
tip4: "Use notes for cells with several possibilities, then narrow them down as you go.",
tip5: "Don't guess randomly: every grid has a unique logical solution.",
},
footer: { text: "Homemade • served from a Docker container" },
settings: { language: "Language", theme: "Theme" },
theme: { blue: "Blue", green: "Emerald", amber: "Amber", mono: "Black & white" },
errors: {
INVALID_PSEUDO: "Invalid username (3 to 20 characters, letters/digits/_).",
INVALID_EMAIL: "Invalid email address.",
INVALID_PASSWORD: "Password must be at least 6 characters.",
PSEUDO_TAKEN: "This username is already taken.",
EMAIL_TAKEN: "This email address is already in use.",
REGISTER_FAILED: "An error occurred during registration.",
MISSING_CREDENTIALS: "Username/email and password required.",
INVALID_CREDENTIALS: "Incorrect credentials.",
AUTH_REQUIRED: "Authentication required.",
RATE_LIMITED: "Too many attempts, please try again later.",
INVALID_DIFFICULTY: "Invalid difficulty.",
INVALID_TIME: "Invalid time.",
INVALID_MISTAKES: "Invalid number of mistakes.",
TIME_TOO_SHORT: "Time too short for this difficulty.",
UNKNOWN: "An error occurred.",
},
},
};
function detectInitialLang() {
try {
const saved = localStorage.getItem('sudoku-lang');
if (saved === 'fr' || saved === 'en') return saved;
} catch (e) {}
return 'fr';
}
let lang = detectInitialLang();
function get(dict, path) {
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), dict);
}
function t(key, vars) {
let str = get(DICTS[lang], key);
if (str === undefined) str = get(DICTS.fr, key);
if (str === undefined) return key;
if (vars) {
Object.keys(vars).forEach(k => {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function applyTranslations() {
document.documentElement.lang = lang;
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
}
function setLang(l) {
if (l !== 'fr' && l !== 'en') return;
lang = l;
try { localStorage.setItem('sudoku-lang', l); } catch (e) {}
applyTranslations();
if (typeof window.onLangChange === 'function') window.onLangChange(l);
}
window.I18N = { t, setLang, getLang: () => lang, applyTranslations };
applyTranslations();
})();

227
app/index.html Normal file
View File

@@ -0,0 +1,227 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Sudoku</title>
<link rel="stylesheet" href="styles.css">
<script>
(function(){
try{
var theme = localStorage.getItem('sudoku-theme');
if(theme) document.documentElement.dataset.theme = theme;
var lang = localStorage.getItem('sudoku-lang');
if(lang) document.documentElement.lang = lang;
}catch(e){}
})();
</script>
</head>
<body>
<div class="app">
<h1>🧩 Sudoku</h1>
<div class="settings-bar">
<div class="lang-switch" id="langSwitch">
<button class="lang-btn" data-lang="fr">FR</button>
<button class="lang-btn" data-lang="en">EN</button>
</div>
<div class="theme-switch" id="themeSwitch">
<button class="theme-swatch" data-theme="blue"></button>
<button class="theme-swatch" data-theme="green"></button>
<button class="theme-swatch" data-theme="amber"></button>
<button class="theme-swatch" data-theme="mono"></button>
</div>
</div>
<div class="authbar" id="authbar"></div>
<div class="anon-banner" id="anonBanner" hidden>
<div class="txt" data-i18n-html="banner.html"></div>
<div class="btns">
<button id="anonBannerRegister" class="primary" data-i18n="auth.register"></button>
<button id="anonBannerLogin" data-i18n="auth.login"></button>
</div>
<button class="close-x" id="anonBannerClose" title="Fermer">✕</button>
</div>
<div class="topbar">
<div class="stat"><span data-i18n="topbar.difficulty"></span>
<select id="difficulty">
<option value="easy" data-i18n="difficulty.easy"></option>
<option value="medium" selected data-i18n="difficulty.medium"></option>
<option value="hard" data-i18n="difficulty.hard"></option>
<option value="expert" data-i18n="difficulty.expert"></option>
</select>
</div>
<div class="stat"><span data-i18n="topbar.time"></span> <span class="value" id="timer">00:00</span></div>
<div class="stat"><span data-i18n="topbar.mistakes"></span> <span class="value" id="mistakes">0</span></div>
</div>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board"></div>
<div class="board-overlay" id="startOverlay">
<p data-i18n="board.ready"></p>
<button class="big-btn" id="startBtn" data-i18n="board.start"></button>
</div>
<div class="board-overlay" id="pauseOverlay" hidden>
<p data-i18n="board.paused"></p>
<button class="big-btn" id="resumeBtn" data-i18n="board.resume"></button>
</div>
</div>
<div class="keypad" id="keypad"></div>
<div class="actions" id="gameActions" hidden>
<button id="pauseBtn" data-i18n="actions.pause"></button>
<button id="stopBtn" data-i18n="actions.stop"></button>
<button id="undoBtn" data-i18n="actions.undo"></button>
<button id="eraseBtn" data-i18n="actions.erase"></button>
<button id="notesBtn" data-i18n="actions.notes"></button>
<button id="hintBtn" data-i18n="actions.hint"></button>
</div>
<div class="actions">
<button id="rulesBtn" data-i18n="actions.rules"></button>
<button id="historyBtn" data-i18n="actions.history"></button>
<button id="leaderboardBtn" data-i18n="actions.leaderboard"></button>
</div>
<div class="msg" id="msg"></div>
<footer data-i18n="footer.text"></footer>
</div>
<!-- Win modal -->
<div class="modal-overlay" id="winModal">
<div class="modal-card win">
<button class="modal-close">✕</button>
<h2 data-i18n="win.title"></h2>
<p class="sub" id="winSubtitle"></p>
<div class="win-stats">
<div class="stat"><span class="v" id="winTime"></span><span class="l" data-i18n="win.time"></span></div>
<div class="stat"><span class="v" id="winMistakes"></span><span class="l" data-i18n="win.mistakesLabel"></span></div>
</div>
<p class="sub" id="winPoints"></p>
<div id="winCta" hidden style="margin-bottom:14px;">
<p class="sub" style="margin-top:0;" data-i18n="win.ctaText"></p>
<button class="full-btn" id="winRegisterBtn" data-i18n="win.ctaButton"></button>
</div>
<button class="full-btn" id="winCloseBtn" data-i18n="win.replay"></button>
</div>
</div>
<!-- Auth modal -->
<div class="modal-overlay" id="authModal">
<div class="modal-card">
<button class="modal-close">✕</button>
<div class="tabs">
<button id="authTabLogin" class="active" data-i18n="auth.loginTab"></button>
<button id="authTabRegister" data-i18n="auth.registerTab"></button>
</div>
<p class="error-text" id="authError"></p>
<form id="loginForm" class="stack">
<div class="field">
<label for="loginIdentifier" data-i18n="auth.identifierLabel"></label>
<input type="text" id="loginIdentifier" required autocomplete="username">
</div>
<div class="field">
<label for="loginPassword" data-i18n="auth.passwordLabel"></label>
<input type="password" id="loginPassword" required autocomplete="current-password">
</div>
<button type="submit" class="full-btn" data-i18n="auth.submitLogin"></button>
</form>
<form id="registerForm" class="stack" hidden>
<ul class="benefits-list">
<li data-i18n="auth.benefit1"></li>
<li data-i18n="auth.benefit2"></li>
<li data-i18n="auth.benefit3"></li>
</ul>
<div class="field">
<label for="registerPseudo" data-i18n="auth.pseudoLabel"></label>
<input type="text" id="registerPseudo" required minlength="3" maxlength="20" autocomplete="username">
</div>
<div class="field">
<label for="registerEmail" data-i18n="auth.emailLabel"></label>
<input type="email" id="registerEmail" required autocomplete="email">
</div>
<div class="field">
<label for="registerPassword" data-i18n="auth.passwordLabel"></label>
<input type="password" id="registerPassword" required minlength="6" autocomplete="new-password">
</div>
<button type="submit" class="full-btn" data-i18n="auth.submitRegister"></button>
</form>
</div>
</div>
<!-- History modal -->
<div class="modal-overlay" id="historyModal">
<div class="modal-card" style="max-width:480px;">
<button class="modal-close">✕</button>
<h2 data-i18n="history.title"></h2>
<div id="historyBody"></div>
</div>
</div>
<!-- Leaderboard modal -->
<div class="modal-overlay" id="leaderboardModal">
<div class="modal-card" style="max-width:480px;">
<button class="modal-close">✕</button>
<h2 data-i18n="leaderboard.title"></h2>
<div class="tabs">
<button id="lbTabTimes" class="active" data-i18n="leaderboard.tabTimes"></button>
<button id="lbTabPoints" data-i18n="leaderboard.tabPoints"></button>
</div>
<div class="diff-tabs" id="lbDiffTabs">
<button data-diff="easy" data-i18n="difficulty.easy"></button>
<button data-diff="medium" class="active" data-i18n="difficulty.medium"></button>
<button data-diff="hard" data-i18n="difficulty.hard"></button>
<button data-diff="expert" data-i18n="difficulty.expert"></button>
</div>
<div id="leaderboardBody"></div>
</div>
</div>
<!-- Rules / beginner's guide modal -->
<div class="modal-overlay" id="rulesModal">
<div class="modal-card rules" style="max-width:480px;">
<button class="modal-close">✕</button>
<h2 data-i18n="rules.title"></h2>
<h3 data-i18n="rules.objectiveTitle"></h3>
<p data-i18n="rules.objectiveText"></p>
<h3 data-i18n="rules.goldenRulesTitle"></h3>
<ol>
<li data-i18n="rules.rule1"></li>
<li data-i18n="rules.rule2"></li>
<li data-i18n="rules.rule3"></li>
</ol>
<h3 data-i18n="rules.howToTitle"></h3>
<ul>
<li data-i18n="rules.how1"></li>
<li data-i18n="rules.how2"></li>
<li data-i18n="rules.how3"></li>
<li data-i18n="rules.how4"></li>
<li data-i18n="rules.how5"></li>
</ul>
<h3 data-i18n="rules.tipsTitle"></h3>
<ul>
<li data-i18n="rules.tip1"></li>
<li data-i18n="rules.tip2"></li>
<li data-i18n="rules.tip3"></li>
<li data-i18n="rules.tip4"></li>
<li data-i18n="rules.tip5"></li>
</ul>
</div>
</div>
<script src="i18n.js"></script>
<script src="game.js"></script>
<script src="main.js"></script>
</body>
</html>

409
app/main.js Normal file
View File

@@ -0,0 +1,409 @@
(function () {
"use strict";
let currentUser = null;
let pendingScore = null;
function diffLabel(d) { return I18N.t('difficulty.' + d); }
// ---------- API helper ----------
async function api(path, options) {
const opts = Object.assign({ credentials: 'include', headers: {} }, options);
if (opts.body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(path, opts);
let data = null;
try { data = await res.json(); } catch (e) { data = null; }
if (!res.ok) {
const code = (data && data.error) || 'UNKNOWN';
throw new Error(I18N.t('errors.' + code));
}
return data;
}
// ---------- Modal helpers ----------
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('show');
});
});
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', () => btn.closest('.modal-overlay').classList.remove('show'));
});
// ---------- Theme ----------
const THEMES = ['blue', 'green', 'amber', 'mono'];
function applyTheme(name) {
if (!THEMES.includes(name)) name = 'blue';
document.documentElement.dataset.theme = name;
try { localStorage.setItem('sudoku-theme', name); } catch (e) {}
document.querySelectorAll('.theme-swatch').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === name);
});
}
document.querySelectorAll('.theme-swatch').forEach(btn => {
btn.addEventListener('click', () => applyTheme(btn.dataset.theme));
});
// ---------- Language ----------
function syncLangButtons() {
const lang = I18N.getLang();
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.lang === lang);
});
}
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.addEventListener('click', () => I18N.setLang(btn.dataset.lang));
});
window.onLangChange = function () {
syncLangButtons();
renderAuthbar();
// Re-render whichever modal body is currently open, since their content is JS-generated
if (document.getElementById('leaderboardModal').classList.contains('show')) loadLeaderboard();
if (document.getElementById('historyModal').classList.contains('show')) loadHistory();
};
// ---------- Auth bar ----------
function renderAuthbar() {
const bar = document.getElementById('authbar');
const banner = document.getElementById('anonBanner');
bar.innerHTML = '';
if (currentUser) {
banner.hidden = true;
const who = document.createElement('div');
who.className = 'who';
who.innerHTML = `👤 <b>${escapeHtml(currentUser.pseudo)}</b> · <span class="pts">${currentUser.totalPoints || 0} pts</span>`;
const actions = document.createElement('div');
actions.className = 'actions-inline';
const logoutBtn = document.createElement('button');
logoutBtn.textContent = I18N.t('auth.logout');
logoutBtn.addEventListener('click', logout);
actions.appendChild(logoutBtn);
bar.appendChild(who);
bar.appendChild(actions);
} else {
const dismissed = localStorage.getItem('sudoku-anon-banner-dismissed') === '1';
banner.hidden = dismissed;
const who = document.createElement('div');
who.className = 'who';
who.textContent = '🔓 ' + (I18N.getLang() === 'en' ? 'Guest mode' : 'Mode anonyme');
const actions = document.createElement('div');
actions.className = 'actions-inline';
const loginBtn = document.createElement('button');
loginBtn.textContent = I18N.t('auth.login');
loginBtn.addEventListener('click', () => openAuthModal('login'));
const registerBtn = document.createElement('button');
registerBtn.textContent = I18N.t('auth.register');
registerBtn.className = 'primary';
registerBtn.addEventListener('click', () => openAuthModal('register'));
actions.appendChild(loginBtn);
actions.appendChild(registerBtn);
bar.appendChild(who);
bar.appendChild(actions);
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function refreshAuth() {
try {
const data = await api('/api/auth/me');
currentUser = data.user;
} catch (e) {
currentUser = null;
}
renderAuthbar();
}
async function logout() {
try { await api('/api/auth/logout', { method: 'POST' }); } catch (e) {}
currentUser = null;
renderAuthbar();
setMsg(I18N.t('msg.loggedOut'));
}
function setMsg(text) {
document.getElementById('msg').textContent = text || '';
}
// ---------- Anonymous banner ----------
document.getElementById('anonBannerClose').addEventListener('click', () => {
localStorage.setItem('sudoku-anon-banner-dismissed', '1');
document.getElementById('anonBanner').hidden = true;
});
document.getElementById('anonBannerLogin').addEventListener('click', () => openAuthModal('login'));
document.getElementById('anonBannerRegister').addEventListener('click', () => openAuthModal('register'));
// ---------- Auth modal ----------
function openAuthModal(tab) {
setAuthTab(tab || 'login');
document.getElementById('authError').textContent = '';
openModal('authModal');
}
function setAuthTab(tab) {
const isLogin = tab === 'login';
document.getElementById('authTabLogin').classList.toggle('active', isLogin);
document.getElementById('authTabRegister').classList.toggle('active', !isLogin);
document.getElementById('loginForm').hidden = !isLogin;
document.getElementById('registerForm').hidden = isLogin;
document.getElementById('authError').textContent = '';
}
document.getElementById('authTabLogin').addEventListener('click', () => setAuthTab('login'));
document.getElementById('authTabRegister').addEventListener('click', () => setAuthTab('register'));
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const identifier = document.getElementById('loginIdentifier').value.trim();
const password = document.getElementById('loginPassword').value;
const errEl = document.getElementById('authError');
errEl.textContent = '';
try {
const user = await api('/api/auth/login', { method: 'POST', body: { identifier, password } });
await refreshAuth();
closeModal('authModal');
e.target.reset();
setMsg(I18N.t('msg.welcome', { pseudo: user.pseudo }));
await maybeSubmitPendingScore();
} catch (err) {
errEl.textContent = err.message;
}
});
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const pseudo = document.getElementById('registerPseudo').value.trim();
const email = document.getElementById('registerEmail').value.trim();
const password = document.getElementById('registerPassword').value;
const errEl = document.getElementById('authError');
errEl.textContent = '';
try {
const user = await api('/api/auth/register', { method: 'POST', body: { pseudo, email, password } });
await refreshAuth();
closeModal('authModal');
e.target.reset();
setMsg(I18N.t('msg.accountCreated', { pseudo: user.pseudo }));
await maybeSubmitPendingScore();
} catch (err) {
errEl.textContent = err.message;
}
});
async function maybeSubmitPendingScore() {
if (!pendingScore || !currentUser) return;
try {
const saved = await api('/api/games', { method: 'POST', body: {
difficulty: pendingScore.difficulty,
time_seconds: pendingScore.timeSeconds,
mistakes: pendingScore.mistakes,
won: true,
}});
setMsg(I18N.t('msg.scoreSaved', { points: saved.points }));
pendingScore = null;
await refreshAuth();
} catch (e) {
setMsg(I18N.t('msg.scoreSaveError', { msg: e.message }));
}
}
// ---------- History modal ----------
async function loadHistory() {
const body = document.getElementById('historyBody');
body.innerHTML = `<p class="empty-note">${I18N.t('history.loading')}</p>`;
try {
const data = await api('/api/games/history');
if (!data.history.length) {
body.innerHTML = `<p class="empty-note">${I18N.t('history.empty')}</p>`;
return;
}
const rows = data.history.map(g => `
<tr>
<td>${new Date(g.created_at + 'Z').toLocaleString(I18N.getLang() === 'en' ? 'en-US' : 'fr-FR', { dateStyle: 'short', timeStyle: 'short' })}</td>
<td>${diffLabel(g.difficulty)}</td>
<td>${formatTime(g.time_seconds)}</td>
<td>${g.mistakes}</td>
<td>${g.won ? '✅' : '❌'}</td>
<td>${g.points}</td>
</tr>`).join('');
body.innerHTML = `
<div style="overflow-x:auto">
<table class="data-table">
<thead><tr>
<th>${I18N.t('history.colDate')}</th><th>${I18N.t('history.colDifficulty')}</th>
<th>${I18N.t('history.colTime')}</th><th>${I18N.t('history.colMistakes')}</th>
<th>${I18N.t('history.colResult')}</th><th>${I18N.t('history.colPoints')}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (e) {
body.innerHTML = `<p class="empty-note">${I18N.t('history.error', { msg: e.message })}</p>`;
}
}
document.getElementById('historyBtn').addEventListener('click', async () => {
if (!currentUser) {
openAuthModal('login');
setMsg(I18N.t('msg.loginRequiredHistory'));
return;
}
openModal('historyModal');
loadHistory();
});
function formatTime(s) {
const m = Math.floor(s / 60).toString().padStart(2, '0');
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
// ---------- Rules modal ----------
document.getElementById('rulesBtn').addEventListener('click', () => openModal('rulesModal'));
// ---------- Leaderboard modal ----------
let leaderboardMode = 'times';
let leaderboardDiff = 'medium';
document.getElementById('leaderboardBtn').addEventListener('click', () => {
openModal('leaderboardModal');
loadLeaderboard();
});
document.getElementById('lbTabTimes').addEventListener('click', () => { leaderboardMode = 'times'; syncLbTabs(); loadLeaderboard(); });
document.getElementById('lbTabPoints').addEventListener('click', () => { leaderboardMode = 'points'; syncLbTabs(); loadLeaderboard(); });
function syncLbTabs() {
document.getElementById('lbTabTimes').classList.toggle('active', leaderboardMode === 'times');
document.getElementById('lbTabPoints').classList.toggle('active', leaderboardMode === 'points');
document.getElementById('lbDiffTabs').hidden = leaderboardMode !== 'times';
}
document.querySelectorAll('#lbDiffTabs button').forEach(btn => {
btn.addEventListener('click', () => {
leaderboardDiff = btn.dataset.diff;
document.querySelectorAll('#lbDiffTabs button').forEach(b => b.classList.toggle('active', b === btn));
loadLeaderboard();
});
});
async function loadLeaderboard() {
const body = document.getElementById('leaderboardBody');
body.innerHTML = `<p class="empty-note">${I18N.t('history.loading')}</p>`;
try {
if (leaderboardMode === 'times') {
const data = await api(`/api/leaderboard/best-times?difficulty=${leaderboardDiff}`);
if (!data.entries.length) {
body.innerHTML = `<p class="empty-note">${I18N.t('leaderboard.emptyTimes')}</p>`;
return;
}
const rows = data.entries.map((e, i) => `
<tr><td>${i + 1}</td><td>${escapeHtml(e.pseudo)}</td><td>${formatTime(e.time_seconds)}</td><td>${e.mistakes}</td></tr>
`).join('');
body.innerHTML = `
<div style="overflow-x:auto">
<table class="data-table">
<thead><tr>
<th>${I18N.t('leaderboard.colRank')}</th><th>${I18N.t('leaderboard.colPlayer')}</th>
<th>${I18N.t('leaderboard.colTime')}</th><th>${I18N.t('leaderboard.colMistakes')}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} else {
const data = await api('/api/leaderboard/points');
if (!data.entries.length) {
body.innerHTML = `<p class="empty-note">${I18N.t('leaderboard.emptyPoints')}</p>`;
return;
}
const rows = data.entries.map((e, i) => `
<tr><td>${i + 1}</td><td>${escapeHtml(e.pseudo)}</td><td>${e.total_points}</td><td>${e.wins}</td></tr>
`).join('');
body.innerHTML = `
<div style="overflow-x:auto">
<table class="data-table">
<thead><tr>
<th>${I18N.t('leaderboard.colRank')}</th><th>${I18N.t('leaderboard.colPlayer')}</th>
<th>${I18N.t('leaderboard.colPoints')}</th><th>${I18N.t('leaderboard.colWins')}</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
} catch (e) {
body.innerHTML = `<p class="empty-note">${I18N.t('leaderboard.error', { msg: e.message })}</p>`;
}
}
// ---------- Win modal ----------
function showWinModal({ difficulty, timeSeconds, mistakes }) {
document.getElementById('winSubtitle').textContent = I18N.t('win.subtitle', { difficulty: diffLabel(difficulty) });
document.getElementById('winTime').textContent = formatTime(timeSeconds);
document.getElementById('winMistakes').textContent = mistakes;
const cta = document.getElementById('winCta');
const pointsLine = document.getElementById('winPoints');
pointsLine.textContent = '';
if (currentUser) {
cta.hidden = true;
api('/api/games', { method: 'POST', body: { difficulty, time_seconds: timeSeconds, mistakes, won: true } })
.then(saved => {
pointsLine.textContent = I18N.t('win.pointsSaved', { points: saved.points });
return refreshAuth();
})
.catch(err => { pointsLine.textContent = I18N.t('win.saveError', { msg: err.message }); });
} else {
pendingScore = { difficulty, timeSeconds, mistakes };
cta.hidden = false;
}
openModal('winModal');
}
document.getElementById('winRegisterBtn').addEventListener('click', () => {
closeModal('winModal');
openAuthModal('register');
});
document.getElementById('winCloseBtn').addEventListener('click', () => {
closeModal('winModal');
window.SudokuGame.reset();
});
// ---------- Abandon ----------
async function handleAbandon({ difficulty, timeSeconds, mistakes }) {
if (!currentUser) return;
try {
await api('/api/games', { method: 'POST', body: { difficulty, time_seconds: timeSeconds, mistakes, won: false } });
} catch (e) { /* silent */ }
}
// ---------- Boot ----------
applyTheme(localStorage.getItem('sudoku-theme') || 'blue');
syncLangButtons();
window.SudokuGame.init({ onWin: showWinModal, onAbandon: handleAbandon });
refreshAuth();
})();

490
app/styles.css Normal file
View File

@@ -0,0 +1,490 @@
:root{
--bg:#1b1e27;
--panel:#242836;
--cell:#2c3142;
--cell-alt:#323851;
--line-thin:#454c66;
--line-thick:#7d88b8;
--text:#eef0f7;
--muted:#9aa2c0;
--accent:#5b8cff;
--accent-soft:#33395222;
--given:#c9cfe8;
--user:#8fc7ff;
--error:#ff6b6b;
--success:#4ade80;
--select:#3a4a7a;
--peer:#2f3650;
--same:#3d5a8a;
--warn:#f5b942;
}
:root[data-theme="green"]{
--accent:#34d399;
--accent-soft:#1f6f5722;
--user:#7ee3bd;
--select:#1f5b48;
--same:#227a5e;
--peer:#20352f;
--given:#bfe8d4;
}
:root[data-theme="amber"]{
--accent:#fb923c;
--accent-soft:#8a4a1c22;
--user:#ffce9e;
--select:#5a3820;
--same:#7a4d28;
--peer:#332a22;
--given:#f0d2b0;
}
:root[data-theme="mono"]{
--bg:#000000;
--panel:#0a0a0a;
--cell:#141414;
--cell-alt:#1c1c1c;
--line-thin:#3a3a3a;
--line-thick:#6b6b6b;
--text:#ffffff;
--muted:#a0a0a0;
--accent:#e11d2e;
--accent-soft:#4d0a0e22;
--given:#ffffff;
--user:#ff6b6b;
--select:#3a0d10;
--peer:#1a1a1a;
--same:#5c1015;
}
*{box-sizing:border-box;}
html,body{height:100%;}
body{
margin:0;
background:radial-gradient(circle at top, #232838, #14161d 70%);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;
align-items:flex-start;
justify-content:center;
min-height:100vh;
padding:16px;
}
.app{
width:100%;
max-width:520px;
display:flex;
flex-direction:column;
gap:14px;
}
h1{
margin:0;
font-size:1.6rem;
letter-spacing:0.04em;
text-align:center;
font-weight:700;
}
[hidden]{ display:none !important; }
select, button, input{
font-family:inherit;
font-size:0.9rem;
border-radius:10px;
border:1px solid var(--line-thin);
background:var(--cell);
color:var(--text);
padding:8px 10px;
cursor:pointer;
}
input{ cursor:text; }
select:hover, button:hover{ border-color:var(--accent); }
button:active{ transform:translateY(1px); }
button:disabled{ opacity:0.4; cursor:not-allowed; }
/* ---------- Settings bar (language + theme) ---------- */
.settings-bar{
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
}
.lang-switch{
display:flex;
gap:4px;
background:var(--panel);
border-radius:10px;
padding:3px;
}
.lang-btn{
padding:5px 12px;
font-size:0.75rem;
font-weight:600;
border:none;
background:transparent;
border-radius:8px;
}
.lang-btn.active{ background:var(--accent); color:#0c1220; }
.theme-switch{
display:flex;
align-items:center;
gap:8px;
background:var(--panel);
border-radius:10px;
padding:6px 12px;
}
.theme-swatch{
width:20px;
height:20px;
border-radius:50%;
padding:0;
border:2px solid transparent;
cursor:pointer;
}
.theme-swatch[data-theme="blue"]{ background:#5b8cff; }
.theme-swatch[data-theme="green"]{ background:#34d399; }
.theme-swatch[data-theme="amber"]{ background:#fb923c; }
.theme-swatch[data-theme="mono"]{ background:conic-gradient(#000 0% 33.33%, #fff 33.33% 66.66%, #e11d2e 66.66% 100%); }
.theme-swatch.active{ border-color:var(--text); }
/* ---------- Auth bar ---------- */
.authbar{
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
background:var(--panel);
border-radius:14px;
padding:8px 14px;
font-size:0.85rem;
}
.authbar .who{ color:var(--muted); }
.authbar .who b{ color:var(--text); }
.authbar .pts{ color:var(--warn); font-weight:700; }
.authbar .actions-inline{ display:flex; gap:6px; }
.authbar button{
padding:6px 10px;
font-size:0.8rem;
background:transparent;
}
.authbar button.primary{
background:var(--accent);
border-color:var(--accent);
color:#0c1220;
font-weight:700;
}
.anon-banner{
display:flex;
align-items:center;
gap:10px;
background:linear-gradient(135deg, #2a3350, #262b45);
border:1px solid var(--line-thin);
border-radius:12px;
padding:10px 12px;
font-size:0.82rem;
color:var(--muted);
}
.anon-banner .txt{ flex:1; line-height:1.4; }
.anon-banner .txt b{ color:var(--text); }
.anon-banner .btns{ display:flex; flex-direction:column; gap:6px; }
.anon-banner button{ padding:6px 10px; font-size:0.78rem; white-space:nowrap; }
.anon-banner .close-x{
background:none; border:none; color:var(--muted); font-size:1rem;
padding:2px 6px; align-self:flex-start;
}
.topbar{
display:flex;
justify-content:space-between;
align-items:center;
background:var(--panel);
border-radius:14px;
padding:10px 16px;
font-variant-numeric:tabular-nums;
box-shadow:0 4px 18px rgba(0,0,0,0.35);
}
.topbar .stat{
display:flex;
flex-direction:column;
align-items:center;
font-size:0.75rem;
color:var(--muted);
gap:2px;
}
.topbar .stat span.value{
font-size:1.05rem;
color:var(--text);
font-weight:600;
}
.board-wrap{
position:relative;
background:var(--panel);
border-radius:16px;
padding:14px;
box-shadow:0 6px 24px rgba(0,0,0,0.4);
}
.board{
display:grid;
grid-template-columns:repeat(9, 1fr);
grid-template-rows:repeat(9, 1fr);
width:100%;
aspect-ratio:1/1;
border:2px solid var(--line-thick);
border-radius:8px;
overflow:hidden;
background:var(--line-thin);
gap:1px;
transition:filter 0.2s ease;
}
.board.blurred{ filter:blur(6px); pointer-events:none; }
.cell{
background:var(--cell);
display:flex;
align-items:center;
justify-content:center;
position:relative;
font-size:clamp(16px, 4.2vw, 26px);
font-weight:600;
color:var(--user);
cursor:pointer;
user-select:none;
}
.cell.alt-box{ background:var(--cell-alt); }
.cell.given{ color:var(--given); font-weight:700; cursor:default; }
.cell.peer{ background:var(--peer); }
.cell.alt-box.peer{ background:#37406a; }
.cell.same-value{ background:var(--same); }
.cell.selected{ background:var(--select); box-shadow:inset 0 0 0 2px var(--accent); }
.cell.error{ color:var(--error) !important; }
.cell.hint{ color:var(--success) !important; }
.cell:nth-child(3n){ border-right:2px solid var(--line-thick); }
.cell:nth-child(9n){ border-right:none; }
.notes{
position:absolute;
inset:0;
display:grid;
grid-template-columns:repeat(3,1fr);
grid-template-rows:repeat(3,1fr);
font-size:clamp(7px, 1.6vw, 10px);
font-weight:500;
color:var(--muted);
padding:2px;
}
.notes span{ display:flex; align-items:center; justify-content:center; }
.board-overlay{
position:absolute;
inset:14px;
background:rgba(20,22,30,0.6);
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
gap:12px;
border-radius:8px;
z-index:5;
text-align:center;
padding:16px;
}
.board-overlay[hidden]{ display:none; }
.board-overlay p{ margin:0; font-size:1.1rem; font-weight:700; color:var(--text); }
.board-overlay .big-btn{
padding:14px 28px;
font-size:1.1rem;
font-weight:700;
background:var(--accent);
border-color:var(--accent);
color:#0c1220;
}
.keypad{
display:grid;
grid-template-columns:repeat(9, 1fr);
gap:6px;
}
.keypad button{
padding:12px 0;
font-size:1.1rem;
font-weight:700;
background:var(--cell);
}
.keypad button.count-done{
opacity:0.35;
pointer-events:none;
}
.actions{
display:flex;
gap:8px;
justify-content:center;
flex-wrap:wrap;
}
.actions button{
display:flex;
align-items:center;
gap:6px;
flex:1 1 auto;
justify-content:center;
min-width:80px;
}
.actions button.active{
border-color:var(--accent);
background:var(--accent-soft);
color:var(--accent);
}
.actions[hidden]{ display:none; }
.msg{
text-align:center;
min-height:1.4em;
font-weight:600;
color:var(--success);
}
/* ---------- Modals ---------- */
.modal-overlay{
position:fixed;
inset:0;
background:rgba(10,12,18,0.75);
display:none;
align-items:center;
justify-content:center;
z-index:20;
padding:16px;
}
.modal-overlay.show{ display:flex; }
.modal-card{
background:var(--panel);
padding:24px 26px;
border-radius:16px;
text-align:center;
box-shadow:0 10px 40px rgba(0,0,0,0.5);
width:100%;
max-width:400px;
max-height:90vh;
overflow-y:auto;
position:relative;
}
.modal-card h2{ margin:0 0 6px; }
.modal-card p.sub{ color:var(--muted); margin:4px 0 16px; font-size:0.85rem; }
.modal-close{
position:absolute;
top:10px; right:10px;
background:none; border:none;
color:var(--muted);
font-size:1.1rem;
padding:4px 8px;
}
.modal-card.win h2{ color:var(--success); }
.modal-card .field{
display:flex;
flex-direction:column;
gap:4px;
text-align:left;
margin-bottom:12px;
}
.modal-card .field label{ font-size:0.8rem; color:var(--muted); }
.modal-card .field input{ width:100%; }
.modal-card .error-text{ color:var(--error); font-size:0.82rem; min-height:1.2em; margin-bottom:8px; }
.modal-card .tabs{
display:flex;
gap:6px;
margin-bottom:16px;
background:var(--cell);
border-radius:10px;
padding:4px;
}
.modal-card .tabs button{
flex:1;
border:none;
background:transparent;
padding:8px;
}
.modal-card .tabs button.active{
background:var(--accent);
color:#0c1220;
font-weight:700;
}
.modal-card .full-btn{
width:100%;
background:var(--accent);
border-color:var(--accent);
color:#0c1220;
font-weight:700;
padding:10px;
}
.modal-card .benefits-list{
text-align:left;
color:var(--muted);
font-size:0.85rem;
line-height:1.7;
margin:0 0 16px;
padding-left:0;
list-style:none;
}
.modal-card .benefits-list li{ display:flex; gap:8px; align-items:flex-start; }
.modal-card .stack{ display:flex; flex-direction:column; gap:8px; }
.modal-card .win-stats{
display:flex;
justify-content:center;
gap:18px;
margin:10px 0 18px;
}
.modal-card .win-stats .stat{ display:flex; flex-direction:column; align-items:center; }
.modal-card .win-stats .stat .v{ font-size:1.3rem; font-weight:700; }
.modal-card .win-stats .stat .l{ font-size:0.7rem; color:var(--muted); text-transform:uppercase; }
.data-table{
width:100%;
border-collapse:collapse;
font-size:0.82rem;
text-align:left;
}
.data-table th, .data-table td{
padding:6px 8px;
border-bottom:1px solid var(--line-thin);
}
.data-table th{ color:var(--muted); font-weight:600; }
.data-table tr:first-child td{ font-weight:700; }
.empty-note{ color:var(--muted); font-size:0.85rem; padding:12px 0; }
.diff-tabs{
display:flex;
gap:6px;
margin-bottom:12px;
flex-wrap:wrap;
justify-content:center;
}
.diff-tabs button{
padding:6px 12px;
font-size:0.78rem;
background:var(--cell);
}
.diff-tabs button.active{
background:var(--accent);
border-color:var(--accent);
color:#0c1220;
font-weight:700;
}
.modal-card.rules{ text-align:left; }
.modal-card.rules h2{ text-align:center; }
.modal-card.rules h3{
margin:16px 0 6px;
font-size:0.9rem;
color:var(--accent);
}
.modal-card.rules h3:first-of-type{ margin-top:0; }
.modal-card.rules p, .modal-card.rules li{
font-size:0.85rem;
color:var(--muted);
line-height:1.6;
}
.modal-card.rules ul, .modal-card.rules ol{
margin:4px 0 12px;
padding-left:20px;
}
footer{
text-align:center;
color:var(--muted);
font-size:0.75rem;
opacity:0.7;
}

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
sudoku:
build: .
container_name: sudoku
restart: unless-stopped
ports:
- "8090:3000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
volumes:
- ./app:/app/public:ro
- ./data:/app/data

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

2
server/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
data

60
server/auth.js Normal file
View File

@@ -0,0 +1,60 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me-please';
const COOKIE_NAME = 'sudoku_token';
const TOKEN_TTL = '30d';
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
function hashPassword(password) {
return bcrypt.hashSync(password, 10);
}
function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
function signToken(user) {
return jwt.sign({ id: user.id, pseudo: user.pseudo }, JWT_SECRET, { expiresIn: TOKEN_TTL });
}
function setAuthCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: COOKIE_MAX_AGE,
});
}
function clearAuthCookie(res) {
res.clearCookie(COOKIE_NAME);
}
function authMiddleware(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
req.user = null;
if (token) {
try {
req.user = jwt.verify(token, JWT_SECRET);
} catch (e) {
req.user = null;
}
}
next();
}
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'AUTH_REQUIRED' });
next();
}
module.exports = {
hashPassword,
verifyPassword,
signToken,
setAuthCookie,
clearAuthCookie,
authMiddleware,
requireAuth,
};

38
server/db.js Normal file
View File

@@ -0,0 +1,38 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(path.join(dataDir, 'sudoku.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pseudo TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
difficulty TEXT NOT NULL,
time_seconds INTEGER NOT NULL,
mistakes INTEGER NOT NULL DEFAULT 0,
won INTEGER NOT NULL DEFAULT 0,
points INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_games_user ON games(user_id);
CREATE INDEX IF NOT EXISTS idx_games_diff ON games(difficulty, won, time_seconds);
`);
module.exports = db;

17
server/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "sudoku-server",
"version": "1.0.0",
"private": true,
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"jsonwebtoken": "^9.0.2"
}
}

219
server/server.js Normal file
View File

@@ -0,0 +1,219 @@
const path = require('path');
const express = require('express');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const db = require('./db');
const {
hashPassword,
verifyPassword,
signToken,
setAuthCookie,
clearAuthCookie,
authMiddleware,
requireAuth,
} = require('./auth');
const PORT = process.env.PORT || 3000;
const DIFFICULTIES = ['easy', 'medium', 'hard', 'expert'];
const BASE_POINTS = { easy: 1200, medium: 2200, hard: 3400, expert: 4800 };
const MIN_TIME_SECONDS = { easy: 20, medium: 30, hard: 45, expert: 60 };
const PSEUDO_RE = /^[a-zA-Z0-9_]{3,20}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function computePoints(difficulty, timeSeconds, mistakes) {
const base = BASE_POINTS[difficulty] || BASE_POINTS.medium;
const raw = (base / Math.max(timeSeconds, 1)) * 10 - mistakes * 3;
return Math.max(0, Math.round(raw));
}
const app = express();
app.disable('x-powered-by');
app.use(express.json());
app.use(cookieParser());
app.use(authMiddleware);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'RATE_LIMITED' },
});
// ---------- Auth routes ----------
app.post('/api/auth/register', authLimiter, (req, res) => {
const pseudo = String(req.body.pseudo || '').trim();
const email = String(req.body.email || '').trim().toLowerCase();
const password = String(req.body.password || '');
if (!PSEUDO_RE.test(pseudo)) {
return res.status(400).json({ error: 'INVALID_PSEUDO' });
}
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'INVALID_EMAIL' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'INVALID_PASSWORD' });
}
try {
const passwordHash = hashPassword(password);
const info = db
.prepare('INSERT INTO users (pseudo, email, password_hash) VALUES (?, ?, ?)')
.run(pseudo, email, passwordHash);
const user = { id: info.lastInsertRowid, pseudo };
const token = signToken(user);
setAuthCookie(res, token);
res.json({ id: user.id, pseudo: user.pseudo, email, totalPoints: 0, wins: 0 });
} catch (e) {
if (String(e.message).includes('users.pseudo')) {
return res.status(409).json({ error: 'PSEUDO_TAKEN' });
}
if (String(e.message).includes('users.email')) {
return res.status(409).json({ error: 'EMAIL_TAKEN' });
}
console.error(e);
res.status(500).json({ error: 'REGISTER_FAILED' });
}
});
app.post('/api/auth/login', authLimiter, (req, res) => {
const identifier = String(req.body.identifier || '').trim().toLowerCase();
const password = String(req.body.password || '');
if (!identifier || !password) {
return res.status(400).json({ error: 'MISSING_CREDENTIALS' });
}
const user = db
.prepare('SELECT * FROM users WHERE lower(pseudo) = ? OR email = ?')
.get(identifier, identifier);
if (!user || !verifyPassword(password, user.password_hash)) {
return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
}
const token = signToken(user);
setAuthCookie(res, token);
res.json({ id: user.id, pseudo: user.pseudo, email: user.email });
});
app.post('/api/auth/logout', (req, res) => {
clearAuthCookie(res);
res.json({ ok: true });
});
app.get('/api/auth/me', (req, res) => {
if (!req.user) return res.json({ user: null });
const row = db
.prepare(
`SELECT u.id, u.pseudo, u.email,
COALESCE(SUM(g.points), 0) AS totalPoints,
COALESCE(SUM(g.won), 0) AS wins
FROM users u
LEFT JOIN games g ON g.user_id = u.id
WHERE u.id = ?
GROUP BY u.id`
)
.get(req.user.id);
if (!row) return res.json({ user: null });
res.json({ user: row });
});
// ---------- Games routes ----------
app.post('/api/games', requireAuth, (req, res) => {
const difficulty = String(req.body.difficulty || '');
const timeSeconds = Math.round(Number(req.body.time_seconds));
const mistakes = Math.round(Number(req.body.mistakes));
const won = !!req.body.won;
if (!DIFFICULTIES.includes(difficulty)) {
return res.status(400).json({ error: 'INVALID_DIFFICULTY' });
}
if (!Number.isFinite(timeSeconds) || timeSeconds < 1 || timeSeconds > 36000) {
return res.status(400).json({ error: 'INVALID_TIME' });
}
if (!Number.isFinite(mistakes) || mistakes < 0 || mistakes > 999) {
return res.status(400).json({ error: 'INVALID_MISTAKES' });
}
if (won && timeSeconds < MIN_TIME_SECONDS[difficulty]) {
return res.status(400).json({ error: 'TIME_TOO_SHORT' });
}
const points = won ? computePoints(difficulty, timeSeconds, mistakes) : 0;
const info = db
.prepare(
'INSERT INTO games (user_id, difficulty, time_seconds, mistakes, won, points) VALUES (?, ?, ?, ?, ?, ?)'
)
.run(req.user.id, difficulty, timeSeconds, mistakes, won ? 1 : 0, points);
res.json({ id: info.lastInsertRowid, difficulty, time_seconds: timeSeconds, mistakes, won, points });
});
app.get('/api/games/history', requireAuth, (req, res) => {
const rows = db
.prepare(
`SELECT difficulty, time_seconds, mistakes, won, points, created_at
FROM games WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`
)
.all(req.user.id);
res.json({ history: rows });
});
// ---------- Leaderboard routes ----------
app.get('/api/leaderboard/best-times', (req, res) => {
const difficulty = String(req.query.difficulty || '');
if (!DIFFICULTIES.includes(difficulty)) {
return res.status(400).json({ error: 'INVALID_DIFFICULTY' });
}
const rows = db
.prepare(
`SELECT u.pseudo AS pseudo, g.time_seconds AS time_seconds, g.mistakes AS mistakes, g.created_at AS created_at
FROM games g
JOIN users u ON u.id = g.user_id
WHERE g.difficulty = ? AND g.won = 1
AND g.time_seconds = (
SELECT MIN(g2.time_seconds) FROM games g2
WHERE g2.user_id = g.user_id AND g2.difficulty = g.difficulty AND g2.won = 1
)
GROUP BY g.user_id
ORDER BY g.time_seconds ASC
LIMIT 10`
)
.all(difficulty);
res.json({ difficulty, entries: rows });
});
app.get('/api/leaderboard/points', (req, res) => {
const rows = db
.prepare(
`SELECT u.pseudo AS pseudo,
COALESCE(SUM(g.points), 0) AS total_points,
COALESCE(SUM(g.won), 0) AS wins,
COUNT(*) AS games_played
FROM users u
JOIN games g ON g.user_id = u.id
GROUP BY u.id
ORDER BY total_points DESC
LIMIT 20`
)
.all();
res.json({ entries: rows });
});
// ---------- Static frontend ----------
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir));
app.get('*', (req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Sudoku server listening on port ${PORT}`);
});