lingo
<!--
Mini “Duolingo” para Blogger (ES -> IS)
Pega TODO este bloque en una entrada/página de Blogger en modo HTML.
No requiere librerías externas. Guarda el progreso en localStorage.
--><div id="is-edu-app" class="is-app">
<header class="is-header" role="banner" aria-label="Cabecera de la app">
<div class="brand">
<span class="logo" aria-hidden="true">🇮🇸</span>
<h1>Aprende Islandés</h1>
</div>
<nav class="stats" aria-label="Estadísticas del usuario">
<div class="stat" title="Puntos de experiencia">
<span class="icon" aria-hidden="true">⭐</span>
<span id="xp">0</span> XP
</div>
<div class="stat" title="Racha diaria">
<span class="icon" aria-hidden="true">🔥</span>
<span id="streak">0</span> días
</div>
<div class="stat" title="Vidas">
<span class="icon" aria-hidden="true">💚</span>
<span id="lives">5</span>
</div>
</nav>
</header> <section class="tabs" role="tablist" aria-label="Secciones de aprendizaje">
<button class="tab active" data-tab="home" role="tab" aria-selected="true">Inicio</button>
<button class="tab" data-tab="lessons" role="tab" aria-selected="false">Lecciones</button>
<button class="tab" data-tab="vocab" role="tab" aria-selected="false">Vocabulario</button>
<button class="tab" data-tab="practice" role="tab" aria-selected="false">Práctica</button>
<button class="tab" data-tab="achievements" role="tab" aria-selected="false">Logros</button>
<button class="tab" data-tab="settings" role="tab" aria-selected="false">Ajustes</button>
</section> <main class="views">
<!-- INICIO -->
<section id="view-home" class="view active" aria-labelledby="Inicio">
<div class="hero">
<h2>Velkomin! 👋</h2>
<p>Curso gamificado para hispanohablantes. Lecciones cortas, audio nativo (TTS), racha diaria y XP.</p>
<button id="btn-start" class="btn primary">Empezar la primera lección</button>
</div>
<div class="grid two">
<article class="card">
<h3>Objetivo diario</h3>
<p>Completa al menos <strong>1 lección</strong> o gana <strong>30 XP</strong> para mantener la racha.</p>
<div class="progress">
<div id="daily-progress" class="bar"></div>
</div>
</article>
<article class="card">
<h3>Consejo rápido</h3>
<p>Haz clic en 🔊 para escuchar el islandés. Repite en voz alta para mejorar tu pronunciación.</p>
</article>
</div>
</section><!-- LECCIONES -->
<section id="view-lessons" class="view" aria-labelledby="Lecciones">
<h2>Ruta de aprendizaje (A1)</h2>
<div id="lessons-list" class="lesson-list"></div>
</section>
<!-- VOCABULARIO -->
<section id="view-vocab" class="view" aria-labelledby="Vocabulario">
<h2>Vocabulario</h2>
<p>Palabras y frases de las lecciones completadas. Pulsa 🔊 para escuchar.</p>
<div id="vocab-table" class="vocab"></div>
</section>
<!-- PRÁCTICA -->
<section id="view-practice" class="view" aria-labelledby="Práctica">
<h2>Práctica rápida</h2>
<p>Repaso de 5 preguntas aleatorias según lo aprendido.</p>
<div id="practice-area"></div>
<button id="btn-practice" class="btn primary">Iniciar práctica</button>
</section>
<!-- LOGROS -->
<section id="view-achievements" class="view" aria-labelledby="Logros">
<h2>Logros</h2>
<ul id="achievements-list" class="achievements"></ul>
</section>
<!-- AJUSTES -->
<section id="view-settings" class="view" aria-labelledby="Ajustes">
<h2>Ajustes</h2>
<label class="row">
<span>Audio TTS islandés (si está disponible):</span>
<button id="btn-tts-test" class="btn">Probar 🔊</button>
</label>
<label class="row">
<span>Reiniciar progreso (local):</span>
<button id="btn-reset" class="btn danger">Borrar todo</button>
</label>
</section>
</main> <!-- Modal de Lección --> <dialog id="lesson-modal" class="modal" aria-label="Lección">
<form method="dialog" class="modal-box">
<header class="modal-header">
<h3 id="lesson-title">Lección</h3>
<button class="btn ghost" value="cancel" aria-label="Cerrar">✖</button>
</header>
<div id="lesson-content" class="modal-content"></div>
<footer class="modal-actions">
<button id="lesson-prev" class="btn">⟨ Anterior</button>
<button id="lesson-next" class="btn primary">Siguiente ⟩</button>
</footer>
</form>
</dialog> <!-- Toast --> <div id="toast" class="toast" role="status" aria-live="polite"></div>
</div><style>
:root{
--bg:#0b1220; /* fondo oscuro elegante */
--panel:#111a2e;
--card:#162342;
--accent:#6ee7ff;
--accent-2:#7c4dff;
--ok:#22c55e;
--warn:#f59e0b;
--err:#ef4444;
--text:#e6f0ff;
--muted:#a0b3d1;
--shadow:0 12px 28px rgba(0,0,0,.35);
--radius:18px;
}
.is-app{font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; color:var(--text); background:linear-gradient(160deg,#0b1220 0%,#0d1630 50%,#0a1126 100%); padding:16px; border-radius:var(--radius); box-shadow:var(--shadow)}
.is-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 8px 16px 8px}
.brand{display:flex;align-items:center;gap:10px}
.brand .logo{font-size:28px}
.brand h1{margin:0;font-size:20px;letter-spacing:.3px}
.stats{display:flex;gap:10px}
.stat{background:var(--card);padding:8px 12px;border-radius:999px;display:flex;align-items:center;gap:6px;color:var(--muted);border:1px solid rgba(255,255,255,.06)}
.stat .icon{filter:drop-shadow(0 1px 0 rgba(0,0,0,.6))}
.tabs{display:flex;flex-wrap:wrap;gap:8px;padding:8px;border-top:1px solid rgba(255,255,255,.06);border-bottom:1px solid rgba(255,255,255,.06)}
.tab{background:transparent;color:var(--muted);border:1px solid rgba(255,255,255,.08);padding:8px 12px;border-radius:999px;cursor:pointer}
.tab.active{background:linear-gradient(135deg, var(--accent), var(--accent-2));color:#0b1220;border:none;font-weight:600}
.views{padding:12px}
.view{display:none}
.view.active{display:block}
.hero{background:var(--panel);border:1px solid rgba(255,255,255,.06);padding:16px;border-radius:var(--radius)}
.grid.two{display:grid;grid-template-columns:1fr;gap:12px;margin-top:12px}
@media(min-width:700px){.grid.two{grid-template-columns:1fr 1fr}}
.card{background:var(--card);border:1px solid rgba(255,255,255,.08);padding:16px;border-radius:var(--radius)}
.progress{height:10px;background:#0b1330;border-radius:999px;overflow:hidden;border:1px solid rgba(255,255,255,.08)}
.progress .bar{height:100%;width:0;background:linear-gradient(90deg,var(--accent),var(--accent-2));transition:width .5s ease}
.lesson-list{display:grid;grid-template-columns:1fr;gap:12px}
@media(min-width:900px){.lesson-list{grid-template-columns:repeat(2,1fr)}}
.lesson{display:flex;align-items:center;justify-content:space-between;gap:12px;background:var(--panel);border:1px solid rgba(255,255,255,.06);padding:14px;border-radius:var(--radius)}
.lesson .meta{display:flex;align-items:center;gap:10px}
.lesson .badge{font-size:20px}
.lesson .title{font-weight:600}
.lesson .desc{color:var(--muted);font-size:14px}
.lesson .actions .btn{margin-left:8px}
.vocab table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid rgba(255,255,255,.06);border-radius:12px;overflow:hidden}
.vocab th,.vocab td{padding:10px;border-bottom:1px solid rgba(255,255,255,.06)}
.vocab th{background:var(--panel);text-align:left;color:var(--muted)}
.vocab tr:hover td{background:rgba(255,255,255,.03)}
.btn{background:#1b2b52;color:var(--text);border:1px solid rgba(255,255,255,.12);padding:10px 14px;border-radius:12px;cursor:pointer;transition:transform .05s ease, filter .2s}
.btn:active{transform:translateY(1px)}
.btn.primary{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#0b1220;border:none;font-weight:700}
.btn.ghost{background:transparent;border:none;color:var(--muted)}
.btn.danger{background:#3a1531;border:1px solid #7f1d1d}
.modal{border:none;border-radius:16px;padding:0;background:transparent}
.modal::backdrop{background:rgba(5,10,30,.6)}
.modal-box{background:var(--panel);border:1px solid rgba(255,255,255,.08);border-radius:16px;max-width:820px;width:92vw;box-shadow:var(--shadow)}
.modal-header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.08)}
.modal-content{padding:16px}
.modal-actions{display:flex;justify-content:space-between;padding:12px 14px;border-top:1px solid rgba(255,255,255,.08)}
.q-card{background:var(--card);border:1px solid rgba(255,255,255,.08);padding:14px;border-radius:14px;margin-bottom:10px}
.choices{display:grid;grid-template-columns:1fr;gap:8px;margin-top:8px}
@media(min-width:600px){.choices{grid-template-columns:1fr 1fr}}
.choice{padding:10px;border:1px solid rgba(255,255,255,.12);border-radius:10px;background:#0f1a34;cursor:pointer}
.choice.correct{border-color:#14532d;background:#0d2518}
.choice.wrong{border-color:#7f1d1d;background:#2a0f13}
.toast{position:fixed;inset:auto 16px 16px auto;background:#0f1a34;border:1px solid rgba(255,255,255,.12);padding:10px 12px;border-radius:12px;opacity:0;transform:translateY(8px);transition:all .25s ease;pointer-events:none}
.toast.show{opacity:1;transform:translateY(0)}
.row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:10px 0}
</style><script>
(function(){
const APP_KEY = 'islandic_es_app_v1';
const state = loadState() || {
xp: 0,
lives: 5,
streak: 0,
lastVisit: null,
unlockedLessons: { 'A1-1': true },
completedLessons: {},
vocabKnown: {}, // { token: {es, is, ipa?, seen: n, correct: n} }
settings: { tts: true }
};
const $$ = (sel, root=document) => root.querySelector(sel);
const $$$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
// --- CONTENIDO INICIAL (puedes editar/añadir) ---
const COURSE = [
{
id: 'A1-1', level: 'A1', icon: '👋', title: 'Saludos',
desc: 'Presentaciones básicas y cortesía.',
items: [
{es:'Hola', is:'Halló', type:'pair'},
{es:'Adiós', is:'Bless', type:'pair'},
{es:'Por favor', is:'Vinsamlegast', type:'pair'},
{es:'Gracias', is:'Takk', type:'pair'},
{es:'Sí', is:'Já', type:'pair'},
{es:'No', is:'Nei', type:'pair'},
],
quiz: [
{kind:'mc', q:'¿Cómo se dice «Gracias» en islandés?', answers:['Takk','Halló','Bless','Vinsamlegast'], ok:0, speak:'Takk'},
{kind:'pair', q:'Empareja saludo con su traducción.', pairs:[['Hola','Halló'],['Adiós','Bless']]},
{kind:'type', q:'Escribe en islandés: «Por favor»', ok:'Vinsamlegast'},
]
},
{
id: 'A1-2', level: 'A1', icon: '🔢', title: 'Números 1–10',
desc: 'Contar del 1 al 10.',
req: ['A1-1'],
items: [
{es:'uno', is:'einn'}, {es:'dos', is:'tveir'}, {es:'tres', is:'þrír'},
{es:'cuatro', is:'fjórir'}, {es:'cinco', is:'fimm'}, {es:'seis', is:'sex'},
{es:'siete', is:'sjö'}, {es:'ocho', is:'átta'}, {es:'nueve', is:'níu'}, {es:'diez', is:'tíu'},
],
quiz: [
{kind:'mc', q:'¿Cuál es «cuatro» en islandés?', answers:['fimm','fjórir','tíu','níu'], ok:1, speak:'fjórir'},
{kind:'select', q:'Selecciona la traducción de «tres».', options:['tveir','þrír','sjö','sex'], ok:1},
{kind:'type', q:'Escribe en islandés: «ocho»', ok:'átta'},
]
},
{
id: 'A1-3', level: 'A1', icon: '🎨', title: 'Colores',
desc: 'Colores comunes.', req:['A1-1'],
items: [
{es:'rojo', is:'rauður'}, {es:'azul', is:'blár'}, {es:'verde', is:'grænn'},
{es:'amarillo', is:'gulur'}, {es:'negro', is:'svartur'}, {es:'blanco', is:'hvítur'},
],
quiz: [
{kind:'mc', q:'¿Cómo se dice «azul»?', answers:['blár','grænn','gulur','svartur'], ok:0, speak:'blár'},
{kind:'type', q:'Escribe en islandés: «rojo»', ok:'rauður'},
{kind:'mc', q:'¿Cuál es «negro»?', answers:['hvítur','svartur','gulur','grænn'], ok:1},
]
}
];
// --- INIT ---
renderStats();
updateStreak();
updateDailyProgress();
renderLessons();
renderVocab();
renderAchievements();
wireTabs();
$$('#btn-start').addEventListener('click', ()=> openLesson('A1-1'));
$$('#btn-practice').addEventListener('click', startPractice);
$$('#btn-reset').addEventListener('click', resetAll);
$$('#btn-tts-test').addEventListener('click', ()=> speak('Halló! Þetta er próf.', 'is-IS'));
// --- CORE FUNCTIONS ---
function renderStats(){
$$('#xp').textContent = state.xp|0;
$$('#streak').textContent = state.streak|0;
$$('#lives').textContent = state.lives|0;
saveState();
}
function updateStreak(){
const today = new Date();
const todayKey = today.toDateString();
if(!state.lastVisit){ state.lastVisit = todayKey; state.streak = 1; }
else if(state.lastVisit !== todayKey){
const prev = new Date(state.lastVisit);
const diff = (today - prev)/(1000*60*60*24);
state.streak = (diff <= 2) ? (state.streak+1) : 1; // tolerancia de 1 día
state.lastVisit = todayKey;
}
renderStats();
}
function updateDailyProgress(){
const target = 30; // XP objetivo diario
const pct = Math.min(100, (state.xp % target) / target * 100);
$$('#daily-progress').style.width = pct + '%';
}
function renderLessons(){
const list = $$('#lessons-list');
list.innerHTML = '';
COURSE.forEach(lesson => {
const locked = lesson.req && !lesson.req.every(id => state.completedLessons[id]);
const done = !!state.completedLessons[lesson.id];
const el = document.createElement('div');
el.className = 'lesson';
el.innerHTML = `
<div class="meta">
<span class="badge">${lesson.icon}</span>
<div>
<div class="title">${lesson.title} <small style="color:var(--muted)">(${lesson.level})</small></div>
<div class="desc">${lesson.desc}</div>
</div>
</div>
<div class="actions">
${locked ? '<span title="Completa requisitos">🔒</span>' : ''}
${done ? '<span title="Completado">✅</span>' : ''}
<button class="btn" ${locked? 'disabled':''} data-open="${lesson.id}">Abrir</button>
</div>`;
list.appendChild(el);
});
$$$('[data-open]').forEach(b => b.addEventListener('click', e => openLesson(e.currentTarget.getAttribute('data-open'))));
}
function openLesson(id){
const lesson = COURSE.find(l => l.id === id);
if(!lesson) return;
const modal = $$('#lesson-modal');
$$('#lesson-title').textContent = `${lesson.icon} ${lesson.title}`;
const content = $$('#lesson-content');
// Construir contenido (teoría + quiz)
const theory = document.createElement('div');
theory.innerHTML = `<h4>Vocabulario</h4>`;
lesson.items.forEach(item => {
const row = document.createElement('div');
row.className = 'q-card';
row.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<div><strong>${item.is}</strong> — <span style="color:var(--muted)">${item.es}</span></div>
<button class="btn" aria-label="Escuchar ${item.is}">🔊</button>
</div>`;
row.querySelector('button').addEventListener('click', ()=> speak(item.is, 'is-IS'));
theory.appendChild(row);
// registrar vocab
const key = tokenKey(item);
if(!state.vocabKnown[key]) state.vocabKnown[key] = {...item, seen:0, correct:0};
});
const quiz = document.createElement('div');
quiz.innerHTML = `<h4>Quiz</h4>`;
let qIndex = 0;
function renderQuestion(){
quiz.querySelectorAll('.q-card').forEach(n => n.remove());
const q = lesson.quiz[qIndex];
if(!q){
// lección terminada
state.completedLessons[lesson.id] = true;
addXP(30);
showToast('Lección completada: +30 XP 🎉');
saveState();
renderLessons();
renderVocab();
renderAchievements();
modal.close();
return;
}
const card = document.createElement('div');
card.className = 'q-card';
card.innerHTML = `<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<div>${q.q}</div>
${q.speak ? '<button class="btn" aria-label="Escuchar">🔊</button>' : ''}
</div>`;
if(q.speak){ card.querySelector('button').addEventListener('click', ()=> speak(q.speak,'is-IS')); }
if(q.kind === 'mc' || q.kind === 'select'){
const box = document.createElement('div');
box.className = 'choices';
q.answers = q.answers || q.options;
q.answers.forEach((ans, i)=>{
const b = document.createElement('button');
b.type = 'button';
b.className = 'choice';
b.textContent = ans;
b.addEventListener('click', ()=> {
const correct = i === q.ok;
b.classList.add(correct? 'correct':'wrong');
if(correct){
addXP(10); markVocab(ans); showToast('+10 XP ✅'); next();
} else { loseLife(); showToast('Fallaste ❌'); }
});
box.appendChild(b);
});
card.appendChild(box);
}
else if(q.kind === 'type'){
const input = document.createElement('input');
input.className = 'choice';
input.placeholder = 'Escribe aquí…';
input.setAttribute('inputmode','latin');
const okBtn = document.createElement('button');
okBtn.className = 'btn primary';
okBtn.textContent = 'Comprobar';
okBtn.addEventListener('click', ()=>{
const val = (input.value || '').trim();
const correct = normalize(val) === normalize(q.ok);
input.classList.add(correct? 'correct' : 'wrong');
if(correct){ addXP(10); markVocab(q.ok); showToast('+10 XP ✅'); next(); }
else { loseLife(); showToast(`Respuesta: ${q.ok}`); }
});
card.appendChild(input); card.appendChild(document.createElement('br')); card.appendChild(okBtn);
}
else if(q.kind === 'pair'){
// emparejar simple por clics
const left = q.pairs.map(p=>p[0]);
const right = shuffle(q.pairs.map(p=>p[1]));
const box = document.createElement('div');
box.className = 'choices';
const selected = {l:null, r:null};
const chosen = new Set();
left.forEach(l => {
const b = document.createElement('button'); b.type='button'; b.className='choice'; b.textContent=l; b.dataset.side='l';
b.addEventListener('click', ()=> pick(b)); box.appendChild(b);
});
right.forEach(r => {
const b = document.createElement('button'); b.type='button'; b.className='choice'; b.textContent=r; b.dataset.side='r';
b.addEventListener('click', ()=> pick(b)); box.appendChild(b);
});
function pick(btn){
const side = btn.dataset.side;
if(side==='l') selected.l = btn; else selected.r = btn;
btn.classList.add('selected');
if(selected.l && selected.r){
const l = selected.l.textContent; const r = selected.r.textContent;
const ok = q.pairs.some(p=>p[0]===l && p[1]===r);
if(ok){
selected.l.classList.add('correct'); selected.r.classList.add('correct');
chosen.add(l);
addXP(5); markVocab(r); showToast('+5 XP ✅');
if(chosen.size === left.length){ next(); }
} else {
selected.l.classList.add('wrong'); selected.r.classList.add('wrong');
loseLife(); showToast('No coincide ❌');
}
selected.l = selected.r = null;
}