Visual English Flashcards

Click the card to flip, see the word and hear its pronunciation. Use I Know This to fill stars. After 10 stars a word becomes

Card: 1/0
Word Image
👆Click
Word Image
Learned 0 / 0
const STORAGE_KEYS = { progress: 'fc_progress_v1', learned: 'fc_learned_v1' }; let progressCounts = JSON.parse(localStorage.getItem(STORAGE_KEYS.progress) || '{}'); // {cat: {Word: n}} let learnedMeta = JSON.parse(localStorage.getItem(STORAGE_KEYS.learned) || '{}'); // {"cat::Word": {word,category}} function saveState(){ localStorage.setItem(STORAGE_KEYS.progress, JSON.stringify(progressCounts)); localStorage.setItem(STORAGE_KEYS.learned, JSON.stringify(learnedMeta)); } // ---------- State ---------- let manifest = null; let category = 'animals'; let words = []; // [{word, image, category}] let currentIndex = 0; let repeatMode = false; let repeatIndex = 0; // ---------- DOM ---------- const flashcard = document.getElementById('flashcard'); const cardImage = document.getElementById('cardImage'); const cardBackImage = document.getElementById('cardBackImage'); const wordDisplay = document.getElementById('wordDisplay'); const currentElement = document.getElementById('current'); const totalElement = document.getElementById('total'); const progressBar = document.getElementById('progressBar'); const nextBtn = document.getElementById('nextBtn'); const knowBtn = document.getElementById('knowBtn'); const repeatBtn = document.getElementById('repeatBtn'); const learnedList = document.getElementById('learnedList'); const categorySelect = document.getElementById('categorySelect'); const learnedCountEl = document.getElementById('learnedCount'); const totalCountEl = document.getElementById('totalCount'); const starsEl = document.getElementById('stars'); // ---------- Utils ---------- function toTitle(str){ return str.replace(/[_-]+/g,' ').replace(/\s+/g,' ').trim().replace(/\b\w/g, c=>c.toUpperCase()); } function learntKey(cat, word){ return `${cat}::${word}`; } function getProgress(cat, word){ return (progressCounts[cat] && progressCounts[cat][word]) || 0; } function setProgress(cat, word, value){ if(!progressCounts[cat]) progressCounts[cat] = {}; progressCounts[cat][word] = value; saveState(); } async function imgExists(url){ return new Promise(resolve => { const i = new Image(); i.onload=()=>resolve(true); i.onerror=()=>resolve(false); i.src = url + '?_=' + Date.now(); }); } async function resolveImagePath(cat, stem){ const base = `images/${cat}/`; const s = stem.toLowerCase().replace(/\s+/g,'_'); const exts = ['jpg','jpeg','png','webp']; for(const ext of exts){ const url = `${base}${s}.${ext}`; if(await imgExists(url)) return url; } return `${base}${s}.jpg`; // best guess } async function loadManifest(){ try{ const res = await fetch(MANIFEST_URL, {cache:'no-store'}); if(!res.ok) return null; return await res.json(); }catch(e){ return null; } } async function listCategoryDirectory(cat){ // Try to parse directory listing if server allows try{ const res = await fetch(`images/${cat}/`, {cache:'no-store'}); if(!res.ok) return []; const html = await res.text(); const files = []; const rx = /href\s*=\s*"([^"?]+)"/gi; let m; const allowed = ['.jpg','.jpeg','.png','.webp']; while((m = rx.exec(html))){ const href = m[1]; const name = href.split('/').pop(); if(allowed.some(ext => name.toLowerCase().endsWith(ext))) files.push(name); } return files; } catch(e){ return []; } } function populateCategorySelect(categories){ categorySelect.innerHTML = ''; categories.forEach(cat => { const opt = document.createElement('option'); opt.value = cat; opt.textContent = toTitle(cat); categorySelect.appendChild(opt); }); if(categories.includes('animals')) categorySelect.value = 'animals'; category = categorySelect.value; } // ---------- Stars UI ---------- function renderStars(word){ starsEl.innerHTML = ''; const count = getProgress(category, word) || 0; for(let i=1;i<=10;i++){ const s = document.createElement('span'); s.className = 'star' + (i <= count ? ' filled' : ''); s.textContent = '★'; starsEl.appendChild(s); } } // ---------- Learned UI ---------- function getLearnedWordsInCurrentCategory(){ return Object.keys(learnedMeta) .filter(k => k.startsWith(category + '::')) .map(k => learnedMeta[k].word); } function updateLearnedUI(popWord=null){ // Badge shows learned count for current category const learnedInCurrent = getLearnedWordsInCurrentCategory(); learnedCountEl.textContent = learnedInCurrent.length; totalCountEl.textContent = words.length; // Learned chips (all categories) learnedList.innerHTML = ''; Object.keys(learnedMeta).forEach(key => { const {word, category:cat} = learnedMeta[key]; const li = document.createElement('li'); const chip = document.createElement('span'); chip.className = `chip ${['animals','fruits','household','food'].includes(cat) ? cat : 'default'}`; chip.innerHTML = `${word}`; li.appendChild(chip); learnedList.appendChild(li); if(popWord && popWord===word){ chip.classList.add('pop'); setTimeout(()=>chip.classList.remove('pop'), 650); } }); } function flyWordToLearned(wordText){ const source = flashcard.getBoundingClientRect(); const target = learnedList.getBoundingClientRect(); const label = document.createElement('div'); label.className = 'flying-label'; label.textContent = wordText; const startX = source.left + source.width/2; const startY = source.top + 40; label.style.left = startX + 'px'; label.style.top = startY + 'px'; const endX = target.left + target.width/2; const endY = target.top + 10; label.style.setProperty('--tx', (endX - startX) + 'px'); label.style.setProperty('--ty', (endY - startY) + 'px'); label.style.animation = 'flyToTarget 700ms ease-in forwards'; document.body.appendChild(label); setTimeout(()=> label.remove(), 750); } // ---------- Core display ---------- function updateProgressBar(){ const curr = repeatMode ? repeatIndex : currentIndex; const total = repeatMode ? getLearnedWordsInCurrentCategory().length : words.length; const progress = total ? ((curr+1)/total)*100 : 0; progressBar.style.width = `${progress}%`; } function getCurrentWord(){ if(repeatMode){ const list = getLearnedWordsInCurrentCategory(); if(!list.length) return null; return list[repeatIndex % list.length]; } else { return words[currentIndex]?.word || null; } } function getItemByWord(word){ return words.find(w=>w.word===word) || null; } function speak(text){ const utter = new SpeechSynthesisUtterance(text); utter.lang = 'en-US'; utter.rate = 0.9; speechSynthesis.speak(utter); } function showCard(index){ const word = getCurrentWord(); const item = word ? getItemByWord(word) : words[index]; if(!item) return; flashcard.classList.remove('flipped'); cardImage.src = item.image; cardBackImage.src = item.image; wordDisplay.textContent = ''; if(repeatMode){ currentElement.textContent = (repeatIndex+1); totalElement.textContent = getLearnedWordsInCurrentCategory().length; } else { currentElement.textContent = (currentIndex+1); totalElement.textContent = words.length; } updateProgressBar(); setTimeout(()=>{ wordDisplay.textContent = ''; }, 150); renderStars(item.word); } // ---------- Events ---------- categorySelect.addEventListener('change', async () => { category = categorySelect.value; currentIndex = 0; repeatIndex = 0; repeatMode = false; repeatBtn.classList.remove('active'); repeatBtn.setAttribute('aria-pressed','false'); repeatBtn.textContent='Repeat Again'; await loadCategoryWords(category); updateLearnedUI(); showCard(currentIndex); }); flashcard.addEventListener('click', ()=>{ if(!flashcard.classList.contains('flipped')){ flashcard.classList.add('flipped'); const w = getCurrentWord(); if(!w) return; const item = getItemByWord(w); if(!item) return; wordDisplay.textContent = item.word; wordDisplay.classList.remove('typewriter'); void wordDisplay.offsetWidth; wordDisplay.classList.add('typewriter'); speak(item.word); } }); function goNext(){ if(repeatMode){ const list = getLearnedWordsInCurrentCategory(); if(!list.length) return; repeatIndex = (repeatIndex+1) % list.length; showCard(repeatIndex); } else { currentIndex = (currentIndex+1) % words.length; showCard(currentIndex); } } nextBtn.addEventListener('click', goNext); knowBtn.addEventListener('click', async () => { const w = getCurrentWord(); if(!w) return; const item = getItemByWord(w); if(!item) return; const curr = getProgress(item.category, item.word) + 1; setProgress(item.category, item.word, Math.min(curr,10)); starsEl.classList.add('bump'); setTimeout(()=>starsEl.classList.remove('bump'), 350); renderStars(item.word); if(getProgress(item.category, item.word) === 10 && !learnedMeta[learntKey(item.category, item.word)]){ learnedMeta[learntKey(item.category, item.word)] = {word:item.word, category:item.category}; saveState(); flyWordToLearned(item.word); updateLearnedUI(item.word); } else { updateLearnedUI(); } goNext(); }); repeatBtn.addEventListener('click', () => { const learnedInCurrent = getLearnedWordsInCurrentCategory(); if(!repeatMode){ if(learnedInCurrent.length === 0){ repeatBtn.style.filter='grayscale(0.8)'; repeatBtn.textContent='No learned words yet'; setTimeout(()=>{ repeatBtn.style.filter=''; repeatBtn.textContent='Repeat Again'; }, 900); return; } repeatMode = true; repeatBtn.classList.add('active'); repeatBtn.setAttribute('aria-pressed','true'); repeatBtn.textContent='Repeat: Learned ✓'; repeatIndex = 0; showCard(repeatIndex); } else { repeatMode = false; repeatBtn.classList.remove('active'); repeatBtn.setAttribute('aria-pressed','false'); repeatBtn.textContent='Repeat Again'; showCard(currentIndex); } }); document.addEventListener('keydown', (e) => { if(e.key === 'ArrowRight'){ goNext(); } else if(e.key === ' '){ flashcard.click(); } else if(e.key.toLowerCase() === 'r'){ repeatBtn.click(); } else if(e.key.toLowerCase() === 'k'){ knowBtn.click(); } }); // ---------- Data loading ---------- async function loadCategoryWords(cat){ words.length = 0; let filenames = []; if(manifest && Array.isArray(manifest[cat])){ filenames = manifest[cat]; } else { filenames = await listCategoryDirectory(cat); } if(filenames.length){ words = filenames.map(fn => { const stem = fn.replace(/\.[^/.]+$/, ''); return { word: toTitle(stem), image: `images/${cat}/${fn}`, category: cat }; }); } else { // Fallback to predefined stems and resolve extensions dynamically const stems = FALLBACKS[cat] || []; const resolved = await Promise.all(stems.map(async (stem) => { const img = await resolveImagePath(cat, stem); return { word: toTitle(stem), image: img, category: cat }; })); words = resolved; } totalElement.textContent = words.length; totalCountEl.textContent = words.length; currentIndex = 0; renderStars(words[0]?.word || ''); } async function init(){ manifest = await loadManifest(); const categories = manifest ? Object.keys(manifest) : Object.keys(FALLBACKS); populateCategorySelect(categories); await loadCategoryWords(category); updateLearnedUI(); showCard(currentIndex); } init();