direito

Repositório direito
git clone git://git.sivaldodavi.com/direito.git
Log | Files | Refs

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>