🧩 Syntax:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vocab Drill</title>
<style>
:root {
--bg: #ffffff;
--text: #111111;
--accent: #2e7d32;
--error: #c62828;
--border: #eeeeee;
--subtle: #888888;
--highlight: #fff5f5;
}
body {
margin: 0;
font-family: "Gentium Book Plus", serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
#app {
width: 600px;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
#progress-container {
width: 100%;
height: 4px;
background: var(--border);
position: fixed;
top: 0;
left: 0;
display: none;
}
#progress-bar {
height: 100%;
background: var(--text);
width: 0%;
transition: width 0.3s ease;
}
#setup-view, #drill-view, #summary-view { width: 100%; text-align: center; }
.hidden { display: none !important; }
#front-display {
font-size: 42px;
margin-top: 20px;
line-height: 1.2;
}
#note-display {
font-size: 16px;
color: var(--subtle);
margin-top: 10px;
margin-bottom: 40px;
min-height: 20px;
font-style: italic;
}
#title-text { font-size: 42px; margin-bottom: 40px; }
.slot-bar {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 15px;
}
.slot-btn {
padding: 6px 20px;
font-size: 13px;
background: transparent;
border: 1px solid var(--border);
color: var(--subtle);
cursor: pointer;
border-radius: 4px;
}
.slot-btn.active {
border-color: var(--text);
color: var(--text);
font-weight: bold;
background: #f9f9f9;
}
textarea {
width: 100%;
height: 200px;
font-family: "Fira Code", monospace;
font-size: 13px;
padding: 15px;
margin-bottom: 25px;
border: 1px solid var(--border);
border-radius: 4px;
box-sizing: border-box;
outline: none;
}
input[type="text"] {
font-family: inherit;
font-size: 28px;
width: 100%;
border: none;
border-bottom: 2px solid var(--border);
padding: 10px;
outline: none;
text-align: center;
background: transparent;
}
button.main-btn {
padding: 12px 40px;
font-family: inherit;
font-size: 16px;
cursor: pointer;
background: var(--text);
color: var(--bg);
border: none;
border-radius: 2px;
}
#feedback {
margin-top: 25px;
height: 30px;
font-size: 18px;
}
.correct { color: var(--accent); }
.wrong { color: var(--error); font-weight: bold; }
.reveal { color: var(--subtle); font-style: italic; }
#bottom {
margin-top: 80px;
width: 100%;
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--subtle);
border-top: 1px solid var(--border);
padding-top: 20px;
letter-spacing: 0.1em;
}
.ledger-container {
margin-top: 30px;
text-align: left;
width: 100%;
max-height: 400px;
overflow-y: auto;
border-top: 1px solid var(--border);
}
.ledger-row {
display: flex;
padding: 12px 10px;
border-bottom: 1px solid var(--border);
font-size: 16px;
align-items: baseline;
}
.ledger-row.missed { background-color: var(--highlight); }
.ledger-status { width: 25px; font-size: 12px; }
.ledger-row.missed .ledger-status { color: var(--error); }
.ledger-front { flex: 1; font-weight: bold; }
.logeion-link {
text-decoration: none;
color: inherit;
border-bottom: 1px dotted var(--border);
transition: border-color 0.2s;
}
.logeion-link:hover {
border-bottom-color: var(--text);
cursor: help;
}
.ledger-back { flex: 1.5; color: var(--subtle); }
</style>
</head>
<body>
<div id="progress-container"><div id="progress-bar"></div></div>
<div id="app">
<div id="setup-view">
<div id="title-text">Vocab Drill</div>
<div class="slot-bar">
<button id="btn-A" class="slot-btn active" onclick="switchSlot('A')">Slot A</button>
<button id="btn-B" class="slot-btn" onclick="switchSlot('B')">Slot B</button>
</div>
<textarea id="jsonPaste" placeholder='[{"front": "ὁ ἄνθρωπος", "back": "man"}]' oninput="autoSave()"></textarea>
<div style="margin-bottom: 25px; font-size: 12px; color: var(--subtle);">
<input type="file" id="fileInput" style="display:none">
<label for="fileInput" style="cursor:pointer; text-decoration: underline;">Upload JSON</label>
</div>
<button class="main-btn" onclick="handleStart()">Begin Session</button>
</div>
<div id="drill-view" class="hidden">
<div id="front-display"></div>
<div id="note-display"></div>
<input id="input" type="text" autocomplete="off" placeholder="Type answer...">
<div id="feedback"></div>
<div id="bottom">
<div id="stats-line">—</div>
<div id="instruction-text">TAB reveal • ESC skip</div>
</div>
</div>
<div id="summary-view" class="hidden">
<h2 style="font-weight: normal; color: var(--subtle); margin-bottom: 10px;">Session Complete</h2>
<div id="stat-accuracy" style="font-size: 14px; color: var(--subtle); margin-bottom: 30px;"></div>
<div id="ledger" class="ledger-container"></div>
<button class="main-btn" onclick="showSetup()" style="margin-top: 40px;">New Session</button>
</div>
</div>
<script>
let deck = [];
let queue = [];
let current = null;
let initialCount = 0;
let stats = { finished: 0, wrongAnswers: {} };
let currentSlot = 'A';
let isPausedForError = false;
window.onload = () => loadSlot('A');
function switchSlot(slot) {
currentSlot = slot;
document.getElementById('btn-A').classList.toggle('active', slot === 'A');
document.getElementById('btn-B').classList.toggle('active', slot === 'B');
loadSlot(slot);
}
function loadSlot(slot) {
const saved = localStorage.getItem(`vdrill_deck_${slot}`);
document.getElementById('jsonPaste').value = saved || "";
}
function autoSave() {
localStorage.setItem(`vdrill_deck_${currentSlot}`, document.getElementById('jsonPaste').value);
}
document.getElementById('fileInput').addEventListener('change', e => {
const reader = new FileReader();
reader.onload = evt => {
document.getElementById('jsonPaste').value = evt.target.result;
autoSave();
};
reader.readAsText(e.target.files[0]);
});
function handleStart() {
try {
deck = JSON.parse(document.getElementById('jsonPaste').value.trim());
if (!Array.isArray(deck) || deck.length === 0) throw "Err";
startSession();
} catch (e) {
alert("Invalid JSON format.");
}
}
function startSession() {
queue = shuffle([...deck]);
initialCount = queue.length;
stats = { finished: 0, wrongAnswers: {} };
isPausedForError = false;
document.getElementById('setup-view').classList.add('hidden');
document.getElementById('summary-view').classList.add('hidden');
document.getElementById('drill-view').classList.remove('hidden');
document.getElementById('progress-container').style.display = 'block';
nextCard();
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function nextCard() {
if (queue.length === 0) return showSummary();
isPausedForError = false;
current = queue.shift();
document.getElementById('front-display').innerText = current.front;
document.getElementById('note-display').innerText = current.note || "";
document.getElementById('input').value = "";
document.getElementById('input').disabled = false;
document.getElementById('feedback').innerText = "";
document.getElementById('instruction-text').innerText = "TAB reveal • ESC skip";
document.getElementById('input').focus();
updateStats();
}
function submit() {
const inputVal = document.getElementById('input').value;
const answers = Array.isArray(current.back) ? current.back : [current.back];
const correct = isCorrect(inputVal, answers);
const feedback = document.getElementById('feedback');
if (correct) {
stats.finished++;
feedback.innerText = "✓ " + answers.join(", ");
feedback.className = "correct";
setTimeout(nextCard, 600);
} else {
feedback.innerText = "✗ " + answers.join(", ");
feedback.className = "wrong";
stats.wrongAnswers[current.front] = (stats.wrongAnswers[current.front] || 0) + 1;
isPausedForError = true;
document.getElementById('input').disabled = true;
document.getElementById('instruction-text').innerText = "ENTER to continue";
queue.splice(Math.min(2, queue.length), 0, current);
queue.splice(Math.min(7, queue.length), 0, current);
}
updateStats();
}
function isCorrect(input, answers) {
const norm = s => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
const nInput = norm(input);
return answers.some(ans => {
const nAns = norm(ans);
if (nInput === nAns) return true;
const d = (function(a, b){
const tmp = Array.from({length:a.length+1},()=>[]);
for(let i=0;i<=a.length;i++)tmp[i][0]=i;
for(let j=0;j<=b.length;j++)tmp[0][j]=j;
for(let i=1;i<=a.length;i++)for(let j=1;j<=b.length;j++)
tmp[i][j]=Math.min(tmp[i-1][j]+1,tmp[i][j-1]+1,tmp[i-1][j-1]+(a[i-1]===b[j-1]?0:1));
return tmp[a.length][b.length];
})(nInput, nAns);
return d <= Math.max(1, Math.floor(nAns.length / 6));
});
}
function updateStats() {
const pct = Math.max(0, Math.min(100, (stats.finished / initialCount) * 100));
document.getElementById('progress-bar').style.width = `${pct}%`;
document.getElementById('stats-line').innerText = `${stats.finished} done • ${queue.length} queue`;
}
function showSummary() {
document.getElementById('drill-view').classList.add('hidden');
document.getElementById('summary-view').classList.remove('hidden');
document.getElementById('progress-bar').style.width = '100%';
const totalMisses = Object.keys(stats.wrongAnswers).length;
const accuracy = Math.round(((initialCount - totalMisses) / initialCount) * 100);
const wordLabel = totalMisses === 1 ? "word" : "words";
document.getElementById('stat-accuracy').innerText = `${accuracy}% accuracy • ${totalMisses} ${wordLabel} flagged`;
const ledger = document.getElementById('ledger');
ledger.innerHTML = deck.map(card => {
const isMissed = stats.wrongAnswers[card.front];
const backText = Array.isArray(card.back) ? card.back.join(", ") : card.back;
const cleanForUrl = (text) => {
const articles = ['ὁ', 'ἡ', 'τὸ', 'ο', 'η', 'το'];
const words = text.replace(/,/g, ' ').trim().split(/\s+/);
if (articles.includes(words[0]) && words.length > 1) return words[1];
return words[0];
};
const logeionUrl = `https://logeion.uchicago.edu/${encodeURIComponent(cleanForUrl(card.front))}`;
return `
<div class="ledger-row ${isMissed ? 'missed' : ''}">
<div class="ledger-status">${isMissed ? '●' : ''}</div>
<div class="ledger-front">
<a href="${logeionUrl}" target="_blank" class="logeion-link">${card.front}</a>
</div>
<div class="ledger-back">${backText}</div>
</div>
`;
}).join('');
}
function showSetup() {
document.getElementById('setup-view').classList.remove('hidden');
document.getElementById('drill-view').classList.add('hidden');
document.getElementById('summary-view').classList.add('hidden');
document.getElementById('progress-container').style.display = 'none';
}
document.addEventListener('keydown', e => {
if (document.getElementById('drill-view').classList.contains('hidden')) return;
if (e.key === 'Enter') {
if (isPausedForError) nextCard();
else if (document.getElementById('input').value.trim() !== "") submit();
} else if (e.key === 'Tab') {
e.preventDefault();
reveal();
} else if (e.key === 'Escape') {
nextCard();
}
});
function reveal() {
const answers = Array.isArray(current.back) ? current.back : [current.back];
const feedback = document.getElementById('feedback');
feedback.innerText = "→ " + answers.join(", ");
feedback.className = "reveal";
}
</script>
</body>
</html>