QuizZip / frontend.js
NathMen12's picture
Create frontend.js
f6d4c54 verified
Raw
History Blame Contribute Delete
42.7 kB
// 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 = `<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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = '<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);
}
// --- 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 = `<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('');
// 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) => `
<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();
// 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<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);
};
// --- 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 => `
<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); // 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 = `<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' });
// 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 = `<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%'; }
// --- 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 = `<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('');
// 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