// 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) => `
${opt.image ? `` : ''}
`).join(''); optionsHtml += ``; } else if (q.type === 'type_answer') { optionsHtml = `
`; } else if (q.type === 'slider') { optionsHtml = `
`; } return `
${index + 1}.
${typeLabels[q.type]}
${q.image ? `` : ''}
${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 += `

    Glisse pour ordonner

    `; setTimeout(() => initPuzzleSortable(q.options), 0); } else if (q.type === 'slider') { const min = parseFloat(q.options[0].text), max = parseFloat(q.options[1].text); html += `
    ${min}
    ${min} ${q.options[2].text}${max} ${q.options[2].text}
    `; } 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