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:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
JWT_SECRET=change-me-to-a-long-random-value
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
server/node_modules/
|
||||||
|
*.log
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
117
README.md
Normal 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
535
app/game.js
Normal 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
258
app/i18n.js
Normal 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
227
app/index.html
Normal 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
409
app/main.js
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
490
app/styles.css
Normal 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
13
docker-compose.yml
Normal 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
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
2
server/.dockerignore
Normal file
2
server/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
60
server/auth.js
Normal file
60
server/auth.js
Normal 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
38
server/db.js
Normal 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
17
server/package.json
Normal 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
219
server/server.js
Normal 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user