| |
| |
| |
| |
|
|
| |
| const state = { |
| user: null, |
| token: null, |
| currentView: 'auth', |
| currentQuizId: null, |
| currentGamePin: null, |
| isHost: false, |
| ws: null, |
| reconnectAttempts: 0, |
| gameData: null, |
| questionDeadline: 0, |
| timerInterval: null, |
| }; |
|
|
| |
| const $ = (sel, ctx = document) => ctx.querySelector(sel); |
| const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; |
|
|
| |
| const api = (url, opts = {}) => fetch(url, { |
| ...opts, |
| headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, |
| credentials: 'include' |
| }).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 = `<span>${msg}</span><button onclick="this.parentElement.remove()">✕</button>`; |
| 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])) : ''; |
|
|
| |
| 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'); |
| }; |
|
|
| |
| $$('[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); |
| }); |
|
|
| |
| async function loadDashboard() { |
| const container = $('#quiz-list'); |
| container.innerHTML = '<div class="col-span-full text-center text-zap-muted py-8">Chargement...</div>'; |
| try { |
| const quizzes = await api('/api/quizzes'); |
| renderQuizCards(quizzes, container, true); |
| } catch (e) { container.innerHTML = `<div class="col-span-full text-center text-zap-red py-8">Erreur: ${e.error}</div>`; } |
| } |
|
|
| async function loadPublicQuizzes() { |
| const container = $('#public-quiz-list'); |
| container.innerHTML = '<div class="col-span-full text-center text-zap-muted py-8">Chargement...</div>'; |
| try { |
| const quizzes = await api('/api/quizzes/public'); |
| renderQuizCards(quizzes, container, false); |
| } catch (e) { container.innerHTML = `<div class="col-span-full text-center text-zap-red py-8">Erreur: ${e.error}</div>`; } |
| } |
|
|
| function renderQuizCards(quizzes, container, isOwner) { |
| if (!quizzes.length) { |
| container.innerHTML = `<div class="col-span-full text-center text-zap-muted py-12">Aucun quiz ${isOwner ? 'créé' : 'public'}. ${isOwner ? '<button class="text-zap-yellow underline" onclick="openEditor()">Créer le premier !</button>' : ''}</div>`; |
| return; |
| } |
| container.innerHTML = quizzes.map(q => ` |
| <div class="quiz-card bg-zap-card rounded-xl border border-zap-border overflow-hidden flex flex-col hover:border-zap-yellow/50 transition-colors"> |
| ${q.cover_image ? `<img src="${q.cover_image}" class="w-full h-40 object-cover" loading="lazy">` : `<div class="w-full h-40 bg-gradient-to-br from-zap-purple/50 to-zap-blue/50"></div>`} |
| <div class="p-4 flex-1 flex flex-col"> |
| <div class="flex items-start justify-between mb-2"> |
| <h3 class="font-space font-bold text-lg truncate pr-2">${escapeHtml(q.title)}</h3> |
| <span class="badge ${q.is_public ? 'badge-primary' : 'badge-secondary'} text-xs">${q.is_public ? 'Public' : 'Privé'}</span> |
| </div> |
| <p class="text-zap-muted text-sm flex-1 mb-3">${escapeHtml(q.description || 'Sans description')}</p> |
| <div class="flex items-center justify-between text-xs text-zap-muted border-t border-zap-border pt-3"> |
| <span>${q.questions?.length || 0} questions</span> |
| <span>${q.play_count || 0} parties</span> |
| </div> |
| </div> |
| <div class="p-4 border-t border-zap-border flex gap-2 bg-zap-dark/50"> |
| ${isOwner ? ` |
| <button onclick="openEditor('${q.id}')" class="btn btn-outline btn-sm flex-1">Éditer</button> |
| <button onclick="playQuiz('${q.id}')" class="btn btn-primary btn-sm flex-1">Jouer ▶</button> |
| <button onclick="deleteQuiz('${q.id}')" class="btn btn-ghost btn-sm text-zap-red" title="Supprimer">🗑️</button> |
| ` : ` |
| <button onclick="playQuiz('${q.id}')" class="btn btn-primary btn-sm flex-1">Jouer ▶</button> |
| `} |
| </div> |
| </div> |
| `).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); |
| } |
|
|
| |
| 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'); } |
| }; |
|
|
| |
| function renderQuestionsList() { |
| const container = $('#questions-container'); |
| const countEl = $('#q-count'); |
| countEl.textContent = editorQuiz.questions.length; |
| |
| if (!editorQuiz.questions.length) { |
| container.innerHTML = `<div class="p-8 text-center text-zap-muted">Aucune question. Ajoute-en une !</div>`; |
| return; |
| } |
| |
| container.innerHTML = editorQuiz.questions.map((q, i) => renderQuestionEditor(q, i)).join(''); |
| |
| |
| $$('.q-image-input').forEach(inp => inp.onchange = e => uploadQuestionImage(inp, e.target.files[0])); |
| |
| $$('.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) => ` |
| <div class="option-row flex items-center gap-2 p-2 bg-zap-dark/50 rounded-lg"> |
| <input type="${q.type === 'quiz' ? 'radio' : 'checkbox'}" name="correct_${index}" class="w-5 h-5 accent-zap-yellow" ${opt.is_correct ? 'checked' : ''} onchange="updateOption(${index}, ${oi}, 'is_correct', this.checked)"> |
| <input type="text" class="input-field flex-1 text-sm" value="${escapeHtml(opt.text)}" placeholder="Option ${oi+1}" onchange="updateOption(${index}, ${oi}, 'text', this.value)"> |
| <div class="relative w-16 h-16 flex-shrink-0"> |
| <img src="${opt.image || ''}" class="w-full h-full object-cover rounded border border-zap-border ${opt.image ? '' : 'hidden'}" id="opt-img-${index}-${oi}"> |
| <input type="file" class="opt-image-input absolute inset-0 w-full h-full opacity-0 cursor-pointer" accept="image/*" data-q="${index}" data-o="${oi}" title="Changer image"> |
| ${opt.image ? `<button type="button" onclick="removeOptionImage(${index}, ${oi})" class="absolute top-1 right-1 bg-zap-red/90 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center">✕</button>` : ''} |
| </div> |
| <button onclick="removeOption(${index}, ${oi})" class="text-zap-red hover:text-red-400 p-1" title="Supprimer">🗑️</button> |
| </div> |
| `).join(''); |
| optionsHtml += `<button onclick="addOption(${index})" class="btn btn-outline btn-sm w-full mt-2">+ Ajouter option</button>`; |
| } else if (q.type === 'type_answer') { |
| optionsHtml = `<div class="input-group"><label class="input-label">Réponse exacte *</label><input type="text" class="input-field" value="${escapeHtml(q.correct_answer || '')}" onchange="updateQuestion(${index}, 'correct_answer', this.value)" placeholder="Réponse attendue (sensible à la casse)"></div>`; |
| } else if (q.type === 'slider') { |
| optionsHtml = ` |
| <div class="grid gap-4 sm:grid-cols-3"> |
| <div class="input-group"><label class="input-label">Bonne réponse (nombre)</label><input type="number" step="0.01" class="input-field" value="${q.correct_answer ?? 0}" onchange="updateQuestion(${index}, 'correct_answer', parseFloat(this.value))"></div> |
| <div class="input-group"><label class="input-label">Min</label><input type="number" step="0.01" class="input-field" value="${q.options?.[0]?.text ?? 0}" onchange="updateQuestion(${index}, 'slider_min', parseFloat(this.value))"></div> |
| <div class="input-group"><label class="input-label">Max</label><input type="number" step="0.01" class="input-field" value="${q.options?.[1]?.text ?? 100}" onchange="updateQuestion(${index}, 'slider_max', parseFloat(this.value))"></div> |
| <div class="input-group sm:col-span-3"><label class="input-label">Unité (ex: km, °C, ans)</label><input type="text" class="input-field" value="${q.options?.[2]?.text || ''}" onchange="updateQuestion(${index}, 'slider_unit', this.value)" placeholder="Unité d'affichage"></div> |
| </div> |
| `; |
| } |
|
|
| return ` |
| <div class="question-block p-4 bg-zap-dark/30 last:border-0" data-qid="${index}"> |
| <div class="flex items-start justify-between gap-4 mb-4"> |
| <div class="flex items-center gap-3 flex-1"> |
| <span class="font-space text-xl font-bold text-zap-yellow">${index + 1}.</span> |
| <div class="flex-1 min-w-0"> |
| <div class="flex items-center gap-2 mb-1"> |
| <span class="badge badge-outline font-space text-xs">${typeLabels[q.type]}</span> |
| <select onchange="changeQuestionType(${index}, this.value)" class="input-field input-field-sm w-auto flex-shrink-0"> |
| <option value="quiz" ${q.type==='quiz'?'selected':''}>Quiz (Choix multiple)</option> |
| <option value="true_false" ${q.type==='true_false'?'selected':''}>Vrai / Faux</option> |
| <option value="type_answer" ${q.type==='type_answer'?'selected':''}>Saisie texte</option> |
| <option value="puzzle" ${q.type==='puzzle'?'selected':''}>Ordre (Puzzle)</option> |
| <option value="slider" ${q.type==='slider'?'selected':''}>Curseur (Nombre)</option> |
| </select> |
| </div> |
| <textarea class="input-field" rows="2" placeholder="Ta question..." onchange="updateQuestion(${index}, 'text', this.value)">${escapeHtml(q.text)}</textarea> |
| </div> |
| </div> |
| <div class="flex items-center gap-2 flex-shrink-0"> |
| <div class="input-group flex-shrink-0 w-24"><label class="input-label">Temps</label><input type="number" min="5" max="240" class="input-field text-center" value="${q.time_limit}" onchange="updateQuestion(${index}, 'time_limit', parseInt(this.value))"></div> |
| <div class="input-group flex-shrink-0 w-24"><label class="input-label">Points</label><input type="number" min="0" max="10000" class="input-field text-center" value="${q.points}" onchange="updateQuestion(${index}, 'points', parseInt(this.value))"></div> |
| <button onclick="deleteQuestion(${index})" class="btn btn-ghost p-2 text-zap-red hover:bg-zap-red/10" title="Supprimer">🗑️</button> |
| </div> |
| </div> |
| |
| <div class="mb-3"> |
| <label class="input-label">Image de la question</label> |
| <div class="flex gap-2"> |
| <input type="file" class="q-image-input hidden" accept="image/*" data-q="${index}" data-preview="q-img-preview-${index}"> |
| <button type="button" onclick="this.previousElementSibling.click()" class="btn btn-outline btn-sm">🖼️ Choisir</button> |
| <img id="q-img-preview-${index}" src="${q.image || ''}" class="w-16 h-16 rounded-lg object-cover border border-zap-border ${q.image ? '' : 'hidden'}"> |
| ${q.image ? `<button type="button" onclick="removeQuestionImage(${index})" class="btn btn-ghost btn-sm p-1 text-zap-red" title="Retirer">✕</button>` : ''} |
| </div> |
| </div> |
| |
| <div class="options-container border-t border-zap-border pt-4">${optionsHtml}</div> |
| </div> |
| `; |
| } |
|
|
| 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(); |
| |
| setTimeout(() => document.querySelector(`[data-qid="${editorQuiz.questions.length-1}"]`)?.scrollIntoView({behavior: 'smooth', block: 'center'}), 0); |
| } |
|
|
| function changeQuestionType(index, newType) { |
| |
| const old = editorQuiz.questions[index]; |
| const newQ = { ...old, type: newType, options: undefined, correct_answer: undefined }; |
| |
| 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; |
| |
| 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(); |
| } |
| } |
|
|
| 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(); } |
|
|
| |
| $('#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'); |
| |
| for (let i=0; i<editorQuiz.questions.length; i++) { |
| const q = editorQuiz.questions[i]; |
| if (!q.text.trim()) return toast(`Question ${i+1}: Texte manquant`, 'error'); |
| if (['quiz', 'true_false', 'puzzle'].includes(q.type)) { |
| if (!q.options?.some(o => 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); |
| }; |
|
|
| |
| 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); |
| } 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); |
| 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 (gameOrPlayer.players !== undefined) { |
| renderHostLobby(gameOrPlayer.players); |
| if (gameOrPlayer.state === 'waiting') showQueueStatus({ position: 1 }); |
| } else { |
| |
| } |
| } |
|
|
| function renderHostLobby(players) { |
| const list = $('#lobby-players'); |
| list.innerHTML = players.map(p => ` |
| <li class="flex items-center justify-between p-2 bg-zap-dark/50 rounded-lg"> |
| <div class="flex items-center gap-2"> |
| <span class="w-8 h-8 rounded-full bg-zap-purple flex items-center justify-center text-xs font-bold">${p.name[0].toUpperCase()}</span> |
| <span class="font-medium">${escapeHtml(p.name)}</span> |
| ${p.is_host ? '<span class="badge badge-xs badge-primary">HOST</span>' : ''} |
| </div> |
| <span class="text-zap-muted text-xs">${p.connected ? '🟢' : '🔴'}</span> |
| </li> |
| `).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); |
| $('#host-responses').textContent = 'Réponses: 0'; |
| |
| |
| |
| } |
|
|
| 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 = `<div class="text-center"><h3 class="font-space text-2xl md:text-3xl font-bold mb-6">${escapeHtml(q.text)}</h3>`; |
| if (q.image) html += `<img src="${q.image}" class="max-w-full h-auto max-h-60 mx-auto mb-6 rounded-xl shadow-lg">`; |
| |
| if (['quiz', 'true_false', 'puzzle'].includes(q.type)) { |
| html += `<div class="grid gap-3 sm:grid-cols-2 max-w-xl mx-auto">`; |
| q.options.forEach((opt, i) => { |
| const isCorrect = opt.is_correct; |
| html += ` |
| <div class="option-btn p-4 rounded-xl border-2 ${isCorrect && isHost ? 'border-zap-yellow bg-zap-yellow/10' : 'border-zap-border bg-zap-card'} text-left transition-all"> |
| <div class="flex items-center gap-3"> |
| <span class="w-8 h-8 rounded-full border-2 flex items-center justify-center font-bold ${isCorrect && isHost ? 'border-zap-yellow text-zap-yellow bg-zap-yellow/10' : 'border-zap-border'}"> |
| ${String.fromCharCode(65 + i)} |
| </span> |
| <span class="flex-1">${escapeHtml(opt.text)}</span> |
| </div> |
| ${opt.image ? `<img src="${opt.image}" class="mt-2 max-h-20 mx-auto rounded">` : ''} |
| ${isCorrect && isHost ? `<div class="mt-2 text-zap-yellow text-sm font-semibold">✓ BONNE RÉPONSE</div>` : ''} |
| </div> |
| `; |
| }); |
| html += `</div>`; |
| } else if (q.type === 'type_answer') { |
| html += `<div class="max-w-md mx-auto text-center text-zap-muted">Réponse attendue: <strong class="text-zap-light">${escapeHtml(q.correct_answer)}</strong></div>`; |
| } else if (q.type === 'slider') { |
| html += `<div class="max-w-md mx-auto text-center text-zap-muted">Bonne réponse: <strong class="text-zap-light text-2xl">${q.correct_answer} ${q.slider_unit || ''}</strong> (Min: ${q.options[0].text}, Max: ${q.options[1].text})</div>`; |
| } |
| html += `</div>`; |
| return html; |
| } |
|
|
| $('#btn-start-game').onclick = async () => { |
| $('#btn-start-game').disabled = true; |
| $('#btn-start-game').innerHTML = `<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Attente places...`; |
| try { |
| await api(`/api/games/${hostGamePin}/start`, { method: 'POST' }); |
| |
| } catch (e) { |
| toast(e.error || 'Erreur lancement', 'error'); |
| $('#btn-start-game').disabled = false; |
| $('#btn-start-game').innerHTML = `<svg class="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M6.3 2.841A1.5 1.5 0 004 4.11V16a1.5 1.5 0 002.3 1.269l9.344-5.945a1.5 1.5 0 000-2.538L6.3 2.84z"/></svg>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) => ` |
| <div class="flex items-center gap-3 p-3 rounded-xl ${i===0 ? 'bg-zap-yellow/10 border border-zap-yellow/30' : 'bg-zap-dark/50'}"> |
| <span class="font-space text-2xl font-bold w-12 text-center ${i===0?'text-zap-yellow':i===1?'text-gray-400':i===2?'text-amber-700':'text-zap-muted'}">#${i+1}</span> |
| <div class="w-10 h-10 rounded-full bg-zap-purple flex items-center justify-center font-bold text-sm flex-shrink-0">${p.name[0].toUpperCase()}</div> |
| <div class="flex-1 min-w-0"><div class="font-medium truncate">${escapeHtml(p.name)}</div><div class="text-xs text-zap-muted">Streak: ${p.streak}</div></div> |
| <div class="font-space font-bold text-xl text-zap-yellow">${p.score.toLocaleString()} pts</div> |
| </div> |
| `).join(''); |
| } |
|
|
| function renderPodium(sel, players) { |
| const container = $(sel); |
| const top3 = players.slice(0, 3); |
| container.innerHTML = ` |
| <div class="flex items-end justify-center gap-4 relative z-10"> |
| ${top3.map((p, i) => ` |
| <div class="flex flex-col items-center ${i===0 ? 'order-2' : i===1 ? 'order-1' : 'order-3'}"> |
| <div class="w-24 h-24 rounded-full bg-zap-card border-4 flex items-center justify-center text-3xl font-bold ${i===0?'border-zap-yellow':i===1?'border-gray-400':'border-amber-700'} ${i===0?'shadow-[0_0_30px_rgba(255,215,0,0.5)]':''}">${p.name[0].toUpperCase()}</div> |
| <div class="mt-2 text-center w-32"> |
| <div class="font-space font-bold ${i===0?'text-zap-yellow':''}">${escapeHtml(p.name)}</div> |
| <div class="text-zap-muted text-sm">${p.score.toLocaleString()} pts</div> |
| </div> |
| </div> |
| `).join('')} |
| </div> |
| <div class="mt-8 w-full max-w-md"> |
| ${players.slice(3).map((p, i) => ` |
| <div class="flex items-center gap-3 p-2 bg-zap-dark/50 rounded-lg mb-2"> |
| <span class="font-space font-bold w-10 text-center text-zap-muted">#${i+4}</span> |
| <span class="font-medium truncate">${escapeHtml(p.name)}</span> |
| <span class="ml-auto font-space font-bold text-zap-yellow">${p.score.toLocaleString()}</span> |
| </div> |
| `).join('')} |
| </div> |
| `; |
| } |
|
|
| function clearTimerBar(sel) { $(sel).style.transition = 'none'; $(sel).style.width = '100%'; } |
|
|
| |
| 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); |
| |
| $$('.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 = `<h3 class="font-space text-2xl md:text-3xl font-bold mb-6 text-center">${escapeHtml(q.text)}</h3>`; |
| if (q.image) html += `<img src="${q.image}" class="max-w-full h-auto max-h-60 mx-auto mb-6 rounded-xl shadow-lg">`; |
| |
| if (['quiz', 'true_false'].includes(q.type)) { |
| html += `<div class="grid gap-3 sm:grid-cols-2 w-full max-w-xl mx-auto">`; |
| q.options.forEach((opt, i) => { |
| html += `<button class="answer-btn p-4 rounded-xl border-2 border-zap-border bg-zap-card text-left font-medium hover:border-zap-yellow/50 hover:bg-zap-yellow/5 transition-all" data-value="${i}">${String.fromCharCode(65+i)}. ${escapeHtml(opt.text)}</button>`; |
| }); |
| html += `</div>`; |
| } else if (q.type === 'type_answer') { |
| html += `<div class="w-full max-w-md mx-auto"><input type="text" id="player-type-input" class="input-field text-center text-lg" placeholder="Ta réponse..." autocomplete="off"></div>`; |
| } else if (q.type === 'puzzle') { |
| html += `<div class="w-full max-w-xl mx-auto"><p class="text-center text-zap-muted mb-4">Glisse pour ordonner</p><div id="puzzle-list" class="space-y-2"></div></div>`; |
| setTimeout(() => initPuzzleSortable(q.options), 0); |
| } else if (q.type === 'slider') { |
| const min = parseFloat(q.options[0].text), max = parseFloat(q.options[1].text); |
| html += `<div class="w-full max-w-xl mx-auto text-center"> |
| <div class="text-xl font-space font-bold text-zap-yellow mb-2" id="slider-val">${min}</div> |
| <input type="range" id="player-slider-input" min="${min}" max="${max}" step="1" value="${min}" class="w-full h-6 accent-zap-yellow"> |
| <div class="flex justify-between text-zap-muted text-xs mt-1"><span>${min} ${q.options[2].text}</span><span>${max} ${q.options[2].text}</span></div> |
| <button id="btn-slider-submit" class="btn btn-primary mt-4 w-full">Valider</button> |
| </div>`; |
| } |
| return html; |
| } |
|
|
| function initPuzzleSortable(options) { |
| const container = $('#puzzle-list'); |
| container.innerHTML = options.map((opt, i) => ` |
| <div class="puzzle-item flex items-center gap-3 p-3 bg-zap-card border border-zap-border rounded-xl" draggable="true" data-index="${i}"> |
| <span class="w-8 h-8 rounded-full border-2 border-zap-border flex items-center justify-center font-bold text-zap-muted">${i+1}</span> |
| <span class="flex-1">${escapeHtml(opt.text)}</span> |
| <svg class="w-5 h-5 text-zap-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/></svg> |
| </div> |
| `).join(''); |
| |
| |
| 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); |
| |
| if (from < to) item.parentNode.insertBefore(dragged, item.nextSibling); |
| else item.parentNode.insertBefore(dragged, item); |
| |
| $$('.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; |
| |
| $$('.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 } })); |
| } |
|
|
| 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'; |
| |
| |
| } |
|
|
| function updatePlayerRank(data) { |
| const me = data.players.find(p => p.name === playerName); |
| 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`; |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| 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); }); } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| const style = document.createElement('style'); |
| style.textContent = `@keyframes fall { to { transform: translateY(110vh) rotate(720deg); opacity: 0; } }`; |
| document.head.appendChild(style); |
| |
| checkAuth(); |
| }); |
|
|
| |
| 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; |