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

đź§© Sudoku

+ +
+
+ + +
+
+ + + + +
+
+ +
+ + + +
+
+ +
+
00:00
+
0
+
+ +
+
+ +
+

+ +
+ + +
+ +
+ + + +
+ + + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..c72d05f --- /dev/null +++ b/app/main.js @@ -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 = `👤 ${escapeHtml(currentUser.pseudo)} · ${currentUser.totalPoints || 0} pts`; + 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 = `

${I18N.t('history.loading')}

`; + try { + const data = await api('/api/games/history'); + if (!data.history.length) { + body.innerHTML = `

${I18N.t('history.empty')}

`; + return; + } + const rows = data.history.map(g => ` + + ${new Date(g.created_at + 'Z').toLocaleString(I18N.getLang() === 'en' ? 'en-US' : 'fr-FR', { dateStyle: 'short', timeStyle: 'short' })} + ${diffLabel(g.difficulty)} + ${formatTime(g.time_seconds)} + ${g.mistakes} + ${g.won ? '✅' : '❌'} + ${g.points} + `).join(''); + body.innerHTML = ` +
+ + + + + + + ${rows} +
${I18N.t('history.colDate')}${I18N.t('history.colDifficulty')}${I18N.t('history.colTime')}${I18N.t('history.colMistakes')}${I18N.t('history.colResult')}${I18N.t('history.colPoints')}
+
`; + } catch (e) { + body.innerHTML = `

${I18N.t('history.error', { msg: e.message })}

`; + } + } + + 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 = `

${I18N.t('history.loading')}

`; + try { + if (leaderboardMode === 'times') { + const data = await api(`/api/leaderboard/best-times?difficulty=${leaderboardDiff}`); + if (!data.entries.length) { + body.innerHTML = `

${I18N.t('leaderboard.emptyTimes')}

`; + return; + } + const rows = data.entries.map((e, i) => ` + ${i + 1}${escapeHtml(e.pseudo)}${formatTime(e.time_seconds)}${e.mistakes} + `).join(''); + body.innerHTML = ` +
+ + + + + + ${rows} +
${I18N.t('leaderboard.colRank')}${I18N.t('leaderboard.colPlayer')}${I18N.t('leaderboard.colTime')}${I18N.t('leaderboard.colMistakes')}
+
`; + } else { + const data = await api('/api/leaderboard/points'); + if (!data.entries.length) { + body.innerHTML = `

${I18N.t('leaderboard.emptyPoints')}

`; + return; + } + const rows = data.entries.map((e, i) => ` + ${i + 1}${escapeHtml(e.pseudo)}${e.total_points}${e.wins} + `).join(''); + body.innerHTML = ` +
+ + + + + + ${rows} +
${I18N.t('leaderboard.colRank')}${I18N.t('leaderboard.colPlayer')}${I18N.t('leaderboard.colPoints')}${I18N.t('leaderboard.colWins')}
+
`; + } + } catch (e) { + body.innerHTML = `

${I18N.t('leaderboard.error', { msg: e.message })}

`; + } + } + + // ---------- 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(); +})(); diff --git a/app/styles.css b/app/styles.css new file mode 100644 index 0000000..8e4b3f7 --- /dev/null +++ b/app/styles.css @@ -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; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a5b01b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..f273f5d Binary files /dev/null and b/docs/screenshot.png differ diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..15de390 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,2 @@ +node_modules +data diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..fdf9251 --- /dev/null +++ b/server/auth.js @@ -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, +}; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..054b5be --- /dev/null +++ b/server/db.js @@ -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; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5022ce8 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..33b37bb --- /dev/null +++ b/server/server.js @@ -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}`); +});