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