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

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

409
app/main.js Normal file
View File

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