`); } // ── QR CODE ENROLLMENT ─────────────────────────────────────────────── function showQRCode(gens){ const url=`${location.origin}?class=${encodeURIComponent(gens)}`; // Use QR code API (no external lib needed) const qrUrl=`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}&color=c9a050&bgcolor=08070a`; const el=document.getElementById('qr-overlay'); const img=document.getElementById('qr-img'); const link=document.getElementById('qr-link'); if(!el||!img)return; img.src=qrUrl; if(link)link.textContent=url; el.classList.add('show'); } // Parse ?class= on load (()=>{ const params=new URLSearchParams(location.search); const cls=params.get('class'); if(cls&&!S.name){ const sel=document.getElementById('inp-gens'); if(sel){ populateGensDropdown(); setTimeout(()=>{ [...sel.options].forEach(o=>{if(o.value===cls)o.selected=true;}); },100); } } })(); // ── WEB SPEECH PRONUNCIATION ───────────────────────────────────────── function pronounce(text){ if(!('speechSynthesis' in window))return; window.speechSynthesis.cancel(); const utt=new SpeechSynthesisUtterance(text); // Italian locale gives closest approximation to classical Latin utt.lang='it-IT';utt.rate=0.85;utt.pitch=1.0; window.speechSynthesis.speak(utt); } // ── WORD FAMILY CHAINS ─────────────────────────────────────────────── function renderWordFamilyHtml(word){ // Related words sharing a stem root let relatedHtml=''; const stem4=(word.stem||word.presStem||'').slice(0,4); if(stem4.length>=3){ const related=[ ...NOUNS.filter(n=>n.id!==word.id&&(n.stem||'').startsWith(stem4)), ...VERBS.filter(v=>v.id!==word.id&&(v.presStem||'').startsWith(stem4)), ...ADJECTIVES.filter(a=>a.id!==word.id&&(a.stem||'').startsWith(stem4)), ].slice(0,4); if(related.length){ relatedHtml=`
familia verbī` +related.map(r=>`${r.nom||r.pp?.split(',')[0]?.trim()||r.m||''} ${r.en?.split(',')[0]||''}`).join('')+'
'; } } if(!word?.derivs)return relatedHtml; const all=[...(word.derivs.en||[]),...(word.derivs.fr||[]),...(word.derivs.es||[])]; if(!all.length)return relatedHtml; const byLang=[ {lang:'EN',items:word.derivs.en||[],color:'var(--bl,#70a8e0)'}, {lang:'FR',items:word.derivs.fr||[],color:'var(--go2)'}, {lang:'ES',items:word.derivs.es||[],color:'var(--ok)'}, ].filter(l=>l.items.length); return relatedHtml+`
Word Family
${byLang.flatMap(l=>l.items.map(w=> `${w}` )).join('')}
`; } // ── ANKI EXPORT ─────────────────────────────────────────────────────── // Generates a text-based CSV in Anki import format (tab-separated) // Full .apkg requires binary SQLite — we export as Anki-importable TSV function exportAnki(){ const rows=[['Front','Back','Tags']]; NOUNS.forEach(n=>{ if(!n.forms)return; const tags=`latin noun decl${n.decl} ${n.id}`; // Main entry: nom sg → meaning + gen sg rows.push([n.nom, `${n.en}
${n.pp}`, tags]); // Meaning → Latin rows.push([`(Latin) ${n.en?.split(',')[0]}`, `${n.nom} — ${n.pp}`, tags]); // Selected forms [['sg','acc'],['sg','gen'],['pl','nom'],['pl','acc']].forEach(([num,c])=>{ const form=n.forms[num]?.[c]; if(form)rows.push([ `${n.nom} — ${CASE_LABELS[c]||c} ${num}`, `${form}
${n.pp}`,tags]); }); }); VERBS.filter(v=>!v.deponent).forEach(v=>{ const tags=`latin verb conj${v.conj} ${v.id}`; rows.push([v.pp?.split(',')[0]?.trim(), `${v.en}
${v.pp}`, tags]); rows.push([`(Latin) to ${v.en?.split(',')[0]?.trim()}`, v.pp?.split(',')[0]?.trim(), tags]); if(v.computedForms?.present){ ['0','1','2'].forEach(i=>{ rows.push([`${v.pp?.split(',')[0]?.trim()} — ${PERS_LABELS[i]} present`, `${v.computedForms.present[i]}`,tags]); }); } }); ADJECTIVES.forEach(a=>{ const tags=`latin adj decl${a.decl} ${a.id}`; rows.push([`${a.m} / ${a.f} / ${a.n}`, `${a.en}
${a.pp}`, tags]); }); const tsv=rows.map(r=>r.map(c=>'"'+String(c).replace(/"/g,'""')+'"').join('\t')).join('\n'); const blob=new Blob([tsv],{type:'text/tab-separated-values;charset=utf-8'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`cotidiepandere-anki-${new Date().toISOString().slice(0,10)}.txt`; a.click(); toast(`Anki deck exported — ${rows.length-1} cards`,'tok'); } // ── CANVAS GRADE PASSBACK ───────────────────────────────────────────── // Requires CANVAS_TOKEN env var in Cloudflare + course/assignment IDs // Configure via Admin → Settings → Canvas Integration async function pushGradesToCanvas(){ const token=CFG.canvasToken; const courseId=CFG.canvasCourseId; if(!token||!courseId){ toast('Configure Canvas token in Admin → Settings first','terr');return; } try{ const r=await fetch(`/api/dashboard?password=${encodeURIComponent(CFG.adminPw)}`); if(!r.ok)return toast('Dashboard fetch failed','terr'); const students=await r.json(); const payload=students.map(s=>({ student_name:s.name, class:s.gens, score:s.score||0, modules_completed:Object.values(s.completed||{}).filter(Boolean).length, badges:(s.badges||[]).length, })); // Post to our Canvas proxy endpoint const res=await fetch('/api/canvas',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({password:CFG.adminPw,grades:payload}) }); toast(res.ok?`Pushed ${payload.length} students to Canvas`:'Canvas push failed',res.ok?'tok':'terr'); }catch(e){toast('Canvas error: '+e.message,'terr');} } // ── GOOGLE SHEETS SYNC ──────────────────────────────────────────────── // Syncs student progress to a configured Google Sheet async function syncToSheets(){ const sheetId=CFG.googleSheetId; if(!sheetId){toast('Configure Google Sheet ID in Admin → Settings first','terr');return;} try{ const r=await fetch(`/api/dashboard?password=${encodeURIComponent(CFG.adminPw)}`); if(!r.ok)return toast('Dashboard fetch failed','terr'); const students=await r.json(); const res=await fetch('/api/sheets',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({password:CFG.adminPw,sheetId,students}) }); toast(res.ok?'Synced to Google Sheets':'Sheets sync failed',res.ok?'tok':'terr'); }catch(e){toast('Sheets error','terr');} } // ── ROMAN MAP ───────────────────────────────────────────────────────── // Maps modules to Roman architectural/geographical locations const MAP_LOCATIONS = [ // Noun modules → parts of the Roman house / city {modId:'n1', x:120, y:210, r:36, label:'Ātrium', sublabel:'1st Decl.', icon:'🏺'}, {modId:'n2', x:220, y:165, r:32, label:'Tablīnum', sublabel:'2nd Decl. m', icon:'⚔️'}, {modId:'n3', x:310, y:165, r:30, label:'Culīna', sublabel:'2nd Decl. n', icon:'🏛'}, {modId:'n4', x:400, y:195, r:34, label:'Forum', sublabel:'3rd Decl.', icon:'🦁'}, {modId:'n5', x:480, y:155, r:28, label:'Biblioth.', sublabel:'3rd n', icon:'📜'}, {modId:'n6', x:555, y:195, r:30, label:'Porticus', sublabel:'3rd i m/f', icon:'⚓'}, {modId:'n7', x:555, y:275, r:28, label:'Hortus', sublabel:'3rd i n', icon:'🌊'}, {modId:'n8', x:480, y:305, r:30, label:'Castra', sublabel:'4th Decl.', icon:'✋'}, {modId:'n9', x:400, y:330, r:28, label:'Basilica', sublabel:'5th Decl.', icon:'📅'}, // Verb modules → civic/public buildings {modId:'v1', x:160, y:320, r:34, label:'Templum', sublabel:'Present', icon:'⚡'}, {modId:'v2', x:240, y:295, r:30, label:'Cūria', sublabel:'Imperfect', icon:'🔄'}, {modId:'v3', x:310, y:285, r:30, label:'Rostra', sublabel:'Future', icon:'🔮'}, {modId:'v4', x:240, y:360, r:32, label:'Colosseum', sublabel:'Perfect', icon:'🏆'}, {modId:'v5', x:310, y:370, r:28, label:'Circus', sublabel:'Pluperf.', icon:'⏮'}, {modId:'v6', x:380, y:395, r:26, label:'Arcus', sublabel:'Fut. Perf.', icon:'⏭'}, // Adjective/pronoun modules → monuments {modId:'adj1',x:120, y:300, r:30, label:'Panthēon', sublabel:'Adj. I', icon:'✨'}, {modId:'adj2',x:120, y:370, r:28, label:'Therm.', sublabel:'Adj. II', icon:'⚔️'}, {modId:'pron',x:200, y:395, r:28, label:'Via Sacra', sublabel:'Pronouns', icon:'👁'}, // Passive/deponent → the arena/border {modId:'v7', x:460, y:370, r:26, label:'Circus II', sublabel:'Pres. Pass.', icon:'🛡'}, {modId:'v8', x:540, y:345, r:24, label:'Campus', sublabel:'Imp. Pass.', icon:'🔄'}, {modId:'v9', x:600, y:300, r:24, label:'Limes', sublabel:'Fut. Pass.', icon:'🔮'}, {modId:'dep', x:600, y:235, r:24, label:'Porta', sublabel:'Deponents', icon:'🎭'}, {modId:'pp', x:610, y:170, r:24, label:'Tabularium',sublabel:'Prin. Parts', icon:'📋'}, // New modules → outer areas {modId:'imp', x:80, y:130, r:26, label:'Forum II', sublabel:'Imperatives', icon:'📣'}, {modId:'inf', x:80, y:200, r:24, label:'Schola', sublabel:'Infinitives', icon:'∞'}, {modId:'prep',x:80, y:270, r:26, label:'Via Appia', sublabel:'Prepositions',icon:'📍'}, {modId:'cmp', x:460, y:100, r:26, label:'Aquaeduct.', sublabel:'Comparatives',icon:'⚖️'}, {modId:'num', x:370, y:100, r:24, label:'Sundial', sublabel:'Numerals', icon:'🔢'}, {modId:'voc', x:280, y:100, r:26, label:'Library', sublabel:'Vocabulary', icon:'🧠'}, {modId:'mx', x:335, y:240, r:40, label:'ROMA', sublabel:'Mixed Review',icon:'🏟'},, // ── Imperative sentences ────────────────────────────────────────── {id:'s68',text:'_____ , serve!',blank:'Labōrā',answer:'Labōrā', hint:'2nd sg imperative of labōrō — drop the -re', trans:'Work, slave!',moduleReq:'imper', glosses:{labōrā:'work! (imperative)',serve:'slave (voc)'}}, {id:'s69',text:'_____ cēnam, ancilla.',blank:'Parā',answer:'Parā', hint:'2nd sg imperative of parō', trans:'Prepare dinner, slave-girl!',moduleReq:'imper', glosses:{parā:'prepare! (imperative)',cēnam:'dinner (acc)',ancilla:'slave-girl (voc)'}}, {id:'s70',text:'_____ , mīlitēs!',blank:'Venīte',answer:'Venīte', hint:'2nd pl imperative of veniō — stem venī + te', trans:'Come, soldiers!',moduleReq:'imper', glosses:{venīte:'come! (pl imperative)',mīlitēs:'soldiers (voc pl)'}}, {id:'s71',text:'_____ viam, amīcī.',blank:'Monstrāte',answer:'Monstrāte', hint:'2nd pl imperative of monstrō', trans:'Show the way, friends.',moduleReq:'imper', glosses:{monstrāte:'show! (pl)',viam:'way (acc)',amīcī:'friends (voc pl)'}}, {id:'s72',text:'_____ , Marce! Tempus fugit.',blank:'Festīnā',answer:'Festīnā', hint:'2nd sg imperative of festīnō', trans:'Hurry, Marcus! Time flies.',moduleReq:'imper', glosses:{festīnā:'hurry! (sg)',tempus:'time',fugit:'flees'}}, ]; function renderRomanMap(){ const el=document.getElementById('map-svg');if(!el)return; const W=660,H=470; let svg=` `; MAP_LOCATIONS.forEach(loc=>{ const mod=MODULES.find(m=>m.id===loc.modId);if(!mod)return; const done=S.completed?.[loc.modId]; const accessible=isAccessible(loc.modId); const locked=!accessible; const fillColor=done?'rgba(201,160,80,0.85)':accessible?'rgba(30,26,40,0.9)':'rgba(15,13,20,0.7)'; const strokeColor=done?'#c9a050':accessible?'#4a4060':'#2e2840'; const textColor=done?'#08070a':accessible?'#9a8878':'#4a3858'; const glowFilter=done?'filter:url(#glow)':''; svg+=` ${loc.icon} ${loc.label} ${done?``:''} `; }); // Roma label svg+=`ROMA`; svg+=``; el.innerHTML=svg; } // ── KEYBOARD SHORTCUTS ──────────────────────────────────────────────── document.addEventListener('keydown', e => { // Don't intercept when typing in an input if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const screen = document.querySelector('.screen.active')?.id; // Drill screen: 1–4 for MC options, Enter/Space for Next if (screen === 'screen-drill') { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); const next = document.getElementById('btn-next'); if (next?.classList.contains('show')) { DRILL.next(); return; } } if (['1','2','3','4'].includes(e.key)) { e.preventDefault(); const opts = document.querySelectorAll('.opt:not(:disabled)'); const idx = parseInt(e.key) - 1; if (opts[idx]) opts[idx].click(); return; } // F = focus typed input if present if (e.key.toLowerCase() === 'f') { const inp = document.getElementById('typed-inp'); if (inp) { e.preventDefault(); inp.focus(); } } } // Result screen: Enter → next module or menu if (screen === 'screen-result' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const nextBtn = document.querySelector('#result-actions .btn-gold'); if (nextBtn) nextBtn.click(); else goMenu(); } // Error review screen: Enter → continue if (screen === 'screen-review' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); dismissErrorReview(); } // Escape: go back / close overlay if (e.key === 'Escape') { // Close open overlays first for (const id of ['sr-overlay','settings-overlay']) { const el = document.getElementById(id); if (el?.classList.contains('show')) { el.classList.remove('show'); return; } } if (screen === 'screen-stem') { goMenu(); return; } if (screen === 'screen-drill') { if (confirm('Exit drill?')) goMenu(); return; } if (screen === 'screen-mastery') { goMenu(); return; } } // L = toggle light mode from anywhere if (e.key.toLowerCase() === 'l' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleLightMode(); } }); // Show keyboard hints on drill screen function showKeyHints() { const wrap = document.getElementById('key-hints'); if (wrap) wrap.style.display = ''; } // ── SERVICE WORKER REGISTRATION ────────────────────────────────────── if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); } window.addEventListener('DOMContentLoaded',async()=>{ load();populateGensDropdown(); await fetchConfig(); // load teacher-controlled module release state from KV try{const intg=JSON.parse(localStorage.getItem('lv4_integrations')||'{}');Object.assign(CFG,intg);}catch(e){} initLightMode();applyLightMode(); if(S.name&&S.gens){trackDay();loadProgress().then(()=>{updateTopbar();});G.go('menu');} else{document.getElementById('screen-welcome').classList.add('active');} document.getElementById('inp-name')?.addEventListener('keydown',e=>{if(e.key==='Enter')startGame();}); updateTopbar(); });
Tiro 0
· SPQR ·

LATINA VIVA

Palaestra latinae linguae

Master Latin morphology one stem at a time.

Semel tantum.

📖 Noun Declensions
⚡ Verb Conjugations
🏟 Review
+0

Read Latin

🏛

Leaderboard

My Class
Week
Month
Trimester
Year
All Time

Insignia

Progress Map

Locked
Released
In progress
≥80%
Perfect

Admin Panel

Nova Dignitas — Rank Achieved

Review Errors

Study these forms before your next drill.

Student Progress

NameClassScoreProgressBadgesLast Active
Loading…

Mūtā Nōmen · Change Settings

Your progress will be migrated to the new name on the server.

Enroll Students
QR Code
Urbs Rōmāna
Tap a building to open its module · Completed locations glow gold
Word Stats
WordDrilledErrorsNext DueEase
Etymology Chain