From 7316238f16655bd127b04476bbf7864b27f940f9 Mon Sep 17 00:00:00 2001 From: Sivaldo Date: Sat, 13 Jun 2026 19:20:23 -0300 Subject: Commit inicial --- static/CNAME | 1 + static/dark-mode.js | 59 ++ static/dashboard.js | 130 ++++ static/footnotes.js | 77 +++ static/home-filter.js | 72 ++ static/keyboard-shortcuts.js | 73 ++ static/main.css | 1560 ++++++++++++++++++++++++++++++++++++++++++ static/manifest.json | 34 + static/offline.html | 22 + static/quiz-engine.js | 170 +++++ static/quiz-parser.js | 144 ++++ static/quiz-runtime.js | 92 +++ static/sw.js | 68 ++ static/toc.js | 52 ++ 14 files changed, 2554 insertions(+) create mode 100644 static/CNAME create mode 100644 static/dark-mode.js create mode 100644 static/dashboard.js create mode 100644 static/footnotes.js create mode 100644 static/home-filter.js create mode 100644 static/keyboard-shortcuts.js create mode 100644 static/main.css create mode 100644 static/manifest.json create mode 100644 static/offline.html create mode 100644 static/quiz-engine.js create mode 100644 static/quiz-parser.js create mode 100644 static/quiz-runtime.js create mode 100644 static/sw.js create mode 100644 static/toc.js (limited to 'static') diff --git a/static/CNAME b/static/CNAME new file mode 100644 index 0000000..488dfd6 --- /dev/null +++ b/static/CNAME @@ -0,0 +1 @@ +dir.sivaldodavi.com diff --git a/static/dark-mode.js b/static/dark-mode.js new file mode 100644 index 0000000..8e2d761 --- /dev/null +++ b/static/dark-mode.js @@ -0,0 +1,59 @@ +/** + * Dark Mode Toggle + * Respeita a preferência do sistema e permite alternar manualmente + * Salva a preferência no localStorage + */ + +const DARK_MODE_KEY = 'caderno_dark_mode'; + +function initDarkMode() { + // Verificar preferência salva + const saved = localStorage.getItem(DARK_MODE_KEY); + let isDark; + + if (saved !== null) { + isDark = saved === 'true'; + } else { + // Respeitar preferência do sistema + isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + applyDarkMode(isDark); +} + +function applyDarkMode(isDark) { + const html = document.documentElement; + const toggle = document.getElementById('dark-mode-toggle'); + + if (isDark) { + html.setAttribute('data-theme', 'dark'); + if (toggle) toggle.textContent = '☀️'; + } else { + html.removeAttribute('data-theme'); + if (toggle) toggle.textContent = '🌙'; + } + + localStorage.setItem(DARK_MODE_KEY, isDark); +} + +function toggleDarkMode() { + const html = document.documentElement; + const isDark = html.getAttribute('data-theme') === 'dark'; + applyDarkMode(!isDark); +} + +// Inicializar quando o DOM estiver pronto +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDarkMode); +} else { + initDarkMode(); +} + +// Detectar mudanças na preferência do sistema +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + const saved = localStorage.getItem(DARK_MODE_KEY); + if (saved === null) { + // Se o usuário não escolheu manualmente, seguir o sistema + applyDarkMode(e.matches); + } +}); diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..c14369f --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,130 @@ +/** + * Dashboard de Progresso + * Lê dados do localStorage e mostra estatísticas de estudo + */ + +var PROGRESS_PREFIX = window.PROGRESS_PREFIX || 'quiz_progress_'; + +function initDashboard() { + const dashboard = document.getElementById('dashboard-stats'); + if (!dashboard) return; + + const stats = calculateStats(); + renderDashboard(stats); +} + +function calculateStats() { + const allKeys = Object.keys(localStorage); + const progressKeys = allKeys.filter((k) => k.startsWith(PROGRESS_PREFIX)); + + const stats = { + totalQuizzes: progressKeys.length, + totalAcertos: 0, + totalErros: 0, + disciplinas: {}, + }; + + progressKeys.forEach((key) => { + try { + const data = JSON.parse(localStorage.getItem(key)); + if (!data || !data.questions) return; + + const disciplina = key.replace(PROGRESS_PREFIX, '').split('_')[0]; + if (!stats.disciplinas[disciplina]) { + stats.disciplinas[disciplina] = { + acertos: 0, + erros: 0, + total: 0, + }; + } + + stats.disciplinas[disciplina].acertos += data.acertos || 0; + stats.disciplinas[disciplina].erros += data.erros || 0; + stats.disciplinas[disciplina].total += data.questions.length; + + stats.totalAcertos += data.acertos || 0; + stats.totalErros += data.erros || 0; + } catch (e) {} + }); + + return stats; +} + +function renderDashboard(stats) { + const dashboard = document.getElementById('dashboard-stats'); + if (!dashboard) return; + + const totalQuestoes = stats.totalAcertos + stats.totalErros; + const pctGeral = totalQuestoes > 0 ? Math.round((stats.totalAcertos / totalQuestoes) * 100) : 0; + + let html = ` +
+

📊 Seu Progresso de Estudo

+ +
+
+
Quizzes Iniciados
+
${stats.totalQuizzes}
+
+
+
Taxa de Acerto Geral
+
${pctGeral}%
+
+
+
Questões Respondidas
+
${totalQuestoes}
+
+
+ +
+

Por Disciplina

+ `; + + Object.entries(stats.disciplinas).forEach(([disc, data]) => { + const pct = data.total > 0 ? Math.round((data.acertos / data.total) * 100) : 0; + const nivel = pct >= 80 ? '🟢 Dominado' : pct >= 60 ? '🟡 Bom' : '🔴 Revisar'; + + html += ` +
+
${disc}
+
+
+
+
+ ${data.acertos}/${data.total} + ${pct}% + ${nivel} +
+
+ `; + }); + + html += ` +
+
+ +
+
+ `; + + dashboard.innerHTML = html; +} + +function clearAllProgress() { + if (confirm('Tem certeza que quer limpar todo o histórico de progresso?')) { + const allKeys = Object.keys(localStorage); + allKeys.forEach((key) => { + if (key.startsWith(PROGRESS_PREFIX)) { + localStorage.removeItem(key); + } + }); + initDashboard(); + } +} + +// Inicializar quando o DOM estiver pronto +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initDashboard); +} else { + initDashboard(); +} diff --git a/static/footnotes.js b/static/footnotes.js new file mode 100644 index 0000000..1d2700a --- /dev/null +++ b/static/footnotes.js @@ -0,0 +1,77 @@ +/** + * Footnotes - Sistema de notas flutuantes (floater) + */ + +let activeFloater = null; + +document.addEventListener('click', (e) => { + const fn = e.target.closest('a[href^="#fn:"]'); + const close = e.target.closest('.f-close'); + + if (close) { + closeFloater(); + return; + } + + if (fn) { + e.preventDefault(); + e.stopPropagation(); + closeFloater(); + + const el = document.getElementById(fn.getAttribute('href').slice(1)); + if (!el) return; + + const clone = el.cloneNode(true); + clone.querySelector('.footnote-backref')?.remove(); + + const parent = fn.closest('p, li'); + const quote = parent ? parent.innerText.slice(0, 40) + '…' : 'Nota'; + + openFloater(fn, quote, clone.innerHTML); + fn.classList.add('active'); + } else if (activeFloater && !e.target.closest('.floater')) { + closeFloater(); + } +}); + +function openFloater(target, quote, html) { + const f = document.createElement('div'); + f.className = 'floater'; + f.innerHTML = `
comentário×
${quote}
${html}
`; + + document.body.appendChild(f); + + const r = target.getBoundingClientRect(); + const top = window.scrollY + r.bottom + 8; + const padding = 20; + let left = window.scrollX + r.left; + const maxLeft = window.innerWidth - f.offsetWidth - padding; + + if (left > maxLeft) left = Math.max(padding, maxLeft); + + f.style.top = top + 'px'; + f.style.left = left + 'px'; + + activeFloater = f; + + // Permitir fechar com Enter ou Space + f.querySelector('.f-close').addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + closeFloater(); + } + }); +} + +function closeFloater() { + if (activeFloater) { + activeFloater.remove(); + activeFloater = null; + } + document.querySelectorAll('a[href^="#fn:"]').forEach((a) => a.classList.remove('active')); +} + +// Fechar com Escape +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeFloater(); +}); diff --git a/static/home-filter.js b/static/home-filter.js new file mode 100644 index 0000000..b2d1a47 --- /dev/null +++ b/static/home-filter.js @@ -0,0 +1,72 @@ +/** + * Home Filter — filtra disciplinas conforme você digita + * Só funciona quando há itens [data-filterable] na página + */ +document.addEventListener('DOMContentLoaded', () => { + const items = document.querySelectorAll('[data-filterable]'); + if (!items.length) return; + + // Cria campo de busca + const wrap = document.createElement('div'); + wrap.className = 'filter-container'; + wrap.innerHTML = ` + + + `; + + // Insere antes do disc-grid dentro do
+ const grid = document.querySelector('.disc-grid'); + if (grid) { + grid.parentNode.insertBefore(wrap, grid); + } else { + const main = document.querySelector('main'); + if (main) main.insertBefore(wrap, main.firstChild); + } + + const input = document.getElementById('home-filter'); + const count = document.getElementById('filter-count'); + + // Normaliza texto para comparação (remove acentos, maiúsculas) + function normalize(str) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + } + + input.addEventListener('input', () => { + const q = normalize(input.value.trim()); + let visible = 0; + + items.forEach(item => { + const text = normalize(item.textContent); + const match = !q || text.includes(q); + item.style.display = match ? '' : 'none'; + if (match) visible++; + }); + + count.textContent = q ? `${visible} resultado${visible !== 1 ? 's' : ''}` : ''; + }); + + // Esc limpa e desfoca + input.addEventListener('keydown', e => { + if (e.key === 'Escape') { + input.value = ''; + input.dispatchEvent(new Event('input')); + input.blur(); + } + }); + + // Foco automático na home com / ou Ctrl+K + document.addEventListener('keydown', e => { + if ((e.key === '/' || (e.ctrlKey && e.key === 'k')) && document.activeElement !== input) { + e.preventDefault(); + input.focus(); + } + }); +}); diff --git a/static/keyboard-shortcuts.js b/static/keyboard-shortcuts.js new file mode 100644 index 0000000..53cfe44 --- /dev/null +++ b/static/keyboard-shortcuts.js @@ -0,0 +1,73 @@ +/** + * Keyboard Shortcuts + * Atalhos para acelerar a navegação e resposta de quizzes + * + * Atalhos: + * 1-5: Selecionar opção A-E + * Espaço: Próxima questão + * Esc: Fechar notas/floaters + * ?: Mostrar ajuda + */ + +const SHORTCUTS = { + '1': () => selectOptionByLetter(0), + '2': () => selectOptionByLetter(1), + '3': () => selectOptionByLetter(2), + '4': () => selectOptionByLetter(3), + '5': () => selectOptionByLetter(4), + ' ': (e) => { + e.preventDefault(); + const nextBtn = document.getElementById('btn-next'); + if (nextBtn && nextBtn.style.display !== 'none') { + nextBtn.click(); + } + }, + 'Escape': () => { + // Fechar floaters se existir + if (typeof closeFloater === 'function') { + closeFloater(); + } + }, + '?': (e) => { + e.preventDefault(); + showKeyboardHelp(); + }, +}; + +function selectOptionByLetter(index) { + const buttons = document.querySelectorAll('.opt'); + if (buttons[index]) { + buttons[index].click(); + } +} + +function showKeyboardHelp() { + const help = ` +⌨️ Atalhos de Teclado + +Quizzes: + 1-5 Selecionar opção A-E + SPC Próxima questão + ESC Fechar notas + +Geral: + ESC Fechar floaters + ? Mostrar esta ajuda + `; + + alert(help); +} + +document.addEventListener('keydown', (e) => { + // Ignorar se está digitando em um input/textarea + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + + const key = e.key; + const handler = SHORTCUTS[key]; + + if (handler) { + handler(e); + } +}); diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..d4257a4 --- /dev/null +++ b/static/main.css @@ -0,0 +1,1560 @@ +/* ── VARIÁVEIS E RESET ── */ +:root { + --serif: 'Lora', serif; + --mono: 'JetBrains Mono', monospace; + --text: #dedad4; + --text-2: #a8a8a8; + --muted: #6a6a75; + --surface: #1a1a1f; + --border: #2a2a30; + --border-2: #3a3a40; + --amber: #c9a961; + --t: 150ms ease; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + background: #0f0f13; + color: var(--text); + font-family: var(--serif); + font-size: 16px; + line-height: 1.6; +} + +body { + background: #0f0f13; + color: var(--text); + font-family: var(--serif); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* ── HEADER ── */ +header { + padding: 1.2rem 2rem; + border-bottom: 1px solid var(--border); + background: rgba(15, 15, 19, 0.95); + backdrop-filter: blur(4px); + position: sticky; + top: 0; + z-index: 100; +} + +.site-name { + font-family: var(--mono); + font-size: 0.75rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text); + text-decoration: none; + transition: color var(--t); +} + +.site-name:hover { + color: var(--amber); +} + +.header-sep { + color: var(--border-2); + margin: 0 0.5rem; +} + +.breadcrumb { + color: var(--text-2); + font-size: 0.9rem; +} + +.breadcrumb-link { + color: var(--text-2); + text-decoration: none; + transition: color var(--t); +} + +.breadcrumb-link:hover { + color: var(--text); +} + +/* ── MAIN ── */ +main { + flex: 1; + padding: 2rem; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* ── FADE ANIMATION ── */ +.fade { + animation: fadeIn 200ms ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.fade-out { + animation: fadeOut 100ms ease; +} + +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +/* ── GRID DE DISCIPLINAS ── */ +.disc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.2rem; + margin-bottom: 2rem; +} + +.disc-card { + display: flex; + flex-direction: column; + padding: 1.2rem; + border: 1px dashed var(--border); + border-radius: 4px; + text-decoration: none; + color: var(--text); + transition: all var(--t); + cursor: pointer; +} + +.disc-card:hover { + border-color: var(--amber); + background: color-mix(in srgb, var(--amber) 3%, transparent); + transform: translateY(-2px); +} + +.disc-card-name { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.disc-card-meta { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* ── BOTÃO VOLTAR ── */ +.disc-back { + margin-bottom: 1.2rem; +} + +.disc-back a { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--muted); + text-decoration: none; + letter-spacing: 0.06em; + text-transform: uppercase; + transition: color var(--t); +} + +.disc-back a:hover { + color: var(--text-2); +} + +/* ── DISCIPLINA ── */ +.discipline { + margin-bottom: 2.5rem; +} + +.discipline-name { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.65rem; + padding-bottom: 0.45rem; + border-bottom: 1px solid var(--border); +} + +/* ── INTRO/NOTAS DA DISCIPLINA ── */ +.disc-intro { + margin: 1.2rem 0 1.8rem; + padding: 1rem 1.2rem; + border-left: 2px solid var(--amber); + background: color-mix(in srgb, var(--amber) 5%, transparent); + border-radius: 0 4px 4px 0; +} + +.disc-intro-prose { + font-size: 0.9rem; + color: var(--text-2); + margin: 0; +} + +.disc-intro-prose p:first-child { + margin-top: 0; +} + +.disc-intro-prose p:last-child { + margin-bottom: 0; +} + +.disc-intro-refs { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} + +/* ── TÓPICOS (SUBPASTAS) ── */ +.topic-section { + margin-bottom: 0.5rem; +} + +.topic-head { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.12em; + text-transform: uppercase; + margin: 1.4rem 0 0.3rem; + padding-bottom: 0.3rem; + border-bottom: 1px dashed var(--border); +} + +.subject-group { + padding: 0.85rem 0; + border-bottom: 1px solid var(--border); +} + +.subject-group:last-child { + border-bottom: none; +} + +.subject-head { + font-size: 0.95rem; + color: var(--text); + margin-bottom: 0.5rem; +} + +.item-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.35rem 0; + cursor: pointer; + text-decoration: none; + color: var(--text-2); + transition: opacity var(--t); + gap: 1rem; +} + +.item-row:hover { + opacity: 0.55; + color: var(--text); +} + +.item-kind { + font-family: var(--mono); + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + min-width: 3.2rem; + flex-shrink: 0; +} + +.item-kind.txt { + color: var(--amber); +} + +.item-kind.qz { + color: var(--text-2); +} + +.item-title { + flex: 1; + font-size: 0.9rem; +} + +.item-meta { + font-family: var(--mono); + font-size: 0.66rem; + color: var(--muted); + white-space: nowrap; + flex-shrink: 0; +} + +/* ── LEITOR DE TEXTOS & PROSE ── */ +.text-meta { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--muted); + margin-bottom: 1.4rem; + letter-spacing: 0.02em; + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.text-meta .dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--border-2); +} + +.text-title { + font-size: 1.4rem; + line-height: 1.35; + margin-bottom: 1.6rem; + color: var(--text); + font-weight: 600; +} + +.prose { + font-size: 1rem; + line-height: 1.8; + color: var(--text); +} + +.prose p { + margin-bottom: 1.1rem; +} + +.prose h2 { + font-family: var(--serif); + font-size: 1.1rem; + font-weight: 600; + margin: 1.8rem 0 0.8rem; + color: var(--text); +} + +.prose h3 { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin: 1.6rem 0 0.6rem; +} + +.prose blockquote { + border-left: 2px solid var(--border-2); + padding: 0.2rem 0 0.2rem 1rem; + margin: 1rem 0; + color: var(--text-2); + font-style: italic; +} + +.prose em { + color: var(--text-2); +} + +.prose strong { + color: var(--text); + font-weight: 600; +} + +.prose code { + font-family: var(--mono); + font-size: 0.85em; + background: var(--surface); + padding: 0.05em 0.35em; + border-radius: 2px; + color: var(--text-2); +} + +/* ── TABELAS ── */ +.prose table { + width: 100%; + border-collapse: collapse; + margin: 1.8rem 0; + font-size: 0.9rem; + color: var(--text-2); +} + +.prose th { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); + border-bottom: 1px solid var(--border-2); + padding: 0.5rem 0.6rem; + text-align: left; +} + +.prose td { + padding: 0.6rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.prose tr:hover td { + color: var(--text); +} + +@media (max-width: 480px) { + .prose table { + overflow-x: auto; + display: block; + } +} + +/* ── NOTAS DE RODAPÉ ── */ +a[href^="#fn:"] { + text-decoration: none; + color: var(--amber); + cursor: pointer; + transition: opacity var(--t); +} + +a[href^="#fn:"]:hover { + opacity: 0.7; +} + +a[href^="#fn:"].active { + font-weight: 600; +} + +.footnotes { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.9rem; +} + +.footnote-backref { + text-decoration: none; + color: var(--amber); +} + +/* ── FLOATER (POPUP DE NOTAS) ── */ +.floater { + position: fixed; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1rem; + max-width: 320px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 1000; + animation: slideUp 150ms ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.f-label { + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.f-close { + cursor: pointer; + font-size: 1.2rem; + color: var(--text-2); + transition: color var(--t); +} + +.f-close:hover { + color: var(--text); +} + +.f-quote { + font-size: 0.85rem; + color: var(--text-2); + font-style: italic; + margin-bottom: 0.5rem; +} + +/* ── SUMÁRIO (TOC) ── */ +.toc { + margin: 1.5rem 0 2rem; + padding: 1rem; + background: color-mix(in srgb, var(--amber) 3%, transparent); + border: 1px solid var(--border); + border-radius: 4px; +} + +.toc-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.75rem; +} + +#toc-list { + list-style: none; + margin: 0; + padding: 0; +} + +.toc-h2 { + margin-bottom: 0.5rem; +} + +.toc-h2 a { + font-size: 0.95rem; + color: var(--text); + text-decoration: none; + transition: color var(--t); +} + +.toc-h2 a:hover { + color: var(--amber); +} + +.toc-h3 { + margin-left: 1.2rem; + margin-bottom: 0.3rem; +} + +.toc-h3 a { + font-size: 0.85rem; + color: var(--text-2); + text-decoration: none; + transition: color var(--t); +} + +.toc-h3 a:hover { + color: var(--text); +} + +/* ── REFERÊNCIAS ── */ +.references { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.ref-head { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 1rem; +} + +.ref-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ref-list p { + font-size: 0.9rem; + color: var(--text-2); + margin: 0; + line-height: 1.5; +} + +/* ── QUIZ: LAYOUT GERAL ── */ +.progress-wrap { + width: 100%; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + margin-bottom: 1.5rem; +} + +.progress-fill { + height: 100%; + background: var(--amber); + width: 0%; + transition: width 300ms ease; +} + +.q-meta { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 1.2rem; +} + +#quiz-body { + margin-bottom: 1.5rem; +} + +/* ── QUIZ: MÚLTIPLA ESCOLHA ── */ +.q-text { + font-size: 1rem; + line-height: 1.8; + color: var(--text); + margin-bottom: 1.5rem; +} + +.options { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.opt { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + transition: all var(--t); + font-family: var(--serif); + font-size: 0.95rem; + color: var(--text); + text-align: left; + min-height: 3rem; +} + +.opt:hover:not(:disabled) { + border-color: var(--amber); + background: color-mix(in srgb, var(--amber) 5%, transparent); +} + +.opt:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.opt.correct { + border-color: #4ade80; + background: color-mix(in srgb, #4ade80 15%, transparent); +} + +.opt.wrong { + border-color: #ef4444; + background: color-mix(in srgb, #ef4444 15%, transparent); +} + +.opt.revealed { + opacity: 0.5; +} + +.opt-letter { + font-family: var(--mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + min-width: 1.5rem; + flex-shrink: 0; +} + +/* ── QUIZ: RESPOSTA ABERTA ── */ +.open-area { + display: flex; + flex-direction: column; + gap: 1rem; +} + +textarea { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-family: var(--serif); + font-size: 0.95rem; + line-height: 1.6; + resize: vertical; + min-height: 120px; + transition: border-color var(--t); +} + +textarea:focus { + outline: none; + border-color: var(--amber); +} + +textarea::placeholder { + color: var(--muted); +} + +/* ── QUIZ: FEEDBACK ── */ +.feedback-line { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--mono); + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-2); + opacity: 0; + transition: opacity var(--t); +} + +.feedback-line.show { + opacity: 1; +} + +.feedback-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-2); +} + +.feedback-line.ok .feedback-dot { + background: #4ade80; +} + +.feedback-line.bad .feedback-dot { + background: #ef4444; +} + +/* ── QUIZ: COMENTÁRIO ── */ +.comment-block { + padding: 1rem; + background: color-mix(in srgb, var(--amber) 5%, transparent); + border-left: 2px solid var(--amber); + border-radius: 0 4px 4px 0; + max-height: 0; + overflow: hidden; + transition: max-height var(--t); +} + +.comment-block.show { + max-height: 500px; +} + +.comment-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.comment-block > div:last-child { + font-size: 0.9rem; + color: var(--text-2); + line-height: 1.6; +} + +/* ── QUIZ: REFERÊNCIA (RESPOSTA ABERTA) ── */ +.ref-block { + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + max-height: 0; + overflow: hidden; + transition: max-height var(--t)); +} + +.ref-block.show { + max-height: 500px; +} + +.ref-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.ref-block > div:last-child { + font-size: 0.9rem; + color: var(--text-2); + line-height: 1.6; +} + +/* ── QUIZ: BOTÕES ── */ +.next-row { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; +} + +.open-btns { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.btn { + padding: 0.6rem 1.2rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-family: var(--serif); + font-size: 0.9rem; + cursor: pointer; + transition: all var(--t); + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:hover:not(:disabled) { + border-color: var(--amber); + background: color-mix(in srgb, var(--amber) 10%, transparent); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-ok { + border-color: #4ade80; + color: #4ade80; +} + +.btn-ok:hover:not(:disabled) { + background: color-mix(in srgb, #4ade80 15%, transparent); +} + +.btn-bad { + border-color: #ef4444; + color: #ef4444; +} + +.btn-bad:hover:not(:disabled) { + background: color-mix(in srgb, #ef4444 15%, transparent); +} + +/* ── QUIZ: RESULTADO ── */ +.res-score { + font-size: 3rem; + font-weight: 600; + color: var(--amber); + text-align: center; + margin-bottom: 0.5rem; +} + +.res-subtitle { + font-size: 1rem; + color: var(--text-2); + text-align: center; + margin-bottom: 1.5rem; +} + +.res-divider { + height: 1px; + background: var(--border); + margin: 1.5rem 0; +} + +.res-stats { + display: flex; + justify-content: center; + gap: 3rem; + margin-bottom: 1.5rem; +} + +.rs-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.rs-num { + font-size: 1.8rem; + font-weight: 600; +} + +.rs-num.g { + color: #4ade80; +} + +.rs-num.r { + color: #ef4444; +} + +.rs-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.res-msg { + padding: 1rem; + background: color-mix(in srgb, var(--amber) 5%, transparent); + border-left: 2px solid var(--amber); + border-radius: 0 4px 4px 0; + margin-bottom: 1.5rem; +} + +.res-msg-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.res-msg-body { + font-size: 0.95rem; + color: var(--text-2); + line-height: 1.6; +} + +.res-btns { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} + +/* ── EXTRAS DE DISCIPLINA (COLAPSÁVEIS) ── */ +.disc-extras-wrap { + margin: 1.5rem 0 2rem; +} + +.disc-extra { + margin-bottom: 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.disc-extra-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--surface); + cursor: pointer; + user-select: none; + transition: background var(--t); +} + +.disc-extra-summary:hover { + background: color-mix(in srgb, var(--amber) 5%, transparent); +} + +.disc-extra-label { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.disc-extra-count { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--text-2); + letter-spacing: 0.05em; +} + +.disc-extra-chevron { + color: var(--text-2); + transition: transform var(--t); +} + +.disc-extra[open] .disc-extra-chevron { + transform: rotate(180deg); +} + +.disc-extra-body { + padding: 1rem; + border-top: 1px solid var(--border); +} + +.disc-extra-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + text-decoration: none; + color: var(--text-2); + transition: color var(--t); +} + +.disc-extra-row:hover { + color: var(--text); +} + +.disc-extra-icon { + font-size: 0.8rem; + color: var(--muted); + min-width: 1.2rem; +} + +.disc-extra-name { + flex: 1; + font-size: 0.9rem; +} + +.disc-extra-meta { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.disc-extra-prose { + font-size: 0.9rem; + color: var(--text-2); +} + +.disc-extra-prose p { + margin-bottom: 0.75rem; +} + +.disc-extra-prose p:last-child { + margin-bottom: 0; +} + +.disc-extra-kind-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 1rem 0 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px dashed var(--border); +} + +.disc-extra-kind-label:first-child { + margin-top: 0; +} + +/* ── SUBJECT QUIZZES (LINKS PARA EXERCÍCIOS) ── */ +.subject-quizzes { + margin: 1.5rem 0 2rem; + padding: 1rem; + background: color-mix(in srgb, var(--amber) 3%, transparent); + border: 1px solid var(--border); + border-radius: 4px; +} + +.sq-label { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.75rem; +} + +.sq-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sq-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + text-decoration: none; + color: var(--text); + transition: all var(--t); +} + +.sq-item:hover { + border-color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, transparent); +} + +.sq-name { + font-size: 0.9rem; + flex: 1; +} + +.sq-meta { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.05em; + text-transform: uppercase; + flex-shrink: 0; +} + +/* ── SUBJECT LIST ── */ +.subject-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.subject-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); + text-decoration: none; + color: var(--text); + transition: all var(--t); +} + +.subject-row:last-child { + border-bottom: none; +} + +.subject-row:hover { + color: var(--amber); + padding-left: 0.5rem; +} + +.subject-row-name { + font-size: 0.95rem; + flex: 1; +} + +.subject-row-meta { + font-family: var(--mono); + font-size: 0.62rem; + color: var(--muted); + letter-spacing: 0.1em; + text-transform: uppercase; + flex-shrink: 0; +} + +.empty-note { + font-size: 0.9rem; + color: var(--muted); + font-style: italic; + padding: 1rem 0; +} + +/* ── RESPONSIVIDADE ── */ +@media (max-width: 768px) { + main { + padding: 1.5rem; + } + + .disc-grid { + grid-template-columns: 1fr; + } + + .res-stats { + gap: 2rem; + } + + header { + padding: 1rem 1.5rem; + } +} + +@media (max-width: 480px) { + main { + padding: 1rem; + } + + .text-title { + font-size: 1.2rem; + } + + .prose { + font-size: 0.95rem; + } + + .options { + gap: 0.5rem; + } + + .opt { + padding: 0.6rem 0.75rem; + min-height: 2.5rem; + } + + .floater { + max-width: 280px; + } + + .res-score { + font-size: 2.5rem; + } + + .res-stats { + gap: 1.5rem; + } +} + +/* ── FLASHCARD ── */ +.flashcard-area { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.flashcard { + perspective: 1000px; + cursor: pointer; + height: 300px; + position: relative; +} + +.flashcard-front, +.flashcard-back { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--amber); + border-radius: 4px; + padding: 2rem; + text-align: center; + transition: transform 0.6s; +} + +.flashcard-front { + background: var(--surface); + color: var(--text); + transform: rotateY(0deg); +} + +.flashcard-back { + background: var(--amber); + color: #0f0f13; + transform: rotateY(180deg); +} + +.flashcard.flipped .flashcard-front { + transform: rotateY(180deg); +} + +.flashcard.flipped .flashcard-back { + transform: rotateY(0deg); +} + +.flashcard-hint { + font-size: 1.2rem; + opacity: 0.7; +} + +.flashcard-btns { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +/* ── FILTRO NA HOME ── */ +.filter-container { + margin-bottom: 2rem; + display: flex; + gap: 1rem; + align-items: center; +} + +.filter-input { + flex: 1; + padding: 0.75rem 1rem; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; + font-family: var(--serif); +} + +.filter-input:focus { + outline: none; + border-color: var(--amber); +} + +.filter-count { + color: var(--amber); + font-size: 0.9rem; + white-space: nowrap; +} + +/* ── DASHBOARD ── */ +.dashboard-container { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2rem; + margin-bottom: 2rem; +} + +.dashboard-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.stat-card { + background: #0f0f13; + border: 1px solid var(--border); + border-radius: 4px; + padding: 1.5rem; + text-align: center; +} + +.stat-label { + font-size: 0.9rem; + color: var(--muted); + margin-bottom: 0.5rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 600; + color: var(--amber); +} + +.dashboard-disciplines { + margin-top: 2rem; +} + +.disc-stat { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.disc-name { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.disc-bar { + height: 8px; + background: #0f0f13; + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.disc-progress { + height: 100%; + background: #4ade80; + transition: width 0.3s; +} + +.disc-info { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: var(--muted); +} + +.dashboard-actions { + margin-top: 2rem; + text-align: center; +} + +/* ── MODO ESCURO TOGGLE ── */ +#dark-mode-toggle { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + margin-left: auto; + padding: 0.5rem; + border-radius: 4px; + transition: background-color var(--t); +} + +#dark-mode-toggle:hover { + background-color: var(--border); +} + +/* ── PRINT-FRIENDLY ── */ +@media print { + header, + .filter-container, + .dashboard-container, + .btn, + .next-row, + #dark-mode-toggle, + .flashcard-btns, + .open-btns { + display: none; + } + + body { + background: white; + color: black; + padding: 0; + } + + main { + max-width: 100%; + padding: 0; + } + + .q-text, + .flashcard-back { + page-break-inside: avoid; + } + + .flashcard { + border: 1px solid black; + background: white; + color: black; + } + + .flashcard-front, + .flashcard-back { + position: static; + transform: none; + border: none; + background: white; + color: black; + } +} + +/* ── RESPONSIVIDADE MOBILE ── */ +@media (max-width: 768px) { + .dashboard-overview { + grid-template-columns: 1fr; + } + + .flashcard { + height: 250px; + } +} + +@media (max-width: 480px) { + .flashcard { + height: 200px; + } + + .filter-container { + flex-direction: column; + } + + .filter-input { + width: 100%; + } +} + +/* ── QUIZ INLINE (dentro do assunto) ── */ +.inline-quiz-wrap { + margin: 2.5rem 0 1.5rem; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.inline-quiz-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem 1.2rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.inline-quiz-head .sq-label { + margin: 0; + padding: 0; + border: none; +} + +.inline-quiz-link { + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + text-decoration: none; + transition: color var(--t); +} + +.inline-quiz-link:hover { + color: var(--amber); +} + +#inline-quiz-body, +#inline-quiz-player, +#inline-quiz-result { + padding: 1.2rem; +} + +.btn-start-quiz { + width: 100%; + padding: 0.85rem; + justify-content: center; + background: color-mix(in srgb, var(--amber) 8%, transparent); + border-color: var(--amber); + color: var(--amber); +} + +.btn-start-quiz:hover { + background: color-mix(in srgb, var(--amber) 15%, transparent); +} + +/* ── TABELA NA PROSE ── */ +.prose table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; + margin: 1.5rem 0; +} + +.prose th { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border-2); + text-align: left; +} + +.prose td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border); + color: var(--text-2); + vertical-align: top; +} + +.prose tr:last-child td { + border-bottom: none; +} + +.prose tr:hover td { + background: color-mix(in srgb, var(--amber) 3%, transparent); +} diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..6460cbe --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,34 @@ +{ + "name": "Caderno de Estudos - Direito", + "short_name": "Caderno", + "description": "Caderno interativo de estudos com quizzes e simulados de Direito", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0f0f13", + "theme_color": "#c9a961", + "orientation": "portrait-primary", + "icons": [ + { + "src": "data:image/svg+xml,D", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "data:image/svg+xml,D", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "maskable" + } + ], + "categories": ["education", "productivity"], + "screenshots": [ + { + "src": "data:image/svg+xml,Caderno", + "sizes": "540x720", + "type": "image/svg+xml", + "form_factor": "narrow" + } + ] +} diff --git a/static/offline.html b/static/offline.html new file mode 100644 index 0000000..0cb1bdc --- /dev/null +++ b/static/offline.html @@ -0,0 +1,22 @@ + + + + + +Sem conexão · Caderno + + + +
+

Você está offline

+

Páginas já visitadas estão disponíveis em cache.
+ Voltar ao início

+
+ + diff --git a/static/quiz-engine.js b/static/quiz-engine.js new file mode 100644 index 0000000..03e50a5 --- /dev/null +++ b/static/quiz-engine.js @@ -0,0 +1,170 @@ +/** + * Quiz Engine - Renderiza questões na página + */ + +function _renderQuestion(q) { + if (!q) return; + const body = document.getElementById('quiz-body'); + if (!body) return; + + const counter = document.getElementById('q-counter'); + if (counter) { + counter.textContent = `${_qState.qIndex + 1} de ${_qState.questions.length}`; + } + + const prog = document.getElementById('prog'); + if (prog) { + const pct = ((_qState.qIndex + 1) / _qState.questions.length) * 100; + prog.style.width = pct + '%'; + } + + if (q.type === 'mc') { + body.innerHTML = `
+

${md(q.text)}

+
+ ${q.options + .map( + (o, i) => `` + ) + .join('')} +
+ + ${q.comment ? `
comentário
${md(q.comment)}
` : ''} +
`; + } else { + // Modo Flashcard para questões abertas + const isRevealed = _qState.flipped || false; + body.innerHTML = `
+

${md(q.text)}

+
+ ${!isRevealed ? `` : `
resposta de referência
${md(q.reference)}
`} +
+ + ${q.comment ? `
comentário
${md(q.comment)}
` : ''} +
+ + +
+
`; + } +} + +function selectOpt(btn) { + if (_qState.answered) return; + _qState.answered = true; + const chosen = btn.dataset.opt; + const correct = btn.dataset.answer; + btn.closest('.options').querySelectorAll('.opt').forEach((b) => { + b.disabled = true; + if (b.dataset.opt === correct) b.classList.add('correct'); + else if (b === btn) b.classList.add('wrong'); + else b.classList.add('revealed'); + }); + if (chosen === correct) { + _qState.acertos++; + setFeedback(true, 'Correto'); + } else { + _qState.erros++; + setFeedback(false, 'Incorreto — a correta está destacada'); + } + document.getElementById('comment')?.classList.add('show'); + document.getElementById('btn-next').style.display = ''; +} + +function flipFlashcard() { + const card = document.getElementById('flashcard'); + if (card) { + _qState.flipped = true; + _renderQuestion(_qState.questions[_qState.qIndex]); + } +} + +function markFlashcardOk() { + _qState.answered = true; + _qState.acertos++; + setFeedback(true, 'Marcado como acertado'); + document.getElementById('comment')?.classList.add('show'); + document.getElementById('flashcard-btns').innerHTML = ''; + document.getElementById('btn-next').style.display = ''; +} + +function markFlashcardBad() { + _qState.answered = true; + _qState.erros++; + setFeedback(false, 'Marcado como errado'); + document.getElementById('comment')?.classList.add('show'); + document.getElementById('flashcard-btns').innerHTML = ''; + document.getElementById('btn-next').style.display = ''; +} + +function setFeedback(ok, msg) { + const fb = document.getElementById('feedback'); + fb.className = 'feedback-line show ' + (ok ? 'ok' : 'bad'); + document.getElementById('feedback-msg').textContent = msg; +} + +function _buildResult() { + const { acertos, erros, questions } = _qState; + const total = questions.length; + const pct = total ? Math.round((acertos / total) * 100) : 0; + let nivel, detalhe; + if (pct >= 90) { + nivel = 'Excelente'; + detalhe = 'Domínio consistente do conteúdo. Mantenha revisão periódica.'; + } else if (pct >= 70) { + nivel = 'Bom'; + detalhe = 'Base sólida com pontos a refinar. Revise os erros e releia os comentários.'; + } else if (pct >= 50) { + nivel = 'Regular'; + detalhe = 'Compreensão parcial, com lacunas importantes. Retome o material teórico.'; + } else { + nivel = 'Insuficiente'; + detalhe = 'Os conceitos centrais ainda não estão consolidados. Estude o tema e refaça.'; + } + document.getElementById('res-pct').textContent = pct + '%'; + document.getElementById('res-a').textContent = acertos; + document.getElementById('res-a2').textContent = acertos; + document.getElementById('res-e').textContent = erros; + document.getElementById('res-t').textContent = total; + document.getElementById('res-msg-body').innerHTML = `${nivel} — ${acertos} de ${total} (${pct}%). ${detalhe}`; +} + +function _saveProgress(key) { + try { + localStorage.setItem(PROGRESS_PREFIX + key, JSON.stringify({ + questions: _qState.questions, + qIndex: _qState.qIndex, + acertos: _qState.acertos, + erros: _qState.erros, + flipped: _qState.flipped, + })); + } catch (e) {} +} + +function _loadProgress(key) { + try { + const s = localStorage.getItem(PROGRESS_PREFIX + key); + if (!s) return false; + const d = JSON.parse(s); + if (!d.questions || !d.questions.length) return false; + Object.assign(_qState, { + questions: d.questions, + qIndex: d.qIndex || 0, + acertos: d.acertos || 0, + erros: d.erros || 0, + flipped: d.flipped || false, + }); + return true; + } catch (e) { + return false; + } +} + +function _clearProgress(key) { + try { + localStorage.removeItem(PROGRESS_PREFIX + key); + } catch (e) {} +} diff --git a/static/quiz-parser.js b/static/quiz-parser.js new file mode 100644 index 0000000..c62beb5 --- /dev/null +++ b/static/quiz-parser.js @@ -0,0 +1,144 @@ +/** + * Quiz Parser - Converte sintaxe customizada em JSON estruturado + * Sintaxe suportada: + * ? Enunciado da questão + * - Opção A / - Opção B + * = Opção correta (incluída nas opções automaticamente se ausente) + * > Comentário (pode repetir; linha vazia separa parágrafos) + * + * ? Questão aberta + * [ Resposta de referência (pode repetir) + * > Comentário + * + * --- ou linha em branco separam questões + * # linha ignorada + */ + +function parseQuizBody(raw) { + if (!raw || !raw.trim()) return []; + const lines = raw.replace(/\r\n/g, '\n').split('\n'); + const result = []; + let enunciado = '', tipo = '', opcoes = [], gabarito = '', referencia = '', comentario = ''; + + function flush() { + if (!enunciado.trim()) return; + const q = { type: tipo || 'mc', text: enunciado.trim() }; + if (tipo === 'mc') { + q.options = opcoes; + q.answer = gabarito; + } else { + q.reference = referencia.trim(); + } + if (comentario.trim()) q.comment = comentario.trim(); + result.push(q); + enunciado = ''; + tipo = ''; + opcoes = []; + gabarito = ''; + referencia = ''; + comentario = ''; + } + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, '').trimEnd(); + if (/^---+\s*$/.test(line)) { + flush(); + continue; + } + if (line.trim() === '') continue; + const mark = line[0]; + const content = line.length > 1 ? line.slice(2) : ''; + switch (mark) { + case '#': + break; + case '?': + flush(); + enunciado = content; + break; + case '-': + opcoes.push(content); + tipo = 'mc'; + break; + case '=': + gabarito = content; + tipo = 'mc'; + if (content && !opcoes.includes(content)) opcoes.push(content); + break; + case '[': + referencia += (referencia ? '\n' : '') + content; + tipo = 'open'; + break; + case '>': + comentario += (comentario ? '\n' : '') + content; + break; + default: + if (enunciado && !tipo) enunciado += '\n' + line; + else if (tipo === 'open' && referencia) referencia += '\n' + line; + } + } + flush(); + return result; +} + +/* ── UTILITÁRIOS BASE ── */ +const LETRAS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +function shuffle(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +function prepareQuizQuestions(questions) { + const letterCounts = []; + let previousCorrectIndex = -1; + return questions.map((q) => { + if (!q || q.type !== 'mc' || !Array.isArray(q.options) || q.options.length < 2) { + return q; + } + const answerIndex = q.options.findIndex((o) => o === q.answer); + if (answerIndex < 0) { + return { ...q, options: shuffle(q.options) }; + } + const optionCount = q.options.length; + const availableIndexes = Array.from({ length: optionCount }, (_, i) => i); + const lowestCount = Math.min(...availableIndexes.map((i) => letterCounts[i] || 0)); + let candidates = availableIndexes.filter((i) => (letterCounts[i] || 0) === lowestCount); + if (optionCount > 1 && candidates.length > 1) { + candidates = candidates.filter((i) => i !== previousCorrectIndex); + } + const correctIndex = shuffle(candidates)[0] ?? 0; + const otherOptions = shuffle(q.options.filter((_, i) => i !== answerIndex)); + const arrangedOptions = [...otherOptions]; + arrangedOptions.splice(correctIndex, 0, q.options[answerIndex]); + letterCounts[correctIndex] = (letterCounts[correctIndex] || 0) + 1; + previousCorrectIndex = correctIndex; + return { ...q, options: arrangedOptions }; + }); +} + +/* Para uso em atributos HTML (onclick="...") */ +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/* Para innerHTML: escapa HTML e renderiza Markdown inline. + Suporta: **negrito**, *itálico*, `código`, \n→
*/ +function md(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\n/g, '
'); +} diff --git a/static/quiz-runtime.js b/static/quiz-runtime.js new file mode 100644 index 0000000..d6e824d --- /dev/null +++ b/static/quiz-runtime.js @@ -0,0 +1,92 @@ +/** + * Quiz Runtime — controla o fluxo de quiz/simulado na página dedicada + */ + +const PROGRESS_PREFIX = window.PROGRESS_PREFIX = 'quiz_progress_'; + +// Estado global do quiz (página de quiz dedicada) +let _qState = { + questions: [], + qIndex: 0, + acertos: 0, + erros: 0, + answered: false, + flipped: false, +}; + +function nextQuestion() { + _qState.qIndex++; + _qState.answered = false; + _qState.flipped = false; + if (_qState.qIndex >= _qState.questions.length) { + showResult(); + return; + } + const body = document.getElementById('quiz-body'); + if (!body) return; + body.classList.add('fade-out'); + setTimeout(() => { + body.classList.remove('fade-out'); + _renderQuestion(_qState.questions[_qState.qIndex]); + _saveProgress(window.QUIZ_KEY); + }, 100); +} + +function showResult() { + const wrap = document.getElementById('quiz-wrap'); + const result = document.getElementById('quiz-result'); + if (wrap) wrap.style.display = 'none'; + if (result) result.style.display = ''; + _buildResult(); + _clearProgress(window.QUIZ_KEY); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function restartQuiz() { + _clearProgress(window.QUIZ_KEY); + _qState.questions = prepareQuizQuestions(parseQuizBody( + typeof RAW_QUIZ !== 'undefined' ? RAW_QUIZ : '' + )); + _qState.qIndex = 0; + _qState.acertos = 0; + _qState.erros = 0; + _qState.answered = false; + _qState.flipped = false; + const wrap = document.getElementById('quiz-wrap'); + const result = document.getElementById('quiz-result'); + if (result) result.style.display = 'none'; + if (wrap) wrap.style.display = ''; + _renderQuestion(_qState.questions[0]); +} + +function initQuiz() { + // Só inicializa na página de quiz (onde existe #quiz-body) + if (!document.getElementById('quiz-body')) return; + + const raw = typeof RAW_QUIZ !== 'undefined' ? RAW_QUIZ : ''; + const key = window.QUIZ_KEY; + + if (!_loadProgress(key)) { + _qState.questions = prepareQuizQuestions(parseQuizBody(raw)); + _qState.qIndex = 0; + _qState.acertos = 0; + _qState.erros = 0; + } + + _qState.answered = false; + _qState.flipped = false; + + const wrap = document.getElementById('quiz-wrap'); + const result = document.getElementById('quiz-result'); + if (result) result.style.display = 'none'; + if (wrap) wrap.style.display = ''; + + if (_qState.qIndex >= _qState.questions.length) { + showResult(); + return; + } + + _renderQuestion(_qState.questions[_qState.qIndex]); +} + +document.addEventListener('DOMContentLoaded', initQuiz); diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..71f0e5f --- /dev/null +++ b/static/sw.js @@ -0,0 +1,68 @@ +/* Service Worker — caderno de estudos + Estratégia: cache-first para assets estáticos, network-first para HTML + Versão: 3 (com suporte a stale-while-revalidate) */ +const CACHE = 'caderno-v3'; +const STATIC = [ + '/', + '/offline.html' +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE).then(c => c.addAll(STATIC)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + const url = new URL(e.request.url); + + // Ignora requisições externas (fontes Google, etc.) + if (url.origin !== location.origin) return; + + if (e.request.mode === 'navigate') { + // HTML: network-first, fallback para offline.html + e.respondWith( + fetch(e.request) + .then(res => { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + return res; + }) + .catch(() => caches.match(e.request).then(r => r || caches.match('/offline.html'))) + ); + } else { + // Assets (CSS, JS, imagens): stale-while-revalidate + e.respondWith( + caches.match(e.request).then(cached => { + // Se estiver em cache, retorna imediatamente e atualiza em background + if (cached) { + // Atualizar em background + fetch(e.request).then(res => { + if (res && res.status === 200) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + }); + return cached; + } + + // Se não estiver em cache, busca da rede + return fetch(e.request).then(res => { + if (res && res.status === 200) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }); + }) + ); + } +}); diff --git a/static/toc.js b/static/toc.js new file mode 100644 index 0000000..950533e --- /dev/null +++ b/static/toc.js @@ -0,0 +1,52 @@ +/** + * Table of Contents - Gera sumário automático de headings + */ + +document.addEventListener('DOMContentLoaded', () => { + const prose = document.getElementById('prose'); + const tocNav = document.getElementById('toc-nav'); + const tocList = document.getElementById('toc-list'); + + if (!prose || !tocNav || !tocList) return; + + const headings = prose.querySelectorAll('h2, h3'); + if (!headings.length) return; + + const used = new Set(); + + headings.forEach((h) => { + // Gerar ID único baseado no texto + let base = h.textContent + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + let id = base || 'sec'; + let i = 2; + while (used.has(id)) id = base + '-' + i++; + + h.id = id; + used.add(id); + + // Criar item no sumário + const li = document.createElement('li'); + li.className = 'toc-' + h.tagName.toLowerCase(); + + const a = document.createElement('a'); + a.href = '#' + id; + a.textContent = h.textContent; + a.addEventListener('click', (e) => { + e.preventDefault(); + h.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + + li.appendChild(a); + tocList.appendChild(li); + }); + + // Mostrar sumário se houver conteúdo + tocNav.style.display = ''; +}); -- cgit v1.2.3