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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user