quiz-runtime.html (16655B)
1 <script> 2 /* ── ESTADO DA APLICAÇÃO ── */ 3 let currentQuiz = null; 4 /* questions, qIndex, acertos, erros, answered → usam _qState (quiz-engine.html) */ 5 6 const BASE_TITLE = document.title; 7 8 let activeFloater = null; 9 let currentTextComments = {}; 10 let lastSubjectRoute = null; 11 12 /* ── UTILITÁRIOS ── */ 13 /* shuffle, esc, md → quiz-parser.html | _qState, _renderQuestion, etc. → quiz-engine.html */ 14 15 function slugifyText(s) { 16 return String(s || '') 17 .normalize('NFD').replace(/[\u0300-\u036f]/g, '') 18 .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); 19 } 20 21 function show(id) { 22 ['page-home','page-quiz','page-result','page-text'].forEach(p => { 23 const el = document.getElementById(p); 24 if (el) el.classList.toggle('active', p === id); 25 }); 26 } 27 28 /* ── ROTEAMENTO ── */ 29 function encodeHash(type, a, b, c) { 30 if (type === 'home') return ''; 31 if (type === 'disc') return '#disc/' + encodeURIComponent(a); 32 if (type === 'assunto') return '#assunto/' + [a, b].map(encodeURIComponent).join('/'); 33 if (type === 'quiz') return '#quiz/' + [a, b, c].map(encodeURIComponent).join('/'); 34 if (type === 'simulado') return '#simulado/' + [a, b].map(encodeURIComponent).join('/'); 35 return ''; 36 } 37 38 function decodeHash(hash) { 39 if (!hash || hash === '#') return { type: 'home' }; 40 const parts = (hash.startsWith('#') ? hash.slice(1) : hash) 41 .split('/').map(s => { try { return decodeURIComponent(s); } catch(e) { return s; } }); 42 43 const type = parts[0]; 44 if (type === 'disc' && parts.length >= 2) 45 return { type: 'disc', disc: parts[1] }; 46 if (type === 'assunto' && parts.length >= 3) 47 return { type: 'assunto', discFolder: parts[1], slug: parts[2] }; 48 if (type === 'quiz' && parts.length >= 4) 49 return { type: 'quiz', discFolder: parts[1], slug: parts[2], quizId: parts[3] }; 50 if (type === 'simulado' && parts.length >= 3) 51 return { type: 'simulado', discFolder: parts[1], quizId: parts[2] }; 52 53 return { type: 'home' }; 54 } 55 56 function navigate(hash, replace) { 57 const url = window.location.pathname + window.location.search + (hash || ''); 58 if (replace) history.replaceState({ hash }, '', url || window.location.pathname); 59 else history.pushState({ hash }, '', url || window.location.pathname); 60 } 61 62 function renderRoute(route) { 63 if (route.type === 'disc') _showDisc(route.disc); 64 else if (route.type === 'assunto') _openSubject(route.discFolder, route.slug); 65 else if (route.type === 'quiz') _startQuiz(route.discFolder, route.slug, route.quizId); 66 else if (route.type === 'simulado')_startSimulado(route.discFolder, route.quizId); 67 else _showHome(); 68 } 69 70 window.addEventListener('popstate', () => renderRoute(decodeHash(window.location.hash))); 71 72 /* ── INTERFACE: HOME ── */ 73 function showHome() { navigate('', false); _showHome(); } 74 75 function _showHome() { 76 closeFloater(); 77 buildHome(); 78 show('page-home'); 79 document.getElementById('breadcrumb').textContent = ''; 80 document.title = BASE_TITLE; 81 } 82 83 function buildHome() { 84 const el = document.getElementById('page-home'); 85 el.innerHTML = ` 86 <div class="disc-grid"> 87 ${DATA.map(disc => { 88 const n = disc.subjects.length; 89 const nq = disc.subjects.reduce((acc, s) => acc + s.quizzes.length, 0) + (disc.quizzes ? disc.quizzes.length : 0); 90 const meta = [ 91 n ? `${n} assunto${n > 1 ? 's' : ''}` : null, 92 nq ? `${nq} quiz${nq > 1 ? 'zes' : ''}` : null, 93 ].filter(Boolean).join(' · ') || 'sem conteúdo'; 94 return ` 95 <a class="disc-card fade" href="${encodeHash('disc', disc.discipline)}" 96 onclick="openDisc('${esc(disc.discipline)}');return false;"> 97 <div class="disc-card-name">${esc(disc.discipline)}</div> 98 <div class="disc-card-meta">${meta}</div> 99 </a>`; 100 }).join('')} 101 </div>`; 102 } 103 104 /* ── INTERFACE: DISCIPLINA ── */ 105 function openDisc(discName) { navigate(encodeHash('disc', discName), false); _showDisc(discName); } 106 107 function _showDisc(discName) { 108 closeFloater(); 109 const disc = DATA.find(d => d.discipline === discName); 110 if (!disc) { _showHome(); return; } 111 112 const introHtml = disc.intro && disc.intro.content 113 ? `<div class="disc-intro"> 114 <article class="prose disc-intro-prose">${disc.intro.content}</article> 115 ${disc.intro.references && disc.intro.references.length 116 ? `<div class="references disc-intro-refs"> 117 <div class="ref-head">referências</div> 118 <div class="ref-list">${disc.intro.references.map(r => `<p>${esc(r)}</p>`).join('')}</div> 119 </div>` 120 : ''} 121 </div>` 122 : ''; 123 124 const simuladosHtml = disc.quizzes && disc.quizzes.length ? ` 125 <div class="subject-group"> 126 <div class="sq-label" style="margin-bottom:10px;">Simulados da Disciplina</div> 127 ${disc.quizzes.map(qz => ` 128 <a class="sq-item" href="${encodeHash('simulado', disc.folderKey, qz.id)}" 129 onclick="startSimulado('${esc(disc.folderKey)}','${esc(qz.id)}');return false;" 130 style="border-bottom:none; background:var(--surface); padding: 10px; border-radius: 4px; margin-bottom: 5px;"> 131 <span class="sq-name" style="color:var(--amber); font-weight:bold;">${esc(qz.title)}</span> 132 <span class="sq-meta">${qz.questions.length} questões</span> 133 </a> 134 `).join('')} 135 </div> 136 ` : ''; 137 138 const subjectsHtml = disc.subjects.map(s => { 139 const bits = []; 140 if (s.text) bits.push('texto'); 141 if (s.quizzes.length) bits.push(`${s.quizzes.length} quiz${s.quizzes.length > 1 ? 'zes' : ''}`); 142 const meta = bits.join(' · ') || 'vazio'; 143 return ` 144 <a class="subject-row" href="${encodeHash('assunto', disc.folderKey, s.slug)}" 145 onclick="openSubject('${esc(disc.folderKey)}','${esc(s.slug)}');return false;"> 146 <span class="subject-row-name">${esc(s.name)}</span> 147 <span class="subject-row-meta">${meta}</span> 148 </a>`; 149 }).join(''); 150 151 const el = document.getElementById('page-home'); 152 el.innerHTML = ` 153 <div class="discipline fade"> 154 <div class="disc-back"><a href="#" onclick="showHome();return false;">← disciplinas</a></div> 155 <div class="discipline-name">${esc(disc.discipline)}</div> 156 ${introHtml} 157 ${simuladosHtml} 158 <div class="subject-list">${subjectsHtml || '<p class="empty-note">sem assuntos ainda</p>'}</div> 159 </div>`; 160 161 show('page-home'); 162 document.getElementById('breadcrumb').textContent = disc.discipline; 163 document.title = disc.discipline + ' · ' + BASE_TITLE; 164 } 165 166 /* ── INTERFACE: ASSUNTO ── */ 167 function openSubject(discFolder, slug) { 168 navigate(encodeHash('assunto', discFolder, slug), false); 169 _openSubject(discFolder, slug); 170 } 171 172 function _openSubject(discFolder, slug) { 173 closeFloater(); 174 const disc = DATA.find(d => d.folderKey === discFolder); 175 if (!disc) { _showHome(); return; } 176 const sub = disc.subjects.find(s => s.slug === slug); 177 if (!sub) { _showDisc(disc.discipline); return; } 178 179 lastSubjectRoute = { discFolder, slug, discName: disc.discipline, subjectName: sub.name }; 180 181 document.getElementById('text-discipline').textContent = disc.discipline; 182 document.getElementById('text-title').textContent = sub.name; 183 184 const qzWrap = document.getElementById('text-quizzes'); 185 const qzList = document.getElementById('text-quizzes-list'); 186 187 if (sub.quizzes.length) { 188 qzWrap.style.display = ''; 189 qzList.innerHTML = sub.quizzes.map(qz => { 190 const mc = qz.questions.filter(q => q.type === 'mc').length; 191 const open = qz.questions.filter(q => q.type === 'open').length; 192 const meta = [ 193 mc ? `${mc} mc` : null, 194 open ? `${open} abertas` : null, 195 ].filter(Boolean).join(' · ') || `${qz.questions.length} questões`; 196 return ` 197 <a class="sq-item" href="${encodeHash('quiz', discFolder, slug, qz.id)}" 198 onclick="startQuiz('${esc(discFolder)}','${esc(slug)}','${esc(qz.id)}');return false;"> 199 <span class="sq-name">${esc(qz.title)}</span> 200 <span class="sq-meta">${meta}</span> 201 </a>`; 202 }).join(''); 203 } else { 204 qzWrap.style.display = 'none'; 205 qzList.innerHTML = ''; 206 } 207 208 const prose = document.getElementById('prose'); 209 if (sub.text && sub.text.content) { 210 prose.innerHTML = sub.text.content; 211 currentTextComments = {}; 212 buildToc(prose); 213 } else { 214 prose.innerHTML = '<p class="empty-note">Texto ainda não disponível para este assunto.</p>'; 215 document.getElementById('text-toc').style.display = 'none'; 216 } 217 218 const refContainer = document.getElementById('text-references-container'); 219 const refList = document.getElementById('text-references-list'); 220 const refs = sub.text ? (sub.text.references || []) : []; 221 222 if (refs.length) { 223 refContainer.style.display = 'block'; 224 refList.innerHTML = refs.map(r => `<p>${esc(r)}</p>`).join(''); 225 } else { 226 refContainer.style.display = 'none'; 227 } 228 229 document.getElementById('breadcrumb').textContent = `${disc.discipline} · ${sub.name}`; 230 document.title = sub.name + ' · ' + BASE_TITLE; 231 show('page-text'); 232 window.scrollTo(0, 0); 233 } 234 235 /* ── INTERFACE: SUMÁRIO (TOC) ── */ 236 function buildToc(proseEl) { 237 const tocWrap = document.getElementById('text-toc'); 238 const tocList = document.getElementById('text-toc-list'); 239 tocList.innerHTML = ''; 240 241 const headings = proseEl.querySelectorAll('h2, h3'); 242 if (!headings.length) { tocWrap.style.display = 'none'; return; } 243 244 const used = new Set(); 245 headings.forEach(h => { 246 if (!h.id) { 247 let base = slugifyText(h.textContent) || 'sec'; 248 let id = base, i = 2; 249 while (used.has(id) || document.getElementById(id)) { id = base + '-' + i++; } 250 h.id = id; 251 } 252 used.add(h.id); 253 }); 254 255 headings.forEach(h => { 256 const li = document.createElement('li'); 257 li.className = 'toc-' + h.tagName.toLowerCase(); 258 const a = document.createElement('a'); 259 a.href = '#' + h.id; 260 a.textContent = h.textContent; 261 a.addEventListener('click', e => { 262 e.preventDefault(); 263 h.scrollIntoView({ behavior: 'smooth', block: 'start' }); 264 history.replaceState(history.state, '', '#' + h.id); 265 }); 266 li.appendChild(a); 267 tocList.appendChild(li); 268 }); 269 270 tocWrap.style.display = ''; 271 } 272 273 /* ── INTERFACE: COMPONENTES FLUTUANTES ── */ 274 document.addEventListener('click', e => { 275 const footnoteLink = e.target.closest('a[href^="#fn:"]'); 276 const closeBtn = e.target.closest('.f-close'); 277 278 if (closeBtn) { closeFloater(); return; } 279 280 if (footnoteLink) { 281 e.preventDefault(); e.stopPropagation(); closeFloater(); 282 const targetId = footnoteLink.getAttribute('href').substring(1); 283 const footnoteElement = document.getElementById(targetId); 284 if (footnoteElement) { 285 const contentClone = footnoteElement.cloneNode(true); 286 const backLink = contentClone.querySelector('.footnote-backref'); 287 if (backLink) backLink.remove(); 288 const parentParagraph = footnoteLink.closest('p, li'); 289 const quoteText = parentParagraph ? parentParagraph.innerText.substring(0, 40) + '…' : 'Nota'; 290 openFloater(footnoteLink, quoteText, contentClone.innerHTML); 291 footnoteLink.classList.add('active'); 292 } 293 } else if (activeFloater && !e.target.closest('.floater')) { 294 closeFloater(); 295 } 296 }); 297 298 function openFloater(target, quote, innerHTMLContent) { 299 const f = document.createElement('div'); 300 f.className = 'floater'; 301 f.innerHTML = ` 302 <div class="f-label"><span>comentário</span><span class="f-close">×</span></div> 303 <div class="f-quote">${quote}</div> 304 <div>${innerHTMLContent}</div>`; 305 document.body.appendChild(f); 306 307 const r = target.getBoundingClientRect(); 308 const top = window.scrollY + r.bottom + 8; 309 const padding = 20; 310 let left = window.scrollX + r.left; 311 const maxLeft = window.innerWidth - f.offsetWidth - padding; 312 313 if (left > maxLeft) left = Math.max(padding, maxLeft); 314 315 f.style.top = top + 'px'; 316 f.style.left = left + 'px'; 317 activeFloater = f; 318 } 319 320 function closeFloater() { 321 if (activeFloater) { activeFloater.remove(); activeFloater = null; } 322 document.querySelectorAll('a[href^="#fn:"]').forEach(a => a.classList.remove('active')); 323 } 324 325 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeFloater(); }); 326 327 /* ── MOTOR: QUIZ E SIMULADO ── */ 328 function startQuiz(discFolder, slug, quizId) { 329 navigate(encodeHash('quiz', discFolder, slug, quizId), false); 330 _startQuiz(discFolder, slug, quizId); 331 } 332 333 function _startQuiz(discFolder, slug, quizId) { 334 closeFloater(); 335 const disc = DATA.find(d => d.folderKey === discFolder); 336 if (!disc) { _showHome(); return; } 337 const sub = disc.subjects.find(s => s.slug === slug); 338 if (!sub) { _showDisc(disc.discipline); return; } 339 const quiz = sub.quizzes.find(q => q.id === quizId); 340 if (!quiz) { _openSubject(discFolder, slug); return; } 341 342 lastSubjectRoute = { discFolder, slug, discName: disc.discipline, subjectName: sub.name }; 343 currentQuiz = { 344 isSimulado: false, discFolder, slug, quizId, 345 subjectName: sub.name, quizTitle: quiz.title 346 }; 347 348 _qState.questions = prepareQuizQuestions(quiz.questions); 349 _qState.qIndex = _qState.acertos = _qState.erros = 0; 350 _clearProgress('spa_' + discFolder + '_' + slug + '_' + quizId); 351 352 document.getElementById('breadcrumb').textContent = `${disc.discipline} · ${sub.name} · ${quiz.title}`; 353 document.title = `${quiz.title} · ${sub.name} · ${BASE_TITLE}`; 354 355 show('page-quiz'); 356 renderQuestion(); 357 } 358 359 function startSimulado(discFolder, quizId) { 360 navigate(encodeHash('simulado', discFolder, quizId), false); 361 _startSimulado(discFolder, quizId); 362 } 363 364 function _startSimulado(discFolder, quizId) { 365 closeFloater(); 366 const disc = DATA.find(d => d.folderKey === discFolder); 367 if (!disc) { _showHome(); return; } 368 const quiz = disc.quizzes.find(q => q.id === quizId); 369 if (!quiz) { _showDisc(disc.discipline); return; } 370 371 lastSubjectRoute = null; 372 currentQuiz = { 373 isSimulado: true, discFolder, quizId, 374 subjectName: disc.discipline, quizTitle: quiz.title 375 }; 376 377 _qState.questions = prepareQuizQuestions(quiz.questions); 378 _qState.qIndex = _qState.acertos = _qState.erros = 0; 379 _clearProgress('spa_sim_' + discFolder + '_' + quizId); 380 381 document.getElementById('breadcrumb').textContent = `${disc.discipline} · ${quiz.title}`; 382 document.title = `${quiz.title} · ${disc.discipline} · ${BASE_TITLE}`; 383 384 show('page-quiz'); 385 renderQuestion(); 386 } 387 388 function restartQuiz() { 389 if (currentQuiz.isSimulado) { 390 _clearProgress('spa_sim_' + currentQuiz.discFolder + '_' + currentQuiz.quizId); 391 _startSimulado(currentQuiz.discFolder, currentQuiz.quizId); 392 } else { 393 _clearProgress('spa_' + currentQuiz.discFolder + '_' + currentQuiz.slug + '_' + currentQuiz.quizId); 394 _startQuiz(currentQuiz.discFolder, currentQuiz.slug, currentQuiz.quizId); 395 } 396 } 397 398 function backToSubject() { 399 if (currentQuiz && currentQuiz.isSimulado) { 400 openDisc(currentQuiz.subjectName); 401 } else if (lastSubjectRoute) { 402 navigate(encodeHash('assunto', lastSubjectRoute.discFolder, lastSubjectRoute.slug), false); 403 _openSubject(lastSubjectRoute.discFolder, lastSubjectRoute.slug); 404 } else { 405 showHome(); 406 } 407 } 408 409 function renderQuestion() { 410 _renderQuestion(_qState.questions[_qState.qIndex]); 411 const progKey = currentQuiz 412 ? 'spa_' + (currentQuiz.isSimulado 413 ? 'sim_' + currentQuiz.discFolder + '_' + currentQuiz.quizId 414 : currentQuiz.discFolder + '_' + currentQuiz.slug + '_' + currentQuiz.quizId) 415 : null; 416 if (progKey) _saveProgress(progKey); 417 } 418 419 /* selectOpt, showRef, selfJudge, setFeedback → definidos em quiz-parser.html */ 420 421 function nextQuestion() { 422 _qState.qIndex++; 423 if (_qState.qIndex >= _qState.questions.length) { showResult(); return; } 424 const body = document.getElementById('quiz-body'); 425 body.classList.add('fade-out'); 426 setTimeout(() => { body.classList.remove('fade-out'); renderQuestion(); }, 100); 427 } 428 429 /* ── TELA DE RESULTADOS ── */ 430 function showResult() { 431 show('page-result'); 432 _buildResult(); 433 document.getElementById('breadcrumb').textContent = currentQuiz.subjectName + ' · ' + currentQuiz.quizTitle; 434 } 435 436 /* ── INICIALIZAÇÃO DE ROTA ── */ 437 (function init() { 438 const route = decodeHash(window.location.hash); 439 if (route.type !== 'home') { 440 history.replaceState({ hash: window.location.hash }, '', window.location.href); 441 renderRoute(route); 442 } else { 443 history.replaceState({ hash: '' }, '', window.location.pathname + window.location.search); 444 _showHome(); 445 } 446 })(); 447 </script>