diff --git a/css/style.css b/css/style.css index e25e30e..dfffcef 100644 --- a/css/style.css +++ b/css/style.css @@ -740,9 +740,10 @@ kbd { border: 1px solid; white-space: nowrap; } -.rank-bronze { color: #cd7f32; background: rgba(205,127,50,0.12); border-color: rgba(205,127,50,0.35); } -.rank-silver { color: #b8b8b8; background: rgba(192,192,192,0.12); border-color: rgba(192,192,192,0.35); } -.rank-gold { color: #ffd700; background: rgba(255,215,0,0.12); border-color: rgba(255,215,0,0.4); } +.rank-bronze { color: #cd7f32; background: rgba(205,127,50,0.12); border-color: rgba(205,127,50,0.35); } +.rank-silver { color: #b8b8b8; background: rgba(192,192,192,0.12); border-color: rgba(192,192,192,0.35); } +.rank-silver2 { color: #c0c8ff; background: rgba(192,200,255,0.15); border-color: rgba(192,200,255,0.4); } +.rank-gold { color: #ffd700; background: rgba(255,215,0,0.12); border-color: rgba(255,215,0,0.4); } /* ── Rank-up overlay ── */ .rankup-overlay { @@ -784,6 +785,118 @@ kbd { 100% { opacity: 1; transform: scale(1) rotate(0deg); } } +/* ── Reaction bar ── */ +.reaction-bar { + display: flex; + gap: 0.5rem; + justify-content: center; +} +.reaction-btn { + font-size: 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + width: 3rem; + height: 3rem; + cursor: pointer; + transition: transform 0.15s, border-color 0.15s; + display: flex; + align-items: center; + justify-content: center; +} +.reaction-btn:hover { transform: scale(1.2); border-color: var(--accent); } +.reaction-btn:active { transform: scale(0.9); } + +/* ── Reaction overlay ── */ +.reaction-overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 400; +} +.reaction-float { + position: absolute; + font-size: 4rem; + animation: reactionPop 1.8s ease forwards; + pointer-events: none; +} +@keyframes reactionPop { + 0% { opacity: 0; transform: translateY(0) scale(0.5); } + 20% { opacity: 1; transform: translateY(-20px) scale(1.3); } + 80% { opacity: 1; transform: translateY(-80px) scale(1); } + 100% { opacity: 0; transform: translateY(-120px) scale(0.8); } +} + +/* ── Share + Submit row ── */ +.result-actions-row { + display: flex; + gap: 0.5rem; +} +.result-actions-row .btn-secondary { flex: 1; } + +/* ── Profile modal body ── */ +.profile-body { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; } +.profile-username { font-size: 1.5rem; font-weight: 900; } +.profile-rank { } +.profile-stats { + display: flex; + gap: 0.75rem; + width: 100%; +} +.profile-stat { + flex: 1; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +/* ── Clickable username in leaderboard ── */ +.lb-name-link { + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--border2); + text-underline-offset: 3px; + transition: color 0.15s; +} +.lb-name-link:hover { color: var(--accent); } + +/* ── History Q&A list ── */ +.history-qa { + display: none; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.3rem; + padding-top: 0.3rem; + border-top: 1px solid var(--border); +} +.history-entry.expanded .history-qa { display: flex; } +.history-qa-item { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.68rem; + color: var(--muted); +} +.history-qa-item.correct-q { color: var(--green); } +.history-qa-item.wrong-q { color: var(--red); } +.history-toggle { + background: none; + border: none; + color: var(--muted); + font-size: 0.68rem; + cursor: pointer; + padding: 0.1rem 0.3rem; + border-radius: 4px; + transition: color 0.15s; + align-self: flex-start; +} +.history-toggle:hover { color: var(--accent); } + /* ── History entries ── */ .history-entry { background: var(--surface); diff --git a/index.html b/index.html index 928f099..30e4f12 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - MathBlitz + QuantQuiz @@ -15,6 +15,35 @@
3
+ +
+ + + +
@@ -85,7 +114,7 @@

@@ -261,7 +290,10 @@

Game Over

- +
+ + +
@@ -277,9 +309,9 @@

Leaderboard

Mode

+ -
@@ -370,6 +402,12 @@

Finding Opponent

 
+
+ + + + +
diff --git a/js/daily.js b/js/daily.js index 904fcba..f9025fa 100644 --- a/js/daily.js +++ b/js/daily.js @@ -69,7 +69,7 @@ const Daily = (() => { // ── Completion tracking (localStorage) ─────────────────────────────────── - function storageKey() { return `mathblitz_daily_${getTodayISO()}`; } + function storageKey() { return `quantquiz_daily_${getTodayISO()}`; } function hasCompletedToday() { return localStorage.getItem(storageKey()) === 'done'; diff --git a/js/db.js b/js/db.js index 95c014c..0a8af87 100644 --- a/js/db.js +++ b/js/db.js @@ -204,12 +204,33 @@ const DB = (() => { return data ?? []; } + async function getGlobalLeaderboard(limit = 25) { + if (!_client) return []; + const { data, error } = await _client + .from('profiles') + .select('username, total_xp, current_streak, longest_streak') + .order('total_xp', { ascending: false }) + .limit(limit); + if (error) throw error; + return data ?? []; + } + + async function getUserProfileByUsername(username) { + if (!_client) return null; + const { data } = await _client + .from('profiles') + .select('username, total_xp, current_streak, longest_streak') + .eq('username', username) + .single(); + return data; + } + return { isConfigured: SUPABASE_CONFIGURED, signUp, signIn, signOut, getUser, getProfile, onAuthChange, saveScore, updateStreak, saveDailyScore, getDailyLeaderboard, hasUserCompletedDaily, - getLeaderboard, + getLeaderboard, getGlobalLeaderboard, getUserProfileByUsername, getCommunityQuestions, submitCommunityQuestion, }; })(); diff --git a/js/duel-client.js b/js/duel-client.js index cceae4c..026a1c6 100644 --- a/js/duel-client.js +++ b/js/duel-client.js @@ -28,7 +28,7 @@ const DuelClient = (() => { const events = [ 'searching', 'matched', 'countdown', 'duel_start', 'answer_result', 'opponent_update', 'timer_tick', - 'duel_end', 'match_cancelled', + 'duel_end', 'match_cancelled', 'reaction', ]; events.forEach(e => socket.on(e, data => _dispatch(e, data))); @@ -47,9 +47,10 @@ const DuelClient = (() => { socket.emit('find_match', { username }); } - function cancelMatch() { socket?.emit('cancel_match'); } - function submitAnswer(answer) { socket?.emit('submit_answer', { answer }); } - function forfeit() { socket?.emit('forfeit'); disconnect(); } + function cancelMatch() { socket?.emit('cancel_match'); } + function submitAnswer(answer) { socket?.emit('submit_answer', { answer }); } + function forfeit() { socket?.emit('forfeit'); disconnect(); } + function sendReaction(emoji) { socket?.emit('send_reaction', { emoji }); } - return { on, connect, disconnect, findMatch, cancelMatch, submitAnswer, forfeit }; + return { on, connect, disconnect, findMatch, cancelMatch, submitAnswer, forfeit, sendReaction }; })(); diff --git a/js/game.js b/js/game.js index d0a2266..a0f1360 100644 --- a/js/game.js +++ b/js/game.js @@ -37,9 +37,10 @@ const Game = (() => { questionIdx: 0, totalQuestions: predefined ? predefined.length : (settings.mode === 'sprint' ? SPRINT_QUESTIONS : null), predefined, - currentQ: null, - startTime: Date.now(), - active: false, + currentQ: null, + answeredQuestions: [], + startTime: Date.now(), + active: false, }; } @@ -116,6 +117,15 @@ const Game = (() => { state.wrong++; } + if (state.answeredQuestions.length < 30) { + state.answeredQuestions.push({ + display: state.currentQ.display, + answer: state.currentQ.answer, + given: userAnswer, + correct: isCorrect, + }); + } + state.questionIdx++; if (isGameOver()) { diff --git a/js/main.js b/js/main.js index e600669..a54081b 100644 --- a/js/main.js +++ b/js/main.js @@ -5,9 +5,10 @@ // ── Rank system (global so ui.js leaderboard rendering can access) ───────── const RANKS = [ - { name: 'Bronze', icon: '🥉', min: 0, cls: 'rank-bronze' }, - { name: 'Silver', icon: '🥈', min: 1000, cls: 'rank-silver' }, - { name: 'Gold', icon: '🥇', min: 5000, cls: 'rank-gold' }, + { name: 'Bronze', icon: '🥉', min: 0, cls: 'rank-bronze' }, + { name: 'Silver I', icon: '🥈', min: 1000, cls: 'rank-silver' }, + { name: 'Silver II', icon: '🥈', min: 2500, cls: 'rank-silver2' }, + { name: 'Gold', icon: '🥇', min: 5000, cls: 'rank-gold' }, ]; function getRankForXP(xp) { let r = RANKS[0]; @@ -35,9 +36,9 @@ const App = (() => { // ── XP & Rank helpers ───────────────────────────────────────────────────── - function getTotalXP() { return parseInt(localStorage.getItem('mathblitz_total_xp') || '0', 10); } - function addXP(pts) { const n = getTotalXP() + pts; localStorage.setItem('mathblitz_total_xp', n); return n; } - function canSubmit(xp) { return xp >= RANKS[1].min; } // Silver+ + function getTotalXP() { return parseInt(localStorage.getItem('quantquiz_total_xp') || '0', 10); } + function addXP(pts) { const n = getTotalXP() + pts; localStorage.setItem('quantquiz_total_xp', n); return n; } + function canSubmit(xp) { return xp >= RANKS[2].min; } // Silver II+ function updateRankBadge(xp) { const badge = document.getElementById('rank-badge'); @@ -50,17 +51,18 @@ const App = (() => { // ── History helpers ─────────────────────────────────────────────────────── function saveGameHistory(state, elapsed) { - const key = 'mathblitz_history'; + const key = 'quantquiz_history'; const hist = JSON.parse(localStorage.getItem(key) || '[]'); hist.unshift({ - ts: Date.now(), - mode: state.mode, - diff: state.difficulty, - score: state.score, - correct: state.correct, - wrong: state.wrong, - streak: state.bestStreak, - elapsed: parseFloat(elapsed), + ts: Date.now(), + mode: state.mode, + diff: state.difficulty, + score: state.score, + correct: state.correct, + wrong: state.wrong, + streak: state.bestStreak, + elapsed: parseFloat(elapsed), + questions: state.answeredQuestions ?? [], }); if (hist.length > 20) hist.pop(); localStorage.setItem(key, JSON.stringify(hist)); @@ -70,8 +72,8 @@ const App = (() => { function storageKey(mode, diff, time) { return mode === 'classic' - ? `mathblitz_best_${mode}_${diff}_${time}s` - : `mathblitz_best_${mode}_${diff}`; + ? `quantquiz_best_${mode}_${diff}_${time}s` + : `quantquiz_best_${mode}_${diff}`; } function getBest(mode, diff, time) { const v = localStorage.getItem(storageKey(mode, diff, time)); @@ -270,12 +272,12 @@ const App = (() => { function initThemes() { ThemeBG.init(); - const saved = localStorage.getItem('mathblitz_theme') || 'void'; + const saved = localStorage.getItem('quantquiz_theme') || 'void'; applyTheme(saved); document.querySelectorAll('.theme-dot').forEach(btn => { btn.addEventListener('click', () => { applyTheme(btn.dataset.theme); - localStorage.setItem('mathblitz_theme', btn.dataset.theme); + localStorage.setItem('quantquiz_theme', btn.dataset.theme); }); }); } @@ -301,7 +303,7 @@ const App = (() => { btn.textContent = '🔔✓'; btn.style.color = 'var(--green)'; // Show a test notification - new Notification('MathBlitz', { + new Notification('QuantQuiz', { body: "Notifications enabled! We'll remind you about the daily challenge.", icon: '/icon.png', }); @@ -365,6 +367,7 @@ const App = (() => { gameSettings.predefinedQuestions = qs.length ? qs : Daily.generateQuestions(); } + Sound.gameStart(); const state = Game.start(gameSettings); UI.updateHUD(state); UI.showQuestion(Game.getCurrentQuestion()); @@ -536,7 +539,8 @@ const App = (() => { alert('Fill in js/config.js with your Supabase credentials first.'); return; } - lb.mode = settings.mode === 'daily' ? 'daily' : settings.mode; + const validLbModes = ['global', 'classic', 'sprint', 'daily']; + lb.mode = validLbModes.includes(settings.mode) ? settings.mode : 'classic'; lb.difficulty = settings.difficulty; lb.timeLimit = settings.timeLimit; syncLbFilterUI(); @@ -552,7 +556,7 @@ const App = (() => { document.querySelectorAll('[data-lb-time]').forEach(b => b.classList.toggle('active', parseInt(b.dataset.lbTime) === lb.timeLimit)); const showTime = lb.mode === 'classic'; - const showDiff = lb.mode !== 'daily'; + const showDiff = lb.mode !== 'daily' && lb.mode !== 'global'; document.getElementById('lb-time-section').classList.toggle('hidden', !showTime); document.querySelectorAll('[data-lb-diff]').forEach(b => b.closest('.section')?.classList.toggle('hidden', !showDiff)); @@ -595,9 +599,14 @@ const App = (() => { list.innerHTML = '

Loading...

'; try { - const rows = lb.mode === 'daily' - ? await DB.getDailyLeaderboard(Daily.getTodayISO()) - : await DB.getLeaderboard(lb.mode, lb.difficulty, lb.timeLimit); + let rows; + if (lb.mode === 'global') { + rows = await DB.getGlobalLeaderboard(); + } else if (lb.mode === 'daily') { + rows = await DB.getDailyLeaderboard(Daily.getTodayISO()); + } else { + rows = await DB.getLeaderboard(lb.mode, lb.difficulty, lb.timeLimit); + } if (rows.length === 0) { list.innerHTML = '

No scores yet. Be the first!

'; @@ -605,13 +614,20 @@ const App = (() => { } list.innerHTML = rows.map((row, i) => { - const username = row.profiles?.username ?? 'anonymous'; - const rank = i + 1; - const isMe = currentUsername && username === currentUsername; - const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`; - - let primary, secondary; - if (lb.mode === 'sprint') { + const username = lb.mode === 'global' + ? (row.username ?? 'anonymous') + : (row.profiles?.username ?? 'anonymous'); + const pos = i + 1; + const isMe = currentUsername && username === currentUsername; + const medal = pos === 1 ? '🥇' : pos === 2 ? '🥈' : pos === 3 ? '🥉' : `#${pos}`; + + let primary, secondary, rankBadge = ''; + if (lb.mode === 'global') { + const r = getRankForXP(row.total_xp ?? 0); + rankBadge = `${r.icon} ${r.name}`; + primary = (row.total_xp ?? 0) + ' XP'; + secondary = `streak ${row.current_streak ?? 0} days`; + } else if (lb.mode === 'sprint') { primary = row.elapsed_seconds.toFixed(2) + 's'; secondary = `${row.correct}/10 correct`; } else if (lb.mode === 'daily') { @@ -625,13 +641,19 @@ const App = (() => { return `
${medal} - ${username}${isMe ? ' (you)' : ''} + + ${username}${isMe ? ' (you)' : ''}${rankBadge} +
${primary} ${secondary}
`; }).join(''); + + list.querySelectorAll('.lb-name-link').forEach(el => { + el.addEventListener('click', () => showProfile(el.dataset.username)); + }); } catch (e) { list.innerHTML = `

Failed to load: ${e.message}

`; } @@ -796,6 +818,27 @@ const App = (() => { }); DuelClient.on('match_cancelled', () => UI.showScreen('menu')); + + DuelClient.on('reaction', ({ emoji }) => { + spawnReaction(emoji); + }); + + document.querySelectorAll('.reaction-btn').forEach(btn => { + btn.addEventListener('click', () => { + DuelClient.sendReaction(btn.dataset.emoji); + }); + }); + } + + function spawnReaction(emoji) { + const overlay = document.getElementById('reaction-overlay'); + const el = document.createElement('div'); + el.className = 'reaction-float'; + el.textContent = emoji; + el.style.left = `${20 + Math.random() * 60}%`; + el.style.top = `${30 + Math.random() * 30}%`; + overlay.appendChild(el); + el.addEventListener('animationend', () => el.remove()); } function startDuelSearch() { @@ -811,7 +854,7 @@ const App = (() => { // ── History ─────────────────────────────────────────────────────────────── function showHistory() { - const hist = JSON.parse(localStorage.getItem('mathblitz_history') || '[]'); + const hist = JSON.parse(localStorage.getItem('quantquiz_history') || '[]'); UI.renderHistory(hist); UI.showScreen('history'); } @@ -820,6 +863,55 @@ const App = (() => { document.getElementById('history-back').addEventListener('click', () => UI.showScreen('menu')); } + // ── Share ───────────────────────────────────────────────────────────────── + + function initShare() { + document.getElementById('share-btn').addEventListener('click', () => { + const score = document.getElementById('res-score').textContent; + const correct = document.getElementById('res-correct').textContent; + const acc = document.getElementById('res-accuracy').textContent; + const mode = settings.mode.charAt(0).toUpperCase() + settings.mode.slice(1); + const text = `QuantQuiz ${mode} — ${score} pts | ${correct} correct | ${acc} accuracy\nPlay at https://mathblitz-jade.vercel.app`; + + if (navigator.share) { + navigator.share({ title: 'QuantQuiz', text }).catch(() => {}); + } else { + navigator.clipboard.writeText(text).then(() => { + const btn = document.getElementById('share-btn'); + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = '↗ Share'; }, 2000); + }); + } + }); + } + + // ── Profile modal ───────────────────────────────────────────────────────── + + function initProfileModal() { + document.getElementById('profile-close-btn').addEventListener('click', () => { + document.getElementById('profile-modal').classList.remove('active'); + }); + } + + async function showProfile(username) { + document.getElementById('profile-username').textContent = username; + document.getElementById('profile-xp').textContent = '...'; + document.getElementById('profile-streak').textContent = '...'; + document.getElementById('profile-longest').textContent = '...'; + document.getElementById('profile-rank').innerHTML = ''; + document.getElementById('profile-modal').classList.add('active'); + + const data = await DB.getUserProfileByUsername(username); + if (!data) return; + + const rank = getRankForXP(data.total_xp ?? 0); + document.getElementById('profile-xp').textContent = data.total_xp ?? 0; + document.getElementById('profile-streak').textContent = data.current_streak ?? 0; + document.getElementById('profile-longest').textContent = data.longest_streak ?? 0; + document.getElementById('profile-rank').innerHTML = + `${rank.icon} ${rank.name}`; + } + // ── Submit Problem ──────────────────────────────────────────────────────── function initSubmitProblem() { @@ -907,6 +999,8 @@ const App = (() => { initServiceWorker(); initHistory(); initSubmitProblem(); + initShare(); + initProfileModal(); updateTimeSectionVisibility(); updateRankBadge(getTotalXP()); refreshBest(); diff --git a/js/sound.js b/js/sound.js index 987f989..ee0a6f2 100644 --- a/js/sound.js +++ b/js/sound.js @@ -67,10 +67,15 @@ const Sound = (() => { } } + function gameStart() { + if (!enabled) return; + vibrate([80, 40, 80, 40, 160]); + } + function toggle() { enabled = !enabled; return enabled; } - return { correct, wrong, streakMilestone, countdown, toggle }; + return { correct, wrong, streakMilestone, countdown, gameStart, toggle }; })(); diff --git a/js/ui.js b/js/ui.js index 083859f..c71dadb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -166,22 +166,48 @@ const UI = (() => { list.innerHTML = '

No games yet.

'; return; } - list.innerHTML = entries.map(e => { + list.innerHTML = entries.map((e, i) => { const date = new Date(e.ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); const mode = MODE_LABEL[e.mode] ?? e.mode; const diff = DIFF_LABEL[e.diff] ?? ''; const elapsed = e.mode === 'sprint' ? ` · ${e.elapsed}s` : ''; const total = e.correct + e.wrong; const acc = total > 0 ? Math.round((e.correct / total) * 100) : 0; + + const qaHtml = e.questions && e.questions.length + ? `
` + + e.questions.map(q => + `
+ ${q.display} + ${q.correct ? '✓ ' + q.answer : '✗ ' + q.given + ' → ' + q.answer} +
` + ).join('') + + `
` + : ''; + + const toggleBtn = e.questions && e.questions.length + ? `` + : ''; + return ` -
+
${mode} ${diff} ${e.score} pts
${e.correct}✓ ${e.wrong}✗ · ${acc}% · streak ×${e.streak}${elapsed} · ${date} + ${toggleBtn} + ${qaHtml}
`; }).join(''); + + list.querySelectorAll('.history-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const entry = btn.closest('.history-entry'); + const open = entry.classList.toggle('expanded'); + btn.textContent = open ? '▲ Questions' : '▼ Questions'; + }); + }); } return { diff --git a/server/package.json b/server/package.json index 3c33bdf..0c9c150 100644 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { - "name": "mathblitz-server", + "name": "quantquiz-server", "version": "1.0.0", - "description": "MathBlitz real-time duel server", + "description": "QuantQuiz real-time duel server", "main": "server.js", "scripts": { "start": "node server.js", diff --git a/server/server.js b/server/server.js index 60421ce..eb71b83 100644 --- a/server/server.js +++ b/server/server.js @@ -1,5 +1,5 @@ /** - * server.js — MathBlitz duel server + * server.js — QuantQuiz duel server * Express + Socket.io. Handles matchmaking and real-time duels. * * Usage: node server.js @@ -82,6 +82,12 @@ io.on('connection', socket => { }); }); + socket.on('send_reaction', ({ emoji }) => { + const { roomId } = socket.data; + if (!roomId) return; + socket.to(roomId).emit('reaction', { emoji }); + }); + socket.on('forfeit', () => { const { roomId, playerIdx } = socket.data; const room = rooms[roomId]; @@ -203,6 +209,6 @@ function endRoom(roomId, forcedWinner, reason) { // ── Start ───────────────────────────────────────────────────────────────────── server.listen(PORT, () => { - console.log(`\nMathBlitz duel server → http://localhost:${PORT}`); + console.log(`\nQuantQuiz duel server → http://localhost:${PORT}`); console.log('Waiting for players...\n'); }); diff --git a/sw.js b/sw.js index d886fce..1ca7fb8 100644 --- a/sw.js +++ b/sw.js @@ -1,9 +1,9 @@ /** - * sw.js — MathBlitz Service Worker + * sw.js — QuantQuiz Service Worker * Handles push notifications. Requires HTTPS to activate. */ -const CACHE = 'mathblitz-v3'; +const CACHE = 'quantquiz-v3'; // Cache core files on install self.addEventListener('install', event => { @@ -38,7 +38,7 @@ self.addEventListener('fetch', event => { self.addEventListener('push', event => { const data = event.data?.json() ?? {}; event.waitUntil( - self.registration.showNotification(data.title || 'MathBlitz', { + self.registration.showNotification(data.title || 'QuantQuiz', { body: data.body || "Your daily challenge is ready! Can you top the leaderboard today? 🧠", icon: '/icon.png', badge: '/icon.png',