diff --git a/dyktando.html b/dyktando.html new file mode 100644 index 0000000..a952e1e --- /dev/null +++ b/dyktando.html @@ -0,0 +1,65 @@ + + + + + + Dyktando + + + + + +
+ +
+
+

✏️ Dyktando

+ +
+

Wybierz tekst

+
+
+ +
+

Własny tekst

+ + +
+
+
+
+ + + + + + + + diff --git a/dyktando.js b/dyktando.js new file mode 100644 index 0000000..f394534 --- /dev/null +++ b/dyktando.js @@ -0,0 +1,279 @@ +// Dyktando — ortografia u/ó, rz/ż, h/ch +;(function () { + + // Pairs: key = lowercase match → [choice1, choice2] (shown to user) + const PAIRS = { + ch: ['ch', 'h'], + rz: ['rz', 'ż'], + ż: ['rz', 'ż'], + ó: ['u', 'ó'], + u: ['u', 'ó'], + h: ['h', 'ch'], + } + + // Order matters: digraphs (ch, rz) must be tried before single chars (h) + const BLANK_RE = /ch|rz|[uóżh]/gi + + let tokens = [] // {type:'text',content} | {type:'blank',...} + let blanks = [] // subset where type==='blank' + let current = 0 // index of active blank + let origText = '' + + // ── DOM refs ───────────────────────────────────────────────────────────── + const listWrap = document.getElementById('list-wrap') + const playWrap = document.getElementById('play-wrap') + const textList = document.getElementById('text-list') + const customInput = document.getElementById('custom-input') + const customStartBtn = document.getElementById('custom-start-btn') + const playBackBtn = document.getElementById('play-back-btn') + const playTitle = document.getElementById('play-title') + const textDisplay = document.getElementById('text-display') + const choicesEl = document.getElementById('choices') + const progressEl = document.getElementById('blank-progress') + const summaryWrap = document.getElementById('summary') + const summaryText = document.getElementById('summary-text') + const summaryScore = document.getElementById('summary-score') + const summaryBackBtn = document.getElementById('summary-back-btn') + const dykScroll = document.getElementById('dyk-scroll') + + // ── Load texts ──────────────────────────────────────────────────────────── + fetch('dyktanda.json') + .then(r => r.json()) + .then(data => { + data.forEach(item => { + const btn = document.createElement('button') + btn.className = 'reading-item' + btn.textContent = item.name + btn.addEventListener('click', () => startGame(item.name, item.text)) + textList.appendChild(btn) + }) + }) + .catch(() => { + textList.innerHTML = '

Nie udało się wczytać tekstów.

' + }) + + customStartBtn.addEventListener('click', () => { + const txt = customInput.value.trim() + if (!txt) return + startGame('Własny', txt) + }) + + // ── Start game ──────────────────────────────────────────────────────────── + function startGame(title, text) { + origText = text + tokens = [] + blanks = [] + current = 0 + + // Tokenize: split text into plain segments and blanks + BLANK_RE.lastIndex = 0 + let lastIdx = 0 + let m + while ((m = BLANK_RE.exec(text)) !== null) { + if (m.index > lastIdx) { + tokens.push({ type: 'text', content: text.slice(lastIdx, m.index) }) + } + const blank = { + type: 'blank', + id: blanks.length, + answer: m[0], // original (preserves case) + choices: PAIRS[m[0].toLowerCase()], // pair to show + start: m.index, // position in origText + end: m.index + m[0].length, + userAnswer: null, + correct: null, + } + tokens.push(blank) + blanks.push(blank) + lastIdx = m.index + m[0].length + } + if (lastIdx < text.length) { + tokens.push({ type: 'text', content: text.slice(lastIdx) }) + } + + // Reset UI + playTitle.textContent = title + summaryWrap.classList.add('hidden') + textDisplay.classList.remove('hidden') + choicesEl.classList.remove('hidden') + progressEl.textContent = '' + + listWrap.classList.add('hidden') + playWrap.classList.remove('hidden') + + if (blanks.length === 0) { + renderText() + showSummary() + } else { + renderText() + activateBlank(0) + } + } + + // ── Render the text with blanks ─────────────────────────────────────────── + function renderText() { + textDisplay.innerHTML = '' + tokens.forEach(tok => { + if (tok.type === 'text') { + textDisplay.appendChild(document.createTextNode(tok.content)) + } else { + const span = document.createElement('span') + span.id = `blank-${tok.id}` + + if (tok.correct !== null) { + // Already answered — always show the CORRECT letter, colored by result + span.className = `dyk-blank dyk-blank--${tok.correct ? 'ok' : 'err'}` + span.textContent = tok.answer + } else if (tok.id === current) { + span.className = 'dyk-blank dyk-blank--active' + span.textContent = '__' + } else { + span.className = 'dyk-blank dyk-blank--pending' + span.textContent = '__' + } + + textDisplay.appendChild(span) + } + }) + } + + // ── Activate a blank ────────────────────────────────────────────────────── + function activateBlank(idx) { + current = idx + renderText() + + const blank = blanks[idx] + + // Shuffle choices so correct answer isn't always in same position + const choices = Math.random() < 0.5 + ? [...blank.choices] + : [...blank.choices].reverse() + + choicesEl.innerHTML = '' + choices.forEach(choice => { + const btn = document.createElement('button') + btn.className = 'choice-btn' + btn.textContent = choice + btn.addEventListener('click', () => handleAnswer(blank, choice)) + choicesEl.appendChild(btn) + }) + + progressEl.textContent = `${idx + 1} / ${blanks.length}` + + // Scroll active blank into view + requestAnimationFrame(() => { + const el = document.getElementById(`blank-${idx}`) + if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) + }) + } + + // ── Handle answer ───────────────────────────────────────────────────────── + function handleAnswer(blank, choice) { + blank.correct = choice.toLowerCase() === blank.answer.toLowerCase() + blank.userAnswer = choice + + const nextIdx = blank.id + 1 + if (nextIdx >= blanks.length) { + // Last blank answered: show final state briefly, then summary + current = blanks.length + choicesEl.innerHTML = '' + progressEl.textContent = '' + renderText() + setTimeout(showSummary, 500) + } else { + activateBlank(nextIdx) + } + } + + // ── Summary ─────────────────────────────────────────────────────────────── + function showSummary() { + choicesEl.classList.add('hidden') + textDisplay.classList.add('hidden') + summaryWrap.classList.remove('hidden') + + const total = blanks.length + const correct = blanks.filter(b => b.correct).length + + if (total === 0) { + summaryScore.textContent = 'Brak liter do uzupełnienia w tym tekście.' + summaryText.innerHTML = esc(origText) + } else { + summaryScore.textContent = `Poprawnie: ${correct} / ${total}` + summaryText.innerHTML = buildSummaryHTML() + } + + dykScroll.scrollTop = 0 + } + + // Build word-coloured summary HTML + function buildSummaryHTML() { + // Find word spans (non-whitespace sequences) in original text + const wordRe = /\S+/g + const words = [] + let wm + while ((wm = wordRe.exec(origText)) !== null) { + words.push({ start: wm.index, end: wm.index + wm[0].length }) + } + + // Assign each word its blanks and overall status + words.forEach(w => { + w.blanks = blanks.filter(b => b.start >= w.start && b.end <= w.end) + w.status = w.blanks.length === 0 ? 'none' + : w.blanks.every(b => b.correct) ? 'ok' : 'err' + }) + + let html = '' + let pos = 0 + + words.forEach(word => { + // Whitespace / punctuation before this word + if (word.start > pos) html += esc(origText.slice(pos, word.start)) + + // Build inner word HTML (blanks replaced by correct answers, coloured) + let wPos = word.start + let inner = '' + word.blanks.forEach(blank => { + if (blank.start > wPos) inner += esc(origText.slice(wPos, blank.start)) + inner += `${esc(blank.answer)}` + wPos = blank.end + }) + inner += esc(origText.slice(wPos, word.end)) + + html += word.status !== 'none' + ? `${inner}` + : inner + + pos = word.end + }) + + if (pos < origText.length) html += esc(origText.slice(pos)) + return html + } + + function esc(s) { + return (s || '').replace(/&/g, '&').replace(//g, '>') + } + + // ── Navigation ──────────────────────────────────────────────────────────── + playBackBtn.addEventListener('click', () => { + const inProgress = blanks.length > 0 && current < blanks.length + if (inProgress && !confirm('Przerwać dyktando i wrócić do listy?')) return + goToList() + }) + + summaryBackBtn.addEventListener('click', goToList) + + function goToList() { + tokens = [] + blanks = [] + current = 0 + textDisplay.innerHTML = '' + textDisplay.classList.remove('hidden') + summaryWrap.classList.add('hidden') + choicesEl.innerHTML = '' + choicesEl.classList.remove('hidden') + playWrap.classList.add('hidden') + listWrap.classList.remove('hidden') + } + +})() diff --git a/index.html b/index.html index 189c4bd..7a1cdf2 100644 --- a/index.html +++ b/index.html @@ -18,10 +18,9 @@ 📖 Czytanie - + ✏️ - Ortografia - wkrótce + Dyktando diff --git a/styles.css b/styles.css index 3170fb4..165ec7d 100644 --- a/styles.css +++ b/styles.css @@ -128,3 +128,32 @@ body{ .next-line-btn{position:absolute;bottom:max(24px,calc(env(safe-area-inset-bottom) + 12px));right:20px;width:64px;height:64px;border-radius:50%;background:var(--accent);color:white;font-size:26px;border:none;box-shadow:0 4px 20px rgba(43,108,176,.38);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:opacity .2s} .next-line-btn:disabled{opacity:.3;cursor:default} + +/* ── Dyktando ── */ +.blank-progress{font-size:14px;font-weight:600;color:var(--muted);white-space:nowrap;flex-shrink:0} + +.dyk-body{flex:1;display:flex;flex-direction:column;overflow:hidden} +.dyk-scroll{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch} +.dyk-text{padding:20px;font-size:20px;line-height:1.9} + +/* inline blank spans */ +.dyk-blank{display:inline;font-weight:700} +.dyk-blank--pending{color:#dc2626;border-bottom:2px solid #dc2626} +.dyk-blank--active{color:#dc2626;background:#fee2e2;border-radius:4px;padding:0 2px;animation:dyk-pulse 1s ease-in-out infinite} +.dyk-blank--ok{color:#16a34a;font-weight:700} +.dyk-blank--err{color:#dc2626;font-weight:700} +@keyframes dyk-pulse{0%,100%{opacity:1}50%{opacity:.4}} + +/* choice buttons */ +.dyk-choices{flex-shrink:0;display:flex;gap:12px;padding:12px 16px;padding-bottom:max(12px,env(safe-area-inset-bottom));background:white;border-top:2px solid #e6e9ef} +.dyk-choices.hidden{display:none} +.choice-btn{flex:1;padding:18px 8px;border-radius:14px;border:2px solid var(--accent);background:white;font-size:24px;font-weight:700;cursor:pointer;color:var(--accent);font-family:inherit;transition:background .12s,color .12s} +.choice-btn:hover,.choice-btn:active{background:var(--accent);color:white} + +/* summary */ +.dyk-summary{padding:20px} +.dyk-summary-text{font-size:20px;line-height:1.9;margin-bottom:20px} +.summary-score{font-size:18px;font-weight:700;text-align:center;color:#111;margin:0 0 20px} +.dyk-summary-back{display:block;width:100%;margin-bottom:32px} +.dyk-word--ok{color:#16a34a;font-weight:700} +.dyk-word--err{color:#dc2626;font-weight:700}