// frontend.js
// ---------------------------------------------------------
// QuizZap Frontend Logic (Vanilla JS, ES Modules style)
// ---------------------------------------------------------
// --- State ---
const state = {
user: null,
token: null,
currentView: 'auth',
currentQuizId: null,
currentGamePin: null,
isHost: false,
ws: null,
reconnectAttempts: 0,
gameData: null,
questionDeadline: 0,
timerInterval: null,
};
// --- DOM Cache ---
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
// --- Utils ---
const api = (url, opts = {}) => fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
credentials: 'include' // Cookies for JWT
}).then(r => { if (!r.ok) throw await r.json().catch(() => ({ error: r.statusText })); return r.json(); });
const toast = (msg, type = 'info') => {
const c = $('#toast-container');
const el = document.createElement('div');
el.className = `toast toast-${type} animate-slide-in`;
el.innerHTML = `${msg}`;
c.appendChild(el);
setTimeout(() => el.remove(), 5000);
};
const showView = (viewName) => {
$$('.page, .view').forEach(v => v.classList.add('hidden'));
$(`#page-${viewName}`)?.classList.remove('hidden');
$(`#view-${viewName}`)?.classList.remove('hidden');
state.currentView = viewName;
window.scrollTo(0, 0);
};
const escapeHtml = (text) => text ? text.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])) : '';
// --- Auth ---
async function checkAuth() {
try {
const user = await api('/api/me');
state.user = user;
updateUserUI();
showView('dashboard');
loadDashboard();
} catch (e) {
showView('auth');
}
}
function updateUserUI() {
if (!state.user) return;
$('#user-name').textContent = state.user.username;
$('#user-avatar').textContent = state.user.username[0].toUpperCase();
$('#user-avatar').style.background = `hsl(${state.user.id.split('').reduce((a,b)=>a+b.charCodeAt(0),0) % 360}, 70%, 50%)`;
}
$('#form-login').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
toast('Connecté !', 'success');
checkAuth();
} catch (err) { toast(err.error || 'Erreur', 'error'); }
};
$('#form-register').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
if (fd.get('password').length < 6) return toast('Min 6 caractères', 'error');
try {
await api('/api/register', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
toast('Compte créé !', 'success');
checkAuth();
} catch (err) { toast(err.error || 'Erreur', 'error'); }
};
$$('[data-switch]').forEach(a => a.onclick = (e) => {
e.preventDefault();
$$('#form-login, #form-register').forEach(f => f.classList.toggle('hidden'));
});
$('#btn-logout').onclick = async () => {
await api('/api/logout', { method: 'POST' });
state.user = null;
showView('auth');
};
// --- Navigation ---
$$('[data-nav]').forEach(btn => btn.onclick = (e) => {
e.preventDefault();
const view = btn.dataset.nav;
if (view === 'dashboard') { showView('dashboard'); loadDashboard(); }
else if (view === 'public') { showView('public'); loadPublicQuizzes(); }
else if (view === 'editor') { openEditor(); }
else showView(view);
});
// --- Dashboard / Quizzes ---
async function loadDashboard() {
const container = $('#quiz-list');
container.innerHTML = '
Chargement...
';
try {
const quizzes = await api('/api/quizzes');
renderQuizCards(quizzes, container, true);
} catch (e) { container.innerHTML = `Erreur: ${e.error}
`; }
}
async function loadPublicQuizzes() {
const container = $('#public-quiz-list');
container.innerHTML = 'Chargement...
';
try {
const quizzes = await api('/api/quizzes/public');
renderQuizCards(quizzes, container, false);
} catch (e) { container.innerHTML = `Erreur: ${e.error}
`; }
}
function renderQuizCards(quizzes, container, isOwner) {
if (!quizzes.length) {
container.innerHTML = `Aucun quiz ${isOwner ? 'créé' : 'public'}. ${isOwner ? '' : ''}
`;
return;
}
container.innerHTML = quizzes.map(q => `
${q.cover_image ? `

` : `
`}
${escapeHtml(q.title)}
${q.is_public ? 'Public' : 'Privé'}
${escapeHtml(q.description || 'Sans description')}
${q.questions?.length || 0} questions
${q.play_count || 0} parties
${isOwner ? `
` : `
`}
`).join('');
}
async function deleteQuiz(id) {
if (!confirm('Supprimer ce quiz définitivement ?')) return;
try { await api(`/api/quizzes/${id}`, { method: 'DELETE' }); toast('Supprimé', 'success'); loadDashboard(); }
catch (e) { toast(e.error, 'error'); }
}
function playQuiz(id) {
state.currentQuizId = id;
showView('host');
initHostLobby(id);
}
// --- Editor ---
let editorQuiz = { id: null, title: '', description: '', cover_image: '', is_public: false, randomize_order: false, questions: [] };
async function openEditor(quizId = null) {
showView('editor');
editorQuiz = { id: null, title: '', description: '', cover_image: '', is_public: false, randomize_order: false, questions: [] };
if (quizId) {
try {
const q = await api(`/api/quizzes/${quizId}`);
editorQuiz = { ...q, questions: q.questions.map((qq, i) => ({ ...qq, _order: i })) };
} catch (e) { toast('Impossible de charger', 'error'); return; }
}
renderEditor();
}
function renderEditor() {
$('#editor-title').textContent = editorQuiz.id ? 'Édition Quiz' : 'Nouveau Quiz';
$('#quiz-visibility-badge').textContent = editorQuiz.is_public ? 'Public' : 'Brouillon';
$('#quiz-visibility-badge').className = `badge ${editorQuiz.is_public ? 'badge-primary' : 'badge-secondary'}`;
$('#quiz-title').value = editorQuiz.title;
$('#quiz-desc').value = editorQuiz.description || '';
$('#quiz-public').checked = editorQuiz.is_public;
$('#quiz-random').checked = editorQuiz.randomize_order;
updateCoverPreview(editorQuiz.cover_image);
renderQuestionsList();
}
function updateCoverPreview(url) {
const el = $('#quiz-cover-preview');
if (url) { el.src = url; el.classList.remove('hidden'); }
else el.classList.add('hidden');
}
$('#quiz-title').oninput = e => editorQuiz.title = e.target.value;
$('#quiz-desc').oninput = e => editorQuiz.description = e.target.value;
$('#quiz-public').onchange = e => { editorQuiz.is_public = e.target.checked; renderEditor(); };
$('#quiz-random').onchange = e => editorQuiz.randomize_order = e.target.checked;
$('#quiz-cover-input').onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
const data = await res.json();
editorQuiz.cover_image = data.url;
updateCoverPreview(data.url);
toast('Image uploadée !', 'success');
} catch (err) { toast('Erreur upload', 'error'); }
};
// --- Questions Management ---
function renderQuestionsList() {
const container = $('#questions-container');
const countEl = $('#q-count');
countEl.textContent = editorQuiz.questions.length;
if (!editorQuiz.questions.length) {
container.innerHTML = `Aucune question. Ajoute-en une !
`;
return;
}
container.innerHTML = editorQuiz.questions.map((q, i) => renderQuestionEditor(q, i)).join('');
// Re-bind image uploads
$$('.q-image-input').forEach(inp => inp.onchange = e => uploadQuestionImage(inp, e.target.files[0]));
// Bind option image uploads
$$('.opt-image-input').forEach(inp => inp.onchange = e => uploadOptionImage(inp, e.target.files[0]));
}
function renderQuestionEditor(q, index) {
const typeLabels = { quiz: '❓ Quiz', true_false: '✅ Vrai/Faux', type_answer: '⌨️ Saisie', puzzle: '🧩 Ordre', slider: '📊 Curseur' };
const typeIcons = { quiz: 'grid', true_false: 'toggle', type_answer: 'keyboard', puzzle: 'puzzle', slider: 'sliders' };
let optionsHtml = '';
if (['quiz', 'true_false', 'puzzle'].includes(q.type)) {
optionsHtml = q.options.map((opt, oi) => `
`).join('');
optionsHtml += ``;
} else if (q.type === 'type_answer') {
optionsHtml = ``;
} else if (q.type === 'slider') {
optionsHtml = `
`;
}
return `
${index + 1}.
${typeLabels[q.type]}
${optionsHtml}
`;
}
function addQuestion(type) {
const defaults = {
quiz: { type: 'quiz', text: '', time_limit: 20, points: 1000, options: [{id: uuid(), text: 'Option 1', is_correct: true}, {id: uuid(), text: 'Option 2', is_correct: false}], image: '' },
true_false: { type: 'true_false', text: '', time_limit: 10, points: 1000, options: [{id: uuid(), text: 'Vrai', is_correct: true}, {id: uuid(), text: 'Faux', is_correct: false}], image: '' },
type_answer: { type: 'type_answer', text: '', time_limit: 30, points: 1000, correct_answer: '', image: '' },
puzzle: { type: 'puzzle', text: '', time_limit: 30, points: 1000, options: [{id: uuid(), text: '1er', is_correct: true}, {id: uuid(), text: '2ème', is_correct: false}, {id: uuid(), text: '3ème', is_correct: false}], image: '' },
slider: { type: 'slider', text: '', time_limit: 20, points: 1000, correct_answer: 50, options: [{text: '0'}, {text: '100'}, {text: ''}], image: '' }
};
editorQuiz.questions.push(defaults[type]);
renderQuestionsList();
// Scroll to new
setTimeout(() => document.querySelector(`[data-qid="${editorQuiz.questions.length-1}"]`)?.scrollIntoView({behavior: 'smooth', block: 'center'}), 0);
}
function changeQuestionType(index, newType) {
// Try to preserve options/text
const old = editorQuiz.questions[index];
const newQ = { ...old, type: newType, options: undefined, correct_answer: undefined };
// Reset specific fields based on new type
if (['quiz', 'true_false', 'puzzle'].includes(newType)) {
if (newType === 'true_false') newQ.options = [{id: uuid(), text: 'Vrai', is_correct: true}, {id: uuid(), text: 'Faux', is_correct: false}];
else if (!old.options?.length) newQ.options = [{id: uuid(), text: 'Option 1', is_correct: true}, {id: uuid(), text: 'Option 2', is_correct: false}];
else newQ.options = old.options.map((o, i) => ({...o, is_correct: newType === 'quiz' ? i===0 : o.is_correct }));
} else if (newType === 'type_answer') {
newQ.correct_answer = old.correct_answer || '';
} else if (newType === 'slider') {
newQ.correct_answer = old.correct_answer ?? 50;
newQ.options = [{text: String(old.slider_min ?? 0)}, {text: String(old.slider_max ?? 100)}, {text: old.slider_unit || ''}];
}
editorQuiz.questions[index] = newQ;
renderQuestionsList();
}
function updateQuestion(qIndex, key, value) {
editorQuiz.questions[qIndex][key] = value;
}
function updateOption(qIndex, oIndex, key, value) {
if (!editorQuiz.questions[qIndex].options) return;
editorQuiz.questions[qIndex].options[oIndex][key] = value;
// Radio logic for Quiz
if (key === 'is_correct' && value && editorQuiz.questions[qIndex].type === 'quiz') {
editorQuiz.questions[qIndex].options.forEach((o, i) => { if (i !== oIndex) o.is_correct = false; });
renderQuestionsList(); // Re-render to update radio buttons
}
}
function addOption(qIndex) {
const q = editorQuiz.questions[qIndex];
if (!q.options) return;
q.options.push({ id: uuid(), text: `Option ${q.options.length + 1}`, is_correct: false });
renderQuestionsList();
}
function removeOption(qIndex, oIndex) {
const q = editorQuiz.questions[qIndex];
if (!q.options || q.options.length <= 2) return toast('Minimum 2 options', 'error');
q.options.splice(oIndex, 1);
renderQuestionsList();
}
function deleteQuestion(index) {
editorQuiz.questions.splice(index, 1);
renderQuestionsList();
}
async function uploadQuestionImage(inputEl, file) {
if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
const data = await res.json();
const qIndex = parseInt(inputEl.dataset.q);
editorQuiz.questions[qIndex].image = data.url;
renderQuestionsList();
toast('Image uploadée', 'success');
} catch (e) { toast('Erreur upload', 'error'); }
}
async function uploadOptionImage(inputEl, file) {
if (!file) return;
const fd = new FormData(); fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
const data = await res.json();
const qIndex = parseInt(inputEl.dataset.q);
const oIndex = parseInt(inputEl.dataset.o);
editorQuiz.questions[qIndex].options[oIndex].image = data.url;
renderQuestionsList();
} catch (e) { toast('Erreur upload', 'error'); }
}
function removeQuestionImage(index) { editorQuiz.questions[index].image = ''; renderQuestionsList(); }
function removeOptionImage(qIndex, oIndex) { editorQuiz.questions[qIndex].options[oIndex].image = ''; renderQuestionsList(); }
// --- Save / Publish ---
$('#btn-save-quiz').onclick = async () => {
if (!editorQuiz.title.trim()) return toast('Titre obligatoire', 'error');
if (!editorQuiz.questions.length) return toast('Au moins 1 question', 'error');
// Validate questions
for (let i=0; i o.is_correct)) return toast(`Question ${i+1}: Aucune bonne réponse`, 'error');
if (q.options.some(o => !o.text.trim())) return toast(`Question ${i+1}: Option vide`, 'error');
}
if (q.type === 'type_answer' && !q.correct_answer) return toast(`Question ${i+1}: Réponse attendue manquante`, 'error');
}
const payload = { ...editorQuiz, questions: editorQuiz.questions.map(q => {
const { _order, ...rest } = q;
if (q.type === 'slider') {
rest.options = [{text: String(q.slider_min||0)}, {text: String(q.slider_max||100)}, {text: q.slider_unit||''}];
delete rest.slider_min; delete rest.slider_max; delete rest.slider_unit;
}
return rest;
})};
try {
let saved;
if (editorQuiz.id) {
await api(`/api/quizzes/${editorQuiz.id}`, { method: 'PUT', body: JSON.stringify(payload) });
saved = editorQuiz;
toast('Quiz mis à jour !', 'success');
} else {
saved = await api('/api/quizzes', { method: 'POST', body: JSON.stringify(payload) });
toast('Quiz créé !', 'success');
editorQuiz.id = saved.id;
}
renderEditor();
} catch (e) { toast(e.error || 'Erreur sauvegarde', 'error'); }
};
$('#btn-play-quiz').onclick = () => {
if (!editorQuiz.id) { toast('Sauvegarde d\'abord !', 'error'); return; }
playQuiz(editorQuiz.id);
};
// --- Host Game Logic ---
let hostGamePin = null;
let hostWs = null;
async function initHostLobby(quizId) {
state.currentQuizId = quizId;
$('#host-content').className = 'flex-1 flex flex-col';
$('#host-lobby').classList.remove('hidden');
$('#host-question, #host-leaderboard, #host-end').classList.add('hidden');
try {
const res = await api('/api/games', { method: 'POST', body: JSON.stringify({ quiz_id: quizId, max_players: 50 }) });
hostGamePin = res.pin;
state.currentGamePin = hostGamePin;
$('#host-pin-display').textContent = hostGamePin;
$('#lobby-max').textContent = res.game.settings.max_players;
connectWS(hostGamePin, true); // true = isHost
} catch (e) { toast(e.error || 'Erreur création partie', 'error'); showView('dashboard'); }
}
function connectWS(pin, isHost) {
if (hostWs) hostWs.close();
state.isHost = isHost;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
hostWs = new WebSocket(`${protocol}//${location.host}/ws/${pin}`);
hostWs.onopen = () => {
state.reconnectAttempts = 0;
hostWs.send(JSON.stringify({ type: 'join', data: { name: state.user?.username || 'Hôte', avatar: '' } }));
if (isHost) $('#btn-start-game').disabled = false;
};
hostWs.onmessage = (e) => handleHostMessage(JSON.parse(e.data));
hostWs.onclose = () => {
if (state.reconnectAttempts < 5) { state.reconnectAttempts++; setTimeout(() => connectWS(pin, isHost), 2000); }
else toast('Connexion perdue', 'error');
};
hostWs.onerror = () => toast('Erreur WS', 'error');
}
function handleHostMessage(msg) {
switch (msg.type) {
case 'state_sync':
updateHostLobby(msg.data);
break;
case 'player_joined':
updateHostLobby(msg.data); // data is player obj
break;
case 'player_left':
removeHostPlayer(msg.data);
break;
case 'lobby_update':
renderHostLobby(msg.data);
break;
case 'queue_status':
showQueueStatus(msg.data);
break;
case 'question_start':
startHostQuestion(msg.data);
break;
case 'answer_result':
showHostResults(msg.data);
break;
case 'leaderboard':
showHostLeaderboard(msg.data);
break;
case 'game_end':
showHostEnd(msg.data);
break;
case 'error':
toast(msg.data.message, 'error');
break;
}
}
function updateHostLobby(gameOrPlayer) {
// If full game state sent (state_sync)
if (gameOrPlayer.players !== undefined) {
renderHostLobby(gameOrPlayer.players);
if (gameOrPlayer.state === 'waiting') showQueueStatus({ position: 1 }); // Simplified
} else { // Single player joined
// Optimistic add or wait for lobby_update
}
}
function renderHostLobby(players) {
const list = $('#lobby-players');
list.innerHTML = players.map(p => `
${p.name[0].toUpperCase()}
${escapeHtml(p.name)}
${p.is_host ? 'HOST' : ''}
${p.connected ? '🟢' : '🔴'}
`).join('');
$('#lobby-count').textContent = players.length;
}
function showQueueStatus(data) {
$('#host-lobby').classList.add('hidden');
$('#host-queue-msg').classList.remove('hidden');
$('#queue-pos').textContent = data.position;
}
function startHostQuestion(data) {
$('#host-lobby, #host-leaderboard, #host-end').classList.add('hidden');
$('#host-question').classList.remove('hidden');
const q = data.question;
$('#host-q-num').textContent = `Q ${data.index + 1} / ${data.total}`;
animateTimerBar('#host-timer-bar', data.deadline_ms);
$('#host-q-content').innerHTML = renderQuestionHTML(q, true); // true = show correct answers for host
$('#host-responses').textContent = 'Réponses: 0';
// Count responses via WS (we'd need a counter message, simplified here)
// In real app, listen for 'answer_received' count.
}
function animateTimerBar(sel, deadline) {
const bar = $(sel);
const start = Date.now();
const duration = deadline - start;
bar.style.transition = 'none';
bar.style.width = '100%';
requestAnimationFrame(() => {
bar.style.transition = `width ${duration}ms linear`;
bar.style.width = '0%';
});
}
function renderQuestionHTML(q, isHost) {
let html = `${escapeHtml(q.text)}
`;
if (q.image) html += `

`;
if (['quiz', 'true_false', 'puzzle'].includes(q.type)) {
html += `
`;
q.options.forEach((opt, i) => {
const isCorrect = opt.is_correct;
html += `
${String.fromCharCode(65 + i)}
${escapeHtml(opt.text)}
${opt.image ? `

` : ''}
${isCorrect && isHost ? `
✓ BONNE RÉPONSE
` : ''}
`;
});
html += `
`;
} else if (q.type === 'type_answer') {
html += `
Réponse attendue: ${escapeHtml(q.correct_answer)}
`;
} else if (q.type === 'slider') {
html += `
Bonne réponse: ${q.correct_answer} ${q.slider_unit || ''} (Min: ${q.options[0].text}, Max: ${q.options[1].text})
`;
}
html += `
`;
return html;
}
$('#btn-start-game').onclick = async () => {
$('#btn-start-game').disabled = true;
$('#btn-start-game').innerHTML = `Attente places...`;
try {
await api(`/api/games/${hostGamePin}/start`, { method: 'POST' });
// Response handled by WS (queue_status or question_start)
} catch (e) {
toast(e.error || 'Erreur lancement', 'error');
$('#btn-start-game').disabled = false;
$('#btn-start-game').innerHTML = `Démarrer la partie`;
}
};
function showHostResults(data) {
$('#host-question').classList.add('hidden');
$('#host-leaderboard').classList.remove('hidden');
renderLeaderboard('#host-lb-list', data.leaderboard, true);
clearTimerBar('#host-timer-bar');
}
function showHostLeaderboard(data) {
renderLeaderboard('#host-lb-list', data.players, true);
}
function showHostEnd(data) {
$('#host-leaderboard').classList.add('hidden');
$('#host-end').classList.remove('hidden');
renderPodium('#host-final-podium', data.leaderboard);
}
function renderLeaderboard(sel, players, isHost) {
const container = $(sel);
container.innerHTML = players.map((p, i) => `
#${i+1}
${p.name[0].toUpperCase()}
${escapeHtml(p.name)}
Streak: ${p.streak}
${p.score.toLocaleString()} pts
`).join('');
}
function renderPodium(sel, players) {
const container = $(sel);
const top3 = players.slice(0, 3);
container.innerHTML = `
${top3.map((p, i) => `
${p.name[0].toUpperCase()}
${escapeHtml(p.name)}
${p.score.toLocaleString()} pts
`).join('')}
${players.slice(3).map((p, i) => `
#${i+4}
${escapeHtml(p.name)}
${p.score.toLocaleString()}
`).join('')}
`;
}
function clearTimerBar(sel) { $(sel).style.transition = 'none'; $(sel).style.width = '100%'; }
// --- Player Game Logic ---
let playerWs = null;
let playerName = '';
function joinGame(pin) {
state.currentGamePin = pin;
showView('player');
$('#player-content').className = 'flex-1 flex flex-col items-center justify-center p-4';
$('#player-lobby').classList.remove('hidden');
$('#player-question, #player-result, #player-end').classList.add('hidden');
$('#player-pin').textContent = pin;
playerName = state.user?.username || `Player${Math.floor(Math.random()*1000)}`;
connectPlayerWS(pin);
}
function connectPlayerWS(pin) {
if (playerWs) playerWs.close();
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
playerWs = new WebSocket(`${protocol}//${location.host}/ws/${pin}`);
playerWs.onopen = () => {
playerWs.send(JSON.stringify({ type: 'join', data: { name: playerName, avatar: '' } }));
};
playerWs.onmessage = (e) => handlePlayerMessage(JSON.parse(e.data));
playerWs.onclose = () => setTimeout(() => connectPlayerWS(pin), 3000);
}
function handlePlayerMessage(msg) {
switch (msg.type) {
case 'state_sync':
if (msg.data.state === 'lobby') showPlayerLobby();
else if (msg.data.state === 'question') startPlayerQuestion(msg.data.current_question, msg.data.question_deadline_ms);
break;
case 'question_start':
startPlayerQuestion(msg.data.question, msg.data.deadline_ms);
break;
case 'answer_result':
showPlayerResult(msg.data);
break;
case 'leaderboard':
updatePlayerRank(msg.data);
break;
case 'game_end':
showPlayerEnd(msg.data);
break;
case 'error':
toast(msg.data.message, 'error');
break;
}
}
function showPlayerLobby() {
$('#player-lobby').classList.remove('hidden');
$('#player-question, #player-result, #player-end').classList.add('hidden');
}
function startPlayerQuestion(q, deadline) {
$('#player-lobby').classList.add('hidden');
$('#player-question').classList.remove('hidden');
$('#player-result').classList.add('hidden');
$('#player-feedback').classList.add('hidden');
$('#player-q-num').textContent = `Question`;
animateTimerBar('#player-timer-bar', deadline);
$('#player-q-content').innerHTML = renderPlayerQuestionHTML(q);
// Bind answer buttons
$$('.answer-btn').forEach(btn => btn.onclick = () => sendAnswer(btn.dataset.value));
$('#player-type-input')?.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendAnswer(e.target.value); });
$('#player-slider-input')?.addEventListener('input', (e) => { $('#slider-val').textContent = e.target.value; });
$('#btn-slider-submit')?.onclick = () => sendAnswer(parseFloat($('#player-slider-input').value));
}
function renderPlayerQuestionHTML(q) {
let html = `${escapeHtml(q.text)}
`;
if (q.image) html += `
`;
if (['quiz', 'true_false'].includes(q.type)) {
html += ``;
q.options.forEach((opt, i) => {
html += ``;
});
html += `
`;
} else if (q.type === 'type_answer') {
html += ``;
} else if (q.type === 'puzzle') {
html += ``;
setTimeout(() => initPuzzleSortable(q.options), 0);
} else if (q.type === 'slider') {
const min = parseFloat(q.options[0].text), max = parseFloat(q.options[1].text);
html += ``;
}
return html;
}
function initPuzzleSortable(options) {
const container = $('#puzzle-list');
container.innerHTML = options.map((opt, i) => `
${i+1}
${escapeHtml(opt.text)}
`).join('');
// Simple SortableJS logic (native HTML5 Drag & Drop for zero deps)
let dragged = null;
$$('.puzzle-item').forEach(item => {
item.ondragstart = e => { dragged = item; item.classList.add('opacity-50'); e.dataTransfer.effectAllowed = 'move'; };
item.ondragend = () => { item.classList.remove('opacity-50'); dragged = null; };
item.ondragover = e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
item.ondrop = e => {
e.preventDefault();
if (dragged && dragged !== item) {
const from = parseInt(dragged.dataset.index);
const to = parseInt(item.dataset.index);
// Reorder in DOM
if (from < to) item.parentNode.insertBefore(dragged, item.nextSibling);
else item.parentNode.insertBefore(dragged, item);
// Update indices
$$('.puzzle-item').forEach((el, idx) => { el.dataset.index = idx; el.querySelector('span').textContent = idx+1; });
}
};
});
}
function sendAnswer(answer) {
if (!playerWs || playerWs.readyState !== WebSocket.OPEN) return;
// Disable inputs immediately
$$('.answer-btn').forEach(b => b.disabled = true);
$('#player-type-input')?.disabled = true;
$('#player-slider-input')?.disabled = true;
$('#btn-slider-submit')?.disabled = true;
$$('.puzzle-item').forEach(i => i.draggable = false);
playerWs.send(JSON.stringify({ type: 'answer', data: { q_index: 0, answer } })); // q_index handled by server state
}
function showPlayerResult(data) {
$('#player-question').classList.add('hidden');
$('#player-result').classList.remove('hidden');
clearTimerBar('#player-timer-bar');
const correct = data.correct;
$('#player-result-icon').textContent = correct ? '✅' : '❌';
$('#player-result-text').textContent = correct ? 'Bonne réponse !' : `Mauvaise ! La réponse: ${data.correct_answer}`;
$('#player-result-points').textContent = `+${data.your_points} pts`;
$('#player-result-points').className = correct ? 'text-zap-yellow' : 'text-zap-red';
// Rank update happens via leaderboard message usually, but we can optimistically update
}
function updatePlayerRank(data) {
const me = data.players.find(p => p.name === playerName); // Imperfect match, use ID in real app
if (me) {
$('#player-rank').textContent = `#${data.players.indexOf(me) + 1}`;
$('#player-score').textContent = `${me.score.toLocaleString()} pts`;
$('#player-streak').textContent = me.streak;
}
}
function showPlayerEnd(data) {
$('#player-result').classList.add('hidden');
$('#player-end').classList.remove('hidden');
const me = data.leaderboard.find(p => p.name === playerName);
const rank = data.leaderboard.indexOf(me) + 1;
$('#player-final-rank').textContent = `#${rank}`;
$('#player-final-score').textContent = `${me?.score.toLocaleString() || 0} pts`;
// Confetti effect for top 3
if (rank <= 3) launchConfetti();
}
function launchConfetti() {
const colors = ['#FFD700', '#FF6B35', '#00D4AA', '#FF3366', '#FFFFFF'];
for (let i=0; i<50; i++) {
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:${Math.random()*100}vw;top:-10px;width:10px;height:10px;background:${colors[Math.floor(Math.random()*colors.length)]};border-radius:50%;pointer-events:none;z-index:9999;animation:fall ${2+Math.random()*2}s linear forwards`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
}
// --- Global Helpers ---
function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random()*16|0; return (c=='x'?r:r&0x3|0x8).toString(16); }); }
// Init
document.addEventListener('DOMContentLoaded', () => {
// Inject keyframes for confetti
const style = document.createElement('style');
style.textContent = `@keyframes fall { to { transform: translateY(110vh) rotate(720deg); opacity: 0; } }`;
document.head.appendChild(style);
checkAuth();
});
// Expose globally for inline onclick
window.openEditor = openEditor;
window.playQuiz = playQuiz;
window.deleteQuiz = deleteQuiz;
window.addQuestion = addQuestion;
window.changeQuestionType = changeQuestionType;
window.updateQuestion = updateQuestion;
window.updateOption = updateOption;
window.addOption = addOption;
window.removeOption = removeOption;
window.deleteQuestion = deleteQuestion;
window.removeQuestionImage = removeQuestionImage;
window.removeOptionImage = removeOptionImage;
window.joinGame = joinGame; // For public quiz cards