From bab625a670adf9cab90f51a688e177453d94fc96 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 23 Aug 2026 09:07:07 +0000 Subject: [PATCH] Initial commit: Portugais App --- .gitignore | 4 + Dockerfile | 10 + README.md | 46 +++ docker-compose.yml | 16 + public/app.js | 847 +++++++++++++++++++++++++++++++++++++ public/data.js | 851 ++++++++++++++++++++++++++++++++++++++ public/icons/icon-192.svg | 1 + public/icons/icon-512.svg | 1 + public/index.html | 273 ++++++++++++ public/login.html | 71 ++++ public/manifest.json | 14 + public/style.css | 256 ++++++++++++ public/sw.js | 5 + server/package.json | 15 + server/server.js | 228 ++++++++++ 15 files changed, 2638 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 public/app.js create mode 100644 public/data.js create mode 100644 public/icons/icon-192.svg create mode 100644 public/icons/icon-512.svg create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/manifest.json create mode 100644 public/style.css create mode 100644 public/sw.js create mode 100644 server/package.json create mode 100644 server/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cc70cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/data/ +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..680b443 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine +RUN apk add --no-cache python3 make g++ +WORKDIR /app +COPY server/package.json . +RUN npm install --production +COPY server/ ./server/ +COPY public/ ./public/ +RUN mkdir -p /data +EXPOSE 3007 +CMD ["node", "server/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d222cb --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Portugais - Application d'Apprentissage + +Application gamifiée d'apprentissage du portugais : utilisateurs, points, activités, administration, avatars emoji. SQLite en mode WAL. + +## Stack technique + +| Composant | Technologie | +|-----------|------------| +| Runtime | Node.js 20 | +| Framework | Express | +| Base de données | SQLite (better-sqlite3, WAL mode) | +| Authentification | JWT (jsonwebtoken) + bcryptjs | +| Frontend | HTML/CSS/JS statique (public/) | + +## Déploiement + +```bash +# Container Docker +docker compose up -d portugais +``` + +Accessible sur le port **3007**. + +## Structure + +``` +/opt/web/portugais-app/ +├── server.js # Point d'entrée Express +├── public/ # Frontend statique +└── data/ + └── portugais.db # Base SQLite (volume Docker) +``` + +## Variables d'environnement + +| Variable | Description | Valeur | +|----------|-------------|--------| +| `PORT` | Port du serveur | `3007` | +| `JWT_SECRET` | Secret JWT | `portugais-super-secret-2026` | +| `ADMIN_PASSWORD` | Mot de passe admin | `Pierre2026!` | + +## Domaine de production + +- **URL** : https://portugal.secourit.fr +- **LXC** : 107 (IP 10.10.10.24) +- **SSL** : Let's Encrypt via Nginx Proxy Manager diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c5fa1da --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + portugais: + build: . + container_name: portugais-app + restart: unless-stopped + ports: + - "3007:3007" + volumes: + - portugais-data:/data + environment: + - JWT_SECRET=portugais-super-secret-2026 + - ADMIN_PASSWORD=Pierre2026! + - PORT=3007 +volumes: + portugais-data: diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..915d580 --- /dev/null +++ b/public/app.js @@ -0,0 +1,847 @@ +// ===== AUTH ===== +const token = localStorage.getItem('pt_token'); +const user = JSON.parse(localStorage.getItem('pt_user') || 'null'); +if (!token || !user) { window.location.href = '/login.html'; } + +const API = { + headers() { return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }; }, + async get(url) { const r = await fetch(url, { headers: this.headers() }); if (r.status === 401) { localStorage.clear(); window.location.href = '/login.html'; } return r.json(); }, + async post(url, body) { const r = await fetch(url, { method: 'POST', headers: this.headers(), body: JSON.stringify(body) }); if (r.status === 401) { localStorage.clear(); window.location.href = '/login.html'; } return r.json(); }, + async del(url) { const r = await fetch(url, { method: 'DELETE', headers: this.headers() }); return r.json(); }, + async put(url) { const r = await fetch(url, { method: 'PUT', headers: this.headers() }); return r.json(); }, +}; + +document.getElementById('app').classList.remove('hidden'); +document.getElementById('header-avatar').textContent = user.avatar; +if (user.is_admin) document.getElementById('nav-admin').style.display = ''; + +document.getElementById('btn-logout').addEventListener('click', () => { + localStorage.clear(); + window.location.href = '/login.html'; +}); + +// ===== TOAST ===== +function showToast(msg) { + const t = document.getElementById('toast'); + t.textContent = msg; + t.classList.remove('hidden'); + clearTimeout(t._timeout); + t._timeout = setTimeout(() => t.classList.add('hidden'), 2000); +} + +// ===== CONFETTI ===== +function celebrate() { + const c = document.createElement('div'); + c.className = 'celebration'; + document.body.appendChild(c); + const colors = ['#006600', '#FF0000', '#FFD700', '#FFFFFF', '#FF69B4']; + for (let i = 0; i < 50; i++) { + const d = document.createElement('div'); + d.className = 'confetti'; + d.style.left = Math.random() * 100 + '%'; + d.style.background = colors[Math.floor(Math.random() * colors.length)]; + d.style.animationDelay = Math.random() * 0.5 + 's'; + d.style.animationDuration = (1 + Math.random()) + 's'; + c.appendChild(d); + } + setTimeout(() => c.remove(), 3000); +} + +// ===== POINTS ===== +async function addPoints(points, activity, type) { + const data = await API.post('/api/points/add', { points, activity, activity_type: type }); + document.getElementById('header-points-val').textContent = data.total_points; +} + +async function loadMyPoints() { + const data = await API.get('/api/points/me'); + document.getElementById('header-points-val').textContent = data.total_points; + return data; +} + +// ===== AUDIO ===== +const SpeechEngine = { + speak(text, lang = 'pt-PT', rate = 0.9) { + if (!('speechSynthesis' in window)) return; + window.speechSynthesis.cancel(); + const u = new SpeechSynthesisUtterance(text); + u.lang = lang; u.rate = rate; u.pitch = 1; + const voices = window.speechSynthesis.getVoices(); + const ptMatch = voices.find(v => v.lang === 'pt-PT'); + const ptFallback = voices.find(v => v.lang.startsWith('pt-PT')); + const ptAny = voices.find(v => v.lang.startsWith('pt')); + const match = ptMatch || ptFallback || ptAny; + if (match) u.voice = match; + window.speechSynthesis.speak(u); + }, + speakPT(t) { this.speak(t, 'pt-PT', 0.85); }, + speakPTSlow(t) { this.speak(t, 'pt-PT', 0.55); }, + speakFR(t) { this.speak(t, 'fr-FR', 0.9); } +}; + +// ===== NAV ===== +let currentTab = 'dashboard'; +const tabs = document.querySelectorAll('.tab'); +const navBtns = document.querySelectorAll('.nav-btn[data-tab]'); + +function switchTab(id) { + currentTab = id; + tabs.forEach(t => t.classList.toggle('active', t.id === `tab-${id}`)); + navBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === id)); + window.scrollTo(0, 0); +} + +navBtns.forEach(b => b.addEventListener('click', () => switchTab(b.dataset.tab))); + +// ===== DASHBOARD ===== +async function loadDashboard() { + const [points, leaderboard, history] = await Promise.all([ + API.get('/api/points/me'), + API.get('/api/points/leaderboard'), + API.get('/api/points/history') + ]); + + document.getElementById('dashboard-welcome').innerHTML = ` +
+

Ola ${user.display_name}! 👋

+

Pretes aprender portugues hoje?

+
+ `; + document.getElementById('dash-total-points').textContent = points.total_points; + document.getElementById('dash-streak').textContent = points.today?.streak || 0; + document.getElementById('dash-today-points').textContent = points.today?.points_earned || 0; + document.getElementById('dash-today-exercises').textContent = points.today?.exercises_done || 0; + + // Leaderboard + const lb = document.getElementById('dash-leaderboard'); + if (leaderboard.leaderboard.length === 0) { + lb.innerHTML = '

Aucun classement pour le moment

'; + } else { + const medals = ['🥇', '🥈', '🥉']; + lb.innerHTML = leaderboard.leaderboard.map((u, i) => ` +
+ ${medals[i] || (i + 1)} + ${u.avatar} + ${u.display_name} + ⭐ ${u.total_points} +
+ `).join(''); + } + + // History chart + const hist = document.getElementById('dash-history'); + const days = history.history.slice(0, 7).reverse(); + if (days.length === 0) { + hist.innerHTML = '

Commence à jouer pour voir ton activité !

'; + } else { + const maxPts = Math.max(...days.map(d => d.points_earned), 1); + hist.innerHTML = `
+ ${days.map(d => { + const h = Math.max((d.points_earned / maxPts) * 100, 4); + const day = new Date(d.date).toLocaleDateString('fr-FR', { weekday: 'short' }); + return `
${day}
${d.points_earned}
`; + }).join('')} +
`; + } +} + +// ===== PHRASES ===== +let currentPhraseCategory = Object.keys(PHRASES)[0]; +let userFavorites = []; + +async function loadFavorites() { + const data = await API.get('/api/favorites'); + userFavorites = data.favorites; +} + +function renderPhraseCategories() { + const c = document.getElementById('phrase-categories'); + c.innerHTML = Object.keys(PHRASES).map(k => + `` + ).join(''); + c.querySelectorAll('.cat-tab').forEach(b => b.addEventListener('click', () => { + currentPhraseCategory = b.dataset.cat; + renderPhraseCategories(); + renderPhrases(); + })); +} + +function renderPhrases() { + const list = document.getElementById('phrases-list'); + const phrases = PHRASES[currentPhraseCategory] || []; + list.innerHTML = phrases.map(p => { + const isFav = userFavorites.includes(p.pt); + return `
+
${p.pt}
+
${p.phonetic}
+
${p.fr}
+
+ + + +
+
`; + }).join(''); + + list.querySelectorAll('.btn-listen').forEach(b => b.addEventListener('click', () => SpeechEngine.speakPT(b.dataset.text))); + list.querySelectorAll('.btn-slow').forEach(b => b.addEventListener('click', () => SpeechEngine.speakPTSlow(b.dataset.text))); + list.querySelectorAll('.btn-favorite').forEach(b => b.addEventListener('click', async () => { + const data = await API.post('/api/favorites/toggle', { phrase: b.dataset.text }); + await loadFavorites(); + renderPhrases(); + showToast(data.favorited ? 'Ajouté aux favoris ❤️' : 'Retiré des favoris'); + })); +} + +// ===== TRANSLATOR ===== +let fromLang = 'pt', toLang = 'fr'; + +document.getElementById('swap-lang').addEventListener('click', () => { + [fromLang, toLang] = [toLang, fromLang]; + document.getElementById('lang-from').textContent = fromLang === 'pt' ? '🇵🇹 Portugais' : '🇫🇷 Français'; + document.getElementById('lang-to').textContent = toLang === 'pt' ? '🇵🇹 Portugais' : '🇫🇷 Français'; + const i = document.getElementById('input-text').value; + const o = document.getElementById('output-text').textContent; + if (o) { document.getElementById('input-text').value = o; document.getElementById('output-text').textContent = i; } +}); + +document.getElementById('translate-btn').addEventListener('click', async () => { + const text = document.getElementById('input-text').value.trim(); + if (!text) return; + const out = document.getElementById('output-text'); + out.textContent = 'Traduction...'; + try { + const r = await fetch(`https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${fromLang === 'pt' ? 'pt-PT' : 'fr'}|${toLang === 'pt' ? 'pt-PT' : 'fr'}`); + const d = await r.json(); + out.textContent = d.responseData.translatedText; + } catch { out.textContent = 'Erreur de traduction'; } +}); + +document.getElementById('listen-input').addEventListener('click', () => { + const t = document.getElementById('input-text').value.trim(); + if (t) fromLang === 'pt' ? SpeechEngine.speakPT(t) : SpeechEngine.speakFR(t); +}); +document.getElementById('listen-output').addEventListener('click', () => { + const t = document.getElementById('output-text').textContent.trim(); + if (t) toLang === 'pt' ? SpeechEngine.speakPT(t) : SpeechEngine.speakFR(t); +}); +document.getElementById('clear-input').addEventListener('click', () => { + document.getElementById('input-text').value = ''; + document.getElementById('output-text').textContent = ''; +}); +document.getElementById('copy-output').addEventListener('click', () => { + const t = document.getElementById('output-text').textContent; + if (t) { navigator.clipboard.writeText(t); showToast('Copié !'); } +}); + +// ===== GAMES ===== +let gameState = null; + +document.querySelectorAll('.game-card[data-game]').forEach(c => c.addEventListener('click', () => startGame(c.dataset.game))); +document.getElementById('back-to-games').addEventListener('click', () => { + document.getElementById('game-area').classList.add('hidden'); + document.getElementById('back-to-games').classList.add('hidden'); + document.querySelector('#tab-games .game-menu').classList.remove('hidden'); + gameState = null; +}); + +function startGame(type) { + document.querySelector('#tab-games .game-menu').classList.add('hidden'); + const area = document.getElementById('game-area'); + document.getElementById('back-to-games').classList.remove('hidden'); + area.classList.remove('hidden'); + area.innerHTML = ''; + gameState = { type, score: 0, total: 0 }; + if (type === 'colors') startColorsGame(area); + else if (type === 'animals') startAnimalsGame(area); + else if (type === 'numbers') startNumbersGame(area); + else if (type === 'memory') startMemoryGame(area); + else if (type === 'quiz') startQuizGame(area); + else if (type === 'dragdrop') startDragDropGame(area); + else if (type === 'scramble') startScrambleGame(area); + else if (type === 'bingo') startBingoGame(area); + else if (type === 'catch') startCatchGame(area); + else if (type === 'riddle') startRiddleGame(area); +} + +function gameRound(area, questionHTML, options, getCorrectPt, onCorrect) { + area.innerHTML = `
${questionHTML}
${options.map(o => ``).join('')}
`; + area.querySelectorAll('.option-btn').forEach(btn => { + btn.addEventListener('click', () => { + gameState.total++; + if (btn.dataset.val === getCorrectPt()) { + gameState.score += 10; + btn.classList.add('correct'); + onCorrect(); + celebrate(); + } else { + btn.classList.add('wrong'); + } + area.querySelectorAll('.option-btn').forEach(b => b.style.pointerEvents = 'none'); + addPoints(btn.classList.contains('correct') ? 10 : 0, gameState.type, 'game'); + setTimeout(() => gameRound(...arguments), 1200); + }); + }); +} + +function startColorsGame(area) { + (function round() { + const correct = COLORS_GAME[Math.floor(Math.random() * COLORS_GAME.length)]; + let opts = [correct]; + while (opts.length < 4) { const c = COLORS_GAME[Math.floor(Math.random() * COLORS_GAME.length)]; if (!opts.find(o => o.pt === c.pt)) opts.push(c); } + opts.sort(() => Math.random() - 0.5); + const scoreHTML = `

Quelle est cette couleur ?

${gameState.score}
Points
`; + area.innerHTML = `
${scoreHTML}
${opts.map(o => ``).join('')}
`; + area.querySelectorAll('.option-btn').forEach(btn => btn.addEventListener('click', () => { + gameState.total++; + if (btn.dataset.pt === correct.pt) { gameState.score += 10; btn.classList.add('correct'); SpeechEngine.speakPT(correct.pt); showToast(`✅ ${correct.pt} = ${correct.fr}`); celebrate(); } + else { btn.classList.add('wrong'); showToast(`❌ ${correct.pt} = ${correct.fr}`); } + area.querySelectorAll('.option-btn').forEach(b => b.style.pointerEvents = 'none'); + addPoints(btn.classList.contains('correct') ? 10 : 0, 'Couleurs', 'game'); + setTimeout(round, 1200); + })); + })(); +} + +function startAnimalsGame(area) { + (function round() { + const correct = ANIMALS_GAME[Math.floor(Math.random() * ANIMALS_GAME.length)]; + let opts = [correct]; + while (opts.length < 4) { const a = ANIMALS_GAME[Math.floor(Math.random() * ANIMALS_GAME.length)]; if (!opts.find(o => o.pt === a.pt)) opts.push(a); } + opts.sort(() => Math.random() - 0.5); + area.innerHTML = `

Cet animal se dit ?

${gameState.score}
Points
${correct.emoji}
${opts.map(o => ``).join('')}
`; + area.querySelectorAll('.option-btn').forEach(btn => btn.addEventListener('click', () => { + gameState.total++; + if (btn.dataset.pt === correct.pt) { gameState.score += 10; btn.classList.add('correct'); SpeechEngine.speakPT(correct.pt); showToast(`✅ ${correct.emoji} = ${correct.pt}`); celebrate(); } + else { btn.classList.add('wrong'); showToast(`❌ ${correct.pt} = ${correct.fr}`); } + area.querySelectorAll('.option-btn').forEach(b => b.style.pointerEvents = 'none'); + addPoints(btn.classList.contains('correct') ? 10 : 0, 'Animaux', 'game'); + setTimeout(round, 1200); + })); + })(); +} + +function startNumbersGame(area) { + (function round() { + const correct = NUMBERS_GAME[Math.floor(Math.random() * NUMBERS_GAME.length)]; + let opts = [correct]; + while (opts.length < 4) { const n = NUMBERS_GAME[Math.floor(Math.random() * NUMBERS_GAME.length)]; if (!opts.find(o => o.num === n.num)) opts.push(n); } + opts.sort(() => Math.random() - 0.5); + area.innerHTML = `

Le nombre ${correct.num} ?

${gameState.score}
Points
${correct.num}
${opts.map(o => ``).join('')}
`; + area.querySelectorAll('.option-btn').forEach(btn => btn.addEventListener('click', () => { + gameState.total++; + if (btn.dataset.pt === correct.pt) { gameState.score += 10; btn.classList.add('correct'); SpeechEngine.speakPT(correct.pt); showToast(`✅ ${correct.num} = ${correct.pt}`); celebrate(); } + else { btn.classList.add('wrong'); showToast(`❌ ${correct.pt}`); } + area.querySelectorAll('.option-btn').forEach(b => b.style.pointerEvents = 'none'); + addPoints(btn.classList.contains('correct') ? 10 : 0, 'Nombres', 'game'); + setTimeout(round, 1200); + })); + })(); +} + +function startMemoryGame(area) { + const animals = ANIMALS_GAME.slice(0, 6); + const pairs = []; + animals.forEach(a => { pairs.push({ id: a.pt, display: a.emoji }); pairs.push({ id: a.pt, display: a.pt }); }); + pairs.sort(() => Math.random() - 0.5); + let flipped = [], matched = 0, lockBoard = false, moves = 0; + + area.innerHTML = `

Memory

0
Coups
0/6
Paires
${pairs.map((p, i) => `
?
`).join('')}
`; + + area.querySelectorAll('.memory-card').forEach(card => card.addEventListener('click', () => { + if (lockBoard || card.classList.contains('flipped') || card.classList.contains('matched')) return; + const idx = parseInt(card.dataset.idx); + card.classList.add('flipped'); + card.textContent = pairs[idx].display; + flipped.push({ el: card, id: pairs[idx].id }); + if (flipped.length === 2) { + lockBoard = true; moves++; + document.getElementById('mm').textContent = moves; + if (flipped[0].id === flipped[1].id) { + flipped.forEach(f => f.el.classList.add('matched')); + matched++; + document.getElementById('mp').textContent = `${matched}/6`; + SpeechEngine.speakPT(flipped[0].id); + flipped = []; lockBoard = false; + addPoints(5, 'Memory', 'game'); + if (matched === 6) { celebrate(); showToast(`Bravo ! ${moves} coups !`); addPoints(20, 'Memory complet', 'game'); } + } else { + setTimeout(() => { flipped.forEach(f => { f.el.classList.remove('flipped'); f.el.textContent = '?'; }); flipped = []; lockBoard = false; }, 800); + } + } + })); +} + +function startQuizGame(area) { + const all = Object.values(PHRASES).flat(); + (function round() { + const correct = all[Math.floor(Math.random() * all.length)]; + let opts = [correct]; + while (opts.length < 4) { const p = all[Math.floor(Math.random() * all.length)]; if (!opts.find(o => o.pt === p.pt)) opts.push(p); } + opts.sort(() => Math.random() - 0.5); + area.innerHTML = `

Que signifie :

${gameState.score}
Points
🔊 ${correct.pt}
${opts.map(o => ``).join('')}
`; + document.getElementById('ql').addEventListener('click', () => SpeechEngine.speakPT(correct.pt)); + area.querySelectorAll('.option-btn').forEach(btn => btn.addEventListener('click', () => { + gameState.total++; + if (btn.dataset.pt === correct.pt) { gameState.score += 10; btn.classList.add('correct'); showToast(`✅ ${correct.pt} = ${correct.fr}`); celebrate(); } + else { btn.classList.add('wrong'); showToast(`❌ ${correct.pt} = ${correct.fr}`); } + area.querySelectorAll('.option-btn').forEach(b => b.style.pointerEvents = 'none'); + addPoints(btn.classList.contains('correct') ? 10 : 0, 'Quiz', 'game'); + setTimeout(round, 1500); + })); + })(); +} + +function startDragDropGame(area) { + const pairs = [{ fr: "Chien", pt: "Cão" }, { fr: "Chat", pt: "Gato" }, { fr: "Maison", pt: "Casa" }, { fr: "Eau", pt: "Água" }, { fr: "Pain", pt: "Pão" }]; + const shuffled = [...pairs].sort(() => Math.random() - 0.5); + let matched = 0, dragged = null; + + function render() { + area.innerHTML = `

Relie les mots

${shuffled.map(p => `
${p.pt}
`).join('')}
${pairs.map(p => `
🇫🇷 ${p.fr}
Appuie ici
`).join('')}`; + area.querySelectorAll('.drag-item').forEach(item => item.addEventListener('click', () => { + if (item.style.opacity === '0.4') return; + area.querySelectorAll('.drag-item').forEach(i => i.style.outline = ''); + item.style.outline = '3px solid var(--gold)'; + dragged = item; + })); + area.querySelectorAll('.drop-zone').forEach(zone => zone.addEventListener('click', () => { + if (!dragged) return; + const pt = dragged.dataset.pt, expected = zone.dataset.expected; + if (pt === expected) { + zone.innerHTML = `✅ ${pt}`; + zone.style.borderColor = 'var(--green)'; zone.style.background = '#e0ffe0'; + dragged.style.opacity = '0.4'; dragged.style.pointerEvents = 'none'; + SpeechEngine.speakPT(pt); matched++; + addPoints(5, 'Associe', 'game'); + if (matched === pairs.length) { celebrate(); showToast('Bravo !'); addPoints(15, 'Associe complet', 'game'); } + } else { + zone.style.borderColor = 'var(--red)'; zone.style.background = '#ffe0e0'; + showToast('❌ Essaye encore !'); + setTimeout(() => { zone.style.borderColor = ''; zone.style.background = ''; }, 600); + } + dragged = null; + area.querySelectorAll('.drag-item').forEach(i => i.style.outline = ''); + })); + } + render(); +} + +// ===== SCRAMBLE GAME ===== +function startScrambleGame(area) { + const words = [...SCRAMBLE_WORDS].sort(() => Math.random() - 0.5); + let idx = 0; + (function round() { + if (idx >= words.length) { + area.innerHTML = `

Terminé ! 🎉 Score : ${gameState.score}/${words.length * 10}

`; + addPoints(gameState.score, 'Scramble', 'game'); + return; + } + const w = words[idx]; + const scrambled = w.word.split('').sort(() => Math.random() - 0.5).join(''); + const isSame = scrambled === w.word; + const display = isSame ? w.word.split('').reverse().join('') : scrambled; + area.innerHTML = ` +
+

🔤 Déchiffre le mot !

+
${gameState.score}
Points
+
${w.emoji}
+

${w.fr}

+
+
${display.toUpperCase().split('').map((l,i) => `${l}`).join('')}
+ + +

+ `; + const check = () => { + const val = document.getElementById('scramble-input').value.trim().toLowerCase(); + const fb = document.getElementById('scramble-feedback'); + gameState.total++; + if (val === w.word) { + gameState.score += 10; + fb.innerHTML = `✅ ${w.word} = ${w.fr}`; + SpeechEngine.speakPT(w.word); + celebrate(); + } else { + fb.innerHTML = `❌ C'était "${w.word}"`; + } + document.getElementById('scramble-input').disabled = true; + document.getElementById('scramble-check').disabled = true; + setTimeout(() => { idx++; round(); }, 1500); + }; + document.getElementById('scramble-check').addEventListener('click', check); + document.getElementById('scramble-input').addEventListener('keydown', e => { if (e.key === 'Enter') check(); }); + document.getElementById('scramble-input').focus(); + })(); +} + +// ===== BINGO GAME ===== +function startBingoGame(area) { + let bingoScore = 0; + let bingoRound = 0; + const totalRounds = 10; + + function newRound() { + if (bingoRound >= totalRounds) { + area.innerHTML = `

Bingo terminé ! 🎤🎉 Score : ${bingoScore}/${totalRounds}

`; + addPoints(bingoScore * 15, 'Bingo', 'game'); + return; + } + bingoRound++; + const correct = BINGO_WORDS[Math.floor(Math.random() * BINGO_WORDS.length)]; + let opts = [correct]; + while (opts.length < 6) { + const w = BINGO_WORDS[Math.floor(Math.random() * BINGO_WORDS.length)]; + if (!opts.find(o => o.pt === w.pt)) opts.push(w); + } + opts.sort(() => Math.random() - 0.5); + + area.innerHTML = ` +
+

🎤 Bingo - ${bingoRound}/${totalRounds}

+
${bingoScore}
Trouvés
+ +

Appuie pour écouter !

+
+
${opts.map(o => ` + + `).join('')}
+ `; + + document.getElementById('bingo-listen').addEventListener('click', () => SpeechEngine.speakPT(correct.pt)); + SpeechEngine.speakPT(correct.pt); + + area.querySelectorAll('.bingo-card').forEach(btn => { + btn.addEventListener('click', () => { + area.querySelectorAll('.bingo-card').forEach(b => b.style.pointerEvents = 'none'); + gameState.total++; + if (btn.dataset.pt === correct.pt) { + bingoScore++; + gameState.score += 15; + btn.classList.add('correct'); + showToast(`✅ ${correct.emoji} ${correct.pt} = ${correct.fr}`); + celebrate(); + } else { + btn.classList.add('wrong'); + area.querySelector(`.bingo-card[data-pt="${correct.pt}"]`)?.classList.add('correct'); + showToast(`❌ C'était ${correct.emoji} ${correct.pt}`); + } + addPoints(btn.dataset.pt === correct.pt ? 15 : 0, 'Bingo', 'game'); + setTimeout(newRound, 1500); + }); + }); + } + newRound(); +} + +// ===== CATCH WORDS GAME ===== +function startCatchGame(area) { + const words = [...BINGO_WORDS].sort(() => Math.random() - 0.5).slice(0, 20); + let caught = 0, missed = 0, gameActive = true; + const catchDuration = 30; + + area.innerHTML = ` +
+

🖐️ Apanha as palavras !

+
+
0
Apanhados
+
${catchDuration}s
Tempo
+
0
Perdidos
+
+
+
+ `; + + const zone = document.getElementById('catch-zone'); + let timer = catchDuration; + const timerInterval = setInterval(() => { + timer--; + document.getElementById('catch-timer').textContent = timer + 's'; + if (timer <= 0) { + clearInterval(timerInterval); + clearInterval(spawnInterval); + gameActive = false; + const pts = caught * 10; + area.innerHTML = `

Tempo esgotado! ⏰

Apanhaste ${caught} palavras!

`; + addPoints(pts, 'Apanha', 'game'); + if (caught >= 5) celebrate(); + } + }, 1000); + + let wordIdx = 0; + const spawnInterval = setInterval(() => { + if (!gameActive || wordIdx >= words.length) return; + const w = words[wordIdx++]; + const el = document.createElement('div'); + el.className = 'catch-word'; + el.innerHTML = `${w.emoji}${w.pt}`; + el.style.left = Math.random() * 80 + 10 + '%'; + el.dataset.pt = w.pt; + el.dataset.fr = w.fr; + el.addEventListener('click', () => { + if (el.classList.contains('caught') || el.classList.contains('missed-word')) return; + caught++; + gameState.score += 10; + el.classList.add('caught'); + document.getElementById('catch-caught').textContent = caught; + SpeechEngine.speakPT(w.pt); + showToast(`✅ ${w.emoji} ${w.pt}`); + setTimeout(() => el.remove(), 500); + }); + zone.appendChild(el); + setTimeout(() => { + if (!el.classList.contains('caught')) { + el.classList.add('missed-word'); + missed++; + const m = document.getElementById('catch-missed'); + if (m) m.textContent = missed; + setTimeout(() => el.remove(), 800); + } + }, 3000); + }, 800); +} + +// ===== RIDDLE GAME ===== +function startRiddleGame(area) { + const riddles = [...RIDDLES].sort(() => Math.random() - 0.5); + let idx = 0; + (function round() { + if (idx >= riddles.length) { + area.innerHTML = `

Terminé ! 🤔🎉 Score : ${gameState.score}/${riddles.length * 10}

`; + addPoints(gameState.score, 'Riddle', 'game'); + return; + } + const r = riddles[idx]; + const opts = [...r.options].sort(() => Math.random() - 0.5); + area.innerHTML = ` +
+

🤔 O que é?

+
${gameState.score}
Points
+
${r.emoji}
+
+
+

"${r.riddle}"

+

${r.fr}

+
+
${opts.map(o => ``).join('')}
+ `; + area.querySelectorAll('.riddle-opt').forEach(btn => { + btn.addEventListener('click', () => { + area.querySelectorAll('.riddle-opt').forEach(b => b.style.pointerEvents = 'none'); + gameState.total++; + if (btn.dataset.val === r.answer) { + gameState.score += 10; + btn.classList.add('correct'); + showToast(`✅ ${r.answer} !`); + SpeechEngine.speakPT(r.answer); + celebrate(); + } else { + btn.classList.add('wrong'); + area.querySelector(`.riddle-opt[data-val="${r.answer}"]`)?.classList.add('correct'); + showToast(`❌ C'était "${r.answer}"`); + } + addPoints(btn.dataset.val === r.answer ? 10 : 0, 'Riddle', 'game'); + setTimeout(round, 1500); + }); + }); + })(); +} + +// ===== EXERCISES ===== +let currentExercise = null; + +document.querySelectorAll('.game-card[data-exercise]').forEach(c => c.addEventListener('click', () => startExercise(c.dataset.exercise))); +document.getElementById('back-to-exercises').addEventListener('click', () => { + document.getElementById('exercise-area').classList.add('hidden'); + document.getElementById('back-to-exercises').classList.add('hidden'); + document.querySelector('#tab-exercises .game-menu').classList.remove('hidden'); + currentExercise = null; +}); + +function startExercise(type) { + document.querySelector('#tab-exercises .game-menu').classList.add('hidden'); + const area = document.getElementById('exercise-area'); + document.getElementById('back-to-exercises').classList.remove('hidden'); + area.classList.remove('hidden'); area.innerHTML = ''; + currentExercise = { type, score: 0, total: 0 }; + if (type === 'fill') startFillExercise(area); + else if (type === 'listenwrite') startListenWriteExercise(area); + else if (type === 'translate') startTranslateExercise(area); + else if (type === 'conjugation') startConjugationExercise(area); +} + +function exerciseDone() { + const score = currentExercise.score; + const total = currentExercise.total || currentExercise.type === 'fill' ? EXERCISES_FILL.length : EXERCISES_LISTEN.length; + addPoints(score * 5, currentExercise.type, 'exercise'); + if (score >= Math.floor(total * 0.7)) celebrate(); +} + +function startFillExercise(area) { + let idx = 0; + (function question() { + if (idx >= EXERCISES_FILL.length) { area.innerHTML = `

Terminé ! Score : ${currentExercise.score}/${EXERCISES_FILL.length}

`; exerciseDone(); return; } + const ex = EXERCISES_FILL[idx]; + const parts = ex.template.split('___'); + area.innerHTML = `

Complète la phrase

${ex.hint}

${parts[0]}____${parts[1] || ''}

${[...ex.options].sort(() => Math.random() - 0.5).map(o => ``).join('')}
`; + area.querySelectorAll('.fill-word').forEach(btn => btn.addEventListener('click', () => { + currentExercise.total++; + const blank = document.getElementById('blank'); + if (btn.dataset.word === ex.answer) { currentExercise.score++; blank.textContent = btn.dataset.word; blank.classList.add('correct-blank'); SpeechEngine.speakPT(ex.template.replace('___', ex.answer)); showToast('✅ Bravo !'); } + else { blank.textContent = btn.dataset.word; blank.classList.add('wrong-blank'); showToast(`❌ "${ex.answer}"`); } + area.querySelectorAll('.fill-word').forEach(b => b.style.pointerEvents = 'none'); + setTimeout(() => { idx++; question(); }, 1500); + })); + })(); +} + +function startListenWriteExercise(area) { + let idx = 0; + (function question() { + if (idx >= EXERCISES_LISTEN.length) { area.innerHTML = `

Terminé ! Score : ${currentExercise.score}/${EXERCISES_LISTEN.length}

`; exerciseDone(); return; } + const ex = EXERCISES_LISTEN[idx]; + area.innerHTML = `

Écoute et écris

${ex.fr}

`; + document.getElementById('lw-listen').addEventListener('click', () => SpeechEngine.speakPT(ex.pt)); + const check = () => { + const val = document.getElementById('lw-input').value.trim().toLowerCase(); + const correct = ex.pt.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const userNorm = val.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + currentExercise.total++; + const fb = document.getElementById('lw-feedback'); + if (userNorm === correct || val === ex.pt.toLowerCase()) { currentExercise.score++; fb.innerHTML = `✅ ${ex.pt}`; SpeechEngine.speakPT(ex.pt); } + else { fb.innerHTML = `❌ ${ex.pt}`; } + setTimeout(() => { idx++; question(); }, 2000); + }; + document.getElementById('lw-check').addEventListener('click', check); + document.getElementById('lw-input').addEventListener('keydown', e => { if (e.key === 'Enter') check(); }); + SpeechEngine.speakPT(ex.pt); + })(); +} + +function startTranslateExercise(area) { + let idx = 0; + (function question() { + if (idx >= EXERCISES_TRANSLATE.length) { area.innerHTML = `

Terminé ! Score : ${currentExercise.score}/${EXERCISES_TRANSLATE.length}

`; exerciseDone(); return; } + const ex = EXERCISES_TRANSLATE[idx]; + area.innerHTML = `

Traduis en portugais

${ex.fr}

`; + const check = () => { + const val = document.getElementById('tr-input').value.trim().toLowerCase(); + const correct = ex.pt.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const userNorm = val.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + currentExercise.total++; + const fb = document.getElementById('tr-feedback'); + if (userNorm === correct || val === ex.pt.toLowerCase()) { currentExercise.score++; fb.innerHTML = `✅ ${ex.pt}`; SpeechEngine.speakPT(ex.pt); } + else { fb.innerHTML = `❌ ${ex.pt}`; } + setTimeout(() => { idx++; question(); }, 2000); + }; + document.getElementById('tr-check').addEventListener('click', check); + document.getElementById('tr-input').addEventListener('keydown', e => { if (e.key === 'Enter') check(); }); + })(); +} + +function startConjugationExercise(area) { + let idx = 0; + const pronouns = ['eu', 'tu', 'ele', 'nós', 'eles']; + (function question() { + if (idx >= CONJUGATIONS.length) { area.innerHTML = `

Terminé ! Score : ${currentExercise.score}/${CONJUGATIONS.length}

`; exerciseDone(); return; } + const conj = CONJUGATIONS[idx]; + const rp = pronouns[Math.floor(Math.random() * pronouns.length)]; + const correct = conj.forms[rp]; + area.innerHTML = `

Conjugue "${conj.verb}"

${conj.meaning}

${rp} ...?

`; + const check = () => { + const val = document.getElementById('conj-input').value.trim().toLowerCase(); + const correctNorm = correct.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const userNorm = val.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + currentExercise.total++; + const fb = document.getElementById('conj-feedback'); + if (userNorm === correctNorm || val === correct.toLowerCase()) { currentExercise.score++; fb.innerHTML = `✅ ${rp} ${correct}`; SpeechEngine.speakPT(`${rp} ${correct}`); } + else { fb.innerHTML = `❌ ${rp} ${correct}
${pronouns.map(p => `${p} ${conj.forms[p]}`).join(', ')}`; } + setTimeout(() => { idx++; question(); }, 2500); + }; + document.getElementById('conj-check').addEventListener('click', check); + document.getElementById('conj-input').addEventListener('keydown', e => { if (e.key === 'Enter') check(); }); + })(); +} + +// ===== VOCAB ===== +let currentVocabCategory = Object.keys(VOCAB)[0]; + +function renderVocabCategories() { + const c = document.getElementById('vocab-categories'); + c.innerHTML = Object.keys(VOCAB).map(k => + `` + ).join(''); + c.querySelectorAll('.cat-tab').forEach(b => b.addEventListener('click', () => { currentVocabCategory = b.dataset.cat; renderVocabCategories(); renderVocab(); })); +} + +function renderVocab() { + const g = document.getElementById('vocab-grid'); + const items = VOCAB[currentVocabCategory] || []; + g.innerHTML = items.map(v => `
${v.emoji}
${v.pt}
${v.fr}
`).join(''); + g.querySelectorAll('.vocab-card').forEach(c => c.addEventListener('click', () => SpeechEngine.speakPT(c.dataset.word))); +} + +// ===== ADMIN ===== +async function loadAdmin() { + if (!user.is_admin) return; + const data = await API.get('/api/admin/users'); + const list = document.getElementById('admin-users-list'); + list.innerHTML = data.users.map(u => ` +
+ + +
+ `).join(''); + + list.querySelectorAll('.admin-btn-delete').forEach(btn => btn.addEventListener('click', async () => { + if (!confirm('Supprimer cet utilisateur ?')) return; + await API.del(`/api/admin/users/${btn.dataset.id}`); + loadAdmin(); + })); + list.querySelectorAll('.admin-btn-toggle').forEach(btn => btn.addEventListener('click', async () => { + await API.put(`/api/admin/users/${btn.dataset.id}/toggle-admin`); + loadAdmin(); + })); +} + +document.getElementById('add-user-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('new-username').value.trim(); + const display_name = document.getElementById('new-displayname').value.trim(); + const password = document.getElementById('new-password').value; + const avatar = document.getElementById('new-avatar').value; + const data = await API.post('/api/register', { username, password, display_name, avatar }); + if (data.error) { showToast(data.error); return; } + showToast(`✅ ${display_name} ajouté !`); + e.target.reset(); + loadAdmin(); +}); + +// ===== INIT ===== +async function init() { + await Promise.all([loadMyPoints(), loadFavorites()]); + renderPhraseCategories(); + renderPhrases(); + renderVocabCategories(); + renderVocab(); + loadDashboard(); + if (user.is_admin) loadAdmin(); +} + +init(); + +if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } +if ('speechSynthesis' in window) { window.speechSynthesis.getVoices(); window.speechSynthesis.onvoiceschanged = () => window.speechSynthesis.getVoices(); } diff --git a/public/data.js b/public/data.js new file mode 100644 index 0000000..97765ba --- /dev/null +++ b/public/data.js @@ -0,0 +1,851 @@ +// ===== LEVELS ===== +const LEVELS = { + 1: { name: "Débutant", minPoints: 0, color: "#4CAF50", icon: "🌱" }, + 2: { name: "Élémentaire", minPoints: 200, color: "#2196F3", icon: "📘" }, + 3: { name: "Intermédiaire", minPoints: 600, color: "#FF9800", icon: "📙" }, + 4: { name: "Avancé", minPoints: 1500, color: "#E91E63", icon: "📕" }, +}; + +function getUserLevel(points) { + let lvl = 1; + for (const [k, v] of Object.entries(LEVELS)) { if (points >= v.minPoints) lvl = parseInt(k); } + return lvl; +} + +function getLevelInfo(lvl) { return LEVELS[lvl] || LEVELS[1]; } + +// ===== PHRASES PAR NIVEAU ===== +const PHRASES = { + "Salutations": [ + { pt: "Olá", fr: "Bonjour", phonetic: "oh-LAH", level: 1 }, + { pt: "Bom dia", fr: "Bonjour (matin)", phonetic: "bom DEE-ah", level: 1 }, + { pt: "Boa tarde", fr: "Bonjour (après-midi)", phonetic: "BOH-ah TAR-deh", level: 1 }, + { pt: "Boa noite", fr: "Bonsoir / Bonne nuit", phonetic: "BOH-ah NOY-teh", level: 1 }, + { pt: "Adeus", fr: "Au revoir", phonetic: "ah-DEUSH", level: 1 }, + { pt: "Até logo", fr: "À bientôt", phonetic: "ah-TEH LOH-goo", level: 1 }, + { pt: "Até amanhã", fr: "À demain", phonetic: "ah-TEH ah-mahn-YAHN", level: 1 }, + { pt: "Como vai?", fr: "Comment vas-tu ?", phonetic: "KOH-moh VAI", level: 1 }, + { pt: "Tudo bem?", fr: "Comment vas-tu ?", phonetic: "TOO-doh beng", level: 1 }, + { pt: "Tudo bem!", fr: "Tout va bien !", phonetic: "TOO-doh beng", level: 1 }, + { pt: "Tudo bom?", fr: "Tout va bien ?", phonetic: "TOO-doh bom", level: 1 }, + { pt: "Prazer", fr: "Enchanté", phonetic: "prah-ZEHR", level: 1 }, + { pt: "Muito prazer", fr: "Très enchanté", phonetic: "MWEEN-too prah-ZEHR", level: 1 }, + { pt: "Bom dia, como está?", fr: "Bonjour, comment allez-vous ?", phonetic: "bom DEE-ah koh-moo eshtah", level: 2 }, + { pt: "Olá, tudo bem consigo?", fr: "Bonjour, allez-vous bien ?", phonetic: "oh-LAH too-doh beng kohn-SEE-goo", level: 2 }, + { pt: "Como passaste?", fr: "Comment as-tu passé ?", phonetic: "koh-moh pah-SAHS-teh", level: 2 }, + { pt: "Foi um prazer conhecê-lo", fr: "Ce fut un plaisir de vous rencontrer", phonetic: "foy oom prah-ZEHR koh-neh-SHEH-loo", level: 3 }, + { pt: "Espero que esteja bem", fr: "J'espère que vous allez bien", phonetic: "eh-SPEH-roo keh esh-TEH-zhah beng", level: 3 }, + { pt: "Cumprimentos da minha parte", fr: "Mes salutations", phonetic: "koom-pree-MEN-too dash MEH-nyah PAR-teh", level: 4 }, + ], + "Famille": [ + { pt: "Família", fr: "Famille", phonetic: "fah-MEE-lee-ah", level: 1 }, + { pt: "Mãe", fr: "Maman", phonetic: "meng", level: 1 }, + { pt: "Pai", fr: "Papa", phonetic: "pai", level: 1 }, + { pt: "Filho", fr: "Fils", phonetic: "FEE-lyoo", level: 1 }, + { pt: "Filha", fr: "Fille", phonetic: "FEE-lyah", level: 1 }, + { pt: "Irmão", fr: "Frère", phonetic: "eer-MAHNG", level: 1 }, + { pt: "Irmã", fr: "Sœur", phonetic: "eer-MAHN", level: 1 }, + { pt: "Avô", fr: "Grand-père", phonetic: "ah-VOH", level: 1 }, + { pt: "Avó", fr: "Grand-mère", phonetic: "ah-VOH", level: 1 }, + { pt: "Bebé", fr: "Bébé", phonetic: "beh-BEH", level: 1 }, + { pt: "Amor", fr: "Amour", phonetic: "ah-MOHR", level: 1 }, + { pt: "Tio", fr: "Oncle", phonetic: "TOO", level: 2 }, + { pt: "Tia", fr: "Tante", phonetic: "TEE-ah", level: 2 }, + { pt: "Primo", fr: "Cousin", phonetic: "PREE-moo", level: 2 }, + { pt: "Prima", fr: "Cousine", phonetic: "PREE-mah", level: 2 }, + { pt: "Sobrinho", fr: "Neveu", phonetic: "soh-BREE-nyoo", level: 3 }, + { pt: "Sobrinha", fr: "Nièce", phonetic: "soh-BREE-nyah", level: 3 }, + { pt: "Genro", fr: "Beau-fils", phonetic: "ZHEN-roo", level: 3 }, + { pt: "Nora", fr: "Belle-fille", phonetic: "NOH-rah", level: 3 }, + { pt: "Padrasto", fr: "Beau-père (beau-père)", phonetic: "pah-DRAHS-too", level: 4 }, + { pt: "Madrasta", fr: "Belle-mère (belle-mère)", phonetic: "mah-DRAHS-tah", level: 4 }, + { pt: "Enteado", fr: "Beau-fils (fils du conjoint)", phonetic: "en-teh-AH-doo", level: 4 }, + { pt: "Padrinho", fr: "Parrain", phonetic: "pah-DREE-nyoo", level: 3 }, + { pt: "Madrinha", fr: "Marraine", phonetic: "mah-DREE-nyah", level: 3 }, + ], + "Nourriture": [ + { pt: "Água", fr: "Eau", phonetic: "AH-goo-ah", level: 1 }, + { pt: "Pão", fr: "Pain", phonetic: "pahng", level: 1 }, + { pt: "Leite", fr: "Lait", phonetic: "LAY-teh", level: 1 }, + { pt: "Café", fr: "Café", phonetic: "kah-FEH", level: 1 }, + { pt: "Arroz", fr: "Riz", phonetic: "ah-HOHSH", level: 1 }, + { pt: "Fruta", fr: "Fruit", phonetic: "FROO-tah", level: 1 }, + { pt: "Carne", fr: "Viande", phonetic: "KAR-neh", level: 1 }, + { pt: "Peixe", fr: "Poisson", phonetic: "PAY-sheh", level: 1 }, + { pt: "Queijo", fr: "Fromage", phonetic: "KAY-zhoo", level: 1 }, + { pt: "Sopa", fr: "Soupe", phonetic: "SOH-pah", level: 1 }, + { pt: "Bom apetite!", fr: "Bon appétit !", phonetic: "bom ah-peh-TEE-teh", level: 1 }, + { pt: "Manteiga", fr: "Beurre", phonetic: "man-TAY-gah", level: 2 }, + { pt: "Ovos", fr: "Œufs", phonetic: "OH-vohsh", level: 2 }, + { pt: "Batatas", fr: "Pommes de terre", phonetic: "bah-TAH-tahsh", level: 2 }, + { pt: "Cenouras", fr: "Carottes", phonetic: "seh-NOH-rahsh", level: 2 }, + { pt: "Tomates", fr: "Tomates", phonetic: "toh-MAH-tehsh", level: 2 }, + { pt: "Cebolas", fr: "Oignons", phonetic: "seh-BOH-lahsh", level: 2 }, + { pt: "Alho", fr: "Ail", phonetic: "AH-lyoo", level: 2 }, + { pt: "Sal", fr: "Sel", phonetic: "sahl", level: 2 }, + { pt: "Açúcar", fr: "Sucre", phonetic: "ah-SOO-kahr", level: 2 }, + { pt: "Vinho", fr: "Vin", phonetic: "VEE-nyoo", level: 3 }, + { pt: "Cerveja", fr: "Bière", phonetic: "sehr-VEH-zhah", level: 3 }, + { pt: "Sobremesa", fr: "Dessert", phonetic: "soh-breh-MEH-zah", level: 3 }, + { pt: "Prato do dia", fr: "Plat du jour", phonetic: "PRAH-too doo DEE-ah", level: 3 }, + { pt: "Conta, por favor", fr: "L'addition, s'il vous plaît", phonetic: "KOHN-tah pohr fah-VOHR", level: 2 }, + { pt: "Em mental", fr: "À emporter", phonetic: "eng mehn-TAHL", level: 4 }, + { pt: "Ao molho", fr: "Sauce", phonetic: "ah-oo MOH-lyoo", level: 4 }, + { pt: "Grelhado", fr: "Grillé", phonetic: "greh-LYAH-doo", level: 3 }, + { pt: "Cozido", fr: "Bouilli", phonetic: "koh-ZEE-doo", level: 3 }, + { pt: "Frito", fr: "Frit", phonetic: "FREE-too", level: 3 }, + ], + "Couleurs": [ + { pt: "Vermelho", fr: "Rouge", phonetic: "vehr-MEH-lyoo", level: 1 }, + { pt: "Azul", fr: "Bleu", phonetic: "ah-ZOOL", level: 1 }, + { pt: "Verde", fr: "Vert", phonetic: "VEHR-deh", level: 1 }, + { pt: "Amarelo", fr: "Jaune", phonetic: "ah-mah-REH-loh", level: 1 }, + { pt: "Branco", fr: "Blanc", phonetic: "BRAHN-koo", level: 1 }, + { pt: "Preto", fr: "Noir", phonetic: "PREH-too", level: 1 }, + { pt: "Rosa", fr: "Rose", phonetic: "HOH-zah", level: 1 }, + { pt: "Laranja", fr: "Orange", phonetic: "lah-RAHN-zhah", level: 1 }, + { pt: "Castanho", fr: "Marron", phonetic: "kash-TAH-nyoo", level: 1 }, + { pt: "Roxo", fr: "Violet", phonetic: "HOH-shoo", level: 1 }, + { pt: "Cinza", fr: "Gris", phonetic: "SEEN-zah", level: 2 }, + { pt: "Dourado", fr: "Doré", phonetic: "doh-RAH-doo", level: 2 }, + { pt: "Prateado", fr: "Argenté", phonetic: "prah-teh-AH-doo", level: 3 }, + ], + "Animaux": [ + { pt: "Cão", fr: "Chien", phonetic: "kahng", level: 1 }, + { pt: "Gato", fr: "Chat", phonetic: "GAH-too", level: 1 }, + { pt: "Pássaro", fr: "Oiseau", phonetic: "PAH-sah-hoo", level: 1 }, + { pt: "Peixe", fr: "Poisson", phonetic: "PAY-sheh", level: 1 }, + { pt: "Coelho", fr: "Lapin", phonetic: "koh-EH-lyoo", level: 1 }, + { pt: "Cavalo", fr: "Cheval", phonetic: "kah-VAH-loh", level: 1 }, + { pt: "Vaca", fr: "Vache", phonetic: "VAH-kah", level: 1 }, + { pt: "Galinha", fr: "Poule", phonetic: "gah-LEE-nyah", level: 1 }, + { pt: "Borboleta", fr: "Papillon", phonetic: "bohr-boh-LEH-tah", level: 1 }, + { pt: "Formiga", fr: "Fourmi", phonetic: "fohr-MEE-gah", level: 1 }, + { pt: "Tartaruga", fr: "Tortue", phonetic: "tar-tah-ROO-gah", level: 1 }, + { pt: "Macaco", fr: "Singe", phonetic: "mah-KAH-koo", level: 1 }, + { pt: "Elefante", fr: "Éléphant", phonetic: "eh-leh-FAHN-teh", level: 1 }, + { pt: "Leão", fr: "Lion", phonetic: "leh-AHNG", level: 1 }, + { pt: "Urso", fr: "Ours", phonetic: "OOR-soo", level: 2 }, + { pt: "Rã", fr: "Grenouille", phonetic: "rahn", level: 2 }, + { pt: "Ovelha", fr: "Mouton", phonetic: "oh-VEH-lyah", level: 2 }, + { pt: "Porco", fr: "Cochon", phonetic: "POHR-koo", level: 2 }, + { pt: "Pato", fr: "Canard", phonetic: "PAH-too", level: 2 }, + { pt: "Golfinho", fr: "Dauphin", phonetic: "gohl-FEE-nyoo", level: 3 }, + { pt: "Girafa", fr: "Girafe", phonetic: "zhee-RAH-fah", level: 3 }, + { pt: "Zebra", fr: "Zèbre", phonetic: "ZEH-brah", level: 3 }, + { pt: "Crocodilo", fr: "Crocodile", phonetic: "koh-koh-DEE-loo", level: 3 }, + { pt: "Raposa", fr: "Renard", phonetic: "rah-POH-zah", level: 3 }, + { pt: "Lobo", fr: "Loup", phonetic: "LOH-boo", level: 3 }, + { pt: "Águia", fr: "Aigle", phonetic: "AH-gee-ah", level: 4 }, + { pt: "Coruja", fr: "Hibou", phonetic: "koh-ROO-zhah", level: 4 }, + { pt: "Lagarta", fr: "Chenille", phonetic: "lah-GAR-tah", level: 4 }, + ], + "Nombres": [ + { pt: "Um", fr: "Un", phonetic: "oom", level: 1 }, + { pt: "Dois", fr: "Deux", phonetic: "doysh", level: 1 }, + { pt: "Três", fr: "Trois", phonetic: "trehsh", level: 1 }, + { pt: "Quatro", fr: "Quatre", phonetic: "KWAH-troo", level: 1 }, + { pt: "Cinco", fr: "Cinq", phonetic: "SEEN-koo", level: 1 }, + { pt: "Seis", fr: "Six", phonetic: "saysh", level: 1 }, + { pt: "Sete", fr: "Sept", phonetic: "SEH-teh", level: 1 }, + { pt: "Oito", fr: "Huit", phonetic: "OY-too", level: 1 }, + { pt: "Nove", fr: "Neuf", phonetic: "NOH-veh", level: 1 }, + { pt: "Dez", fr: "Dix", phonetic: "dehsh", level: 1 }, + { pt: "Onze", fr: "Onze", phonetic: "OHN-zeh", level: 2 }, + { pt: "Doze", fr: "Douze", phonetic: "DOH-zeh", level: 2 }, + { pt: "Treze", fr: "Treize", phonetic: "TREH-zeh", level: 2 }, + { pt: "Catorze", fr: "Quatorze", phonetic: "kah-TOR-zeh", level: 2 }, + { pt: "Quinze", fr: "Quinze", phonetic: "KEEN-zeh", level: 2 }, + { pt: "Dezanove", fr: "Dix-neuf", phonetic: "deh-zah-NOH-veh", level: 2 }, + { pt: "Vinte", fr: "Vingt", phonetic: "VEEN-teh", level: 1 }, + { pt: "Trinta", fr: "Trente", phonetic: "TREEN-tah", level: 2 }, + { pt: "Quarenta", fr: "Quarante", phonetic: "kwah-REHN-tah", level: 2 }, + { pt: "Cinquenta", fr: "Cinquante", phonetic: "seen-KWEN-tah", level: 2 }, + { pt: "Sessenta", fr: "Soixante", phonetic: "seh-SEN-tah", level: 2 }, + { pt: "Setenta", fr: "Soixante-dix", phonetic: "seh-TEN-tah", level: 2 }, + { pt: "Oitenta", fr: "Quatre-vingts", phonetic: "oy-TEN-tah", level: 2 }, + { pt: "Noventa", fr: "Quatre-vingt-dix", phonetic: "noh-VEN-tah", level: 2 }, + { pt: "Cem", fr: "Cent", phonetic: "seng", level: 1 }, + { pt: "Duzentos", fr: "Deux cents", phonetic: "doo-ZEN-toosh", level: 3 }, + { pt: "Trezentos", fr: "Trois cents", phonetic: "treh-ZEN-toosh", level: 3 }, + { pt: "Mil", fr: "Mille", phonetic: "meel", level: 1 }, + { pt: "Milhão", fr: "Un million", phonetic: "mee-LYAHNG", level: 3 }, + ], + "Politesse": [ + { pt: "Obrigado", fr: "Merci (homme)", phonetic: "oh-bree-GAH-doo", level: 1 }, + { pt: "Obrigada", fr: "Merci (femme)", phonetic: "oh-bree-GAH-dah", level: 1 }, + { pt: "De nada", fr: "De rien", phonetic: "deh NAH-dah", level: 1 }, + { pt: "Por favor", fr: "S'il vous plaît", phonetic: "pohr fah-VOHR", level: 1 }, + { pt: "Desculpe", fr: "Excusez-moi", phonetic: "desh-KOOL-peh", level: 1 }, + { pt: "Com licença", fr: "Avec votre permission", phonetic: "kong lee-SEN-sah", level: 1 }, + { pt: "Sim", fr: "Oui", phonetic: "seeng", level: 1 }, + { pt: "Não", fr: "Non", phonetic: "nahng", level: 1 }, + { pt: "Com certeza", fr: "Certainement", phonetic: "kong sehr-TEH-zah", level: 1 }, + { pt: "Claro", fr: "Bien sûr", phonetic: "KLAH-roo", level: 1 }, + { pt: "Comprehendo", fr: "Je comprends", phonetic: "kohm-preh-EN-doo", level: 2 }, + { pt: "Não compreendo", fr: "Je ne comprends pas", phonetic: "nahng kohm-preh-EN-doo", level: 2 }, + { pt: "Pode falar devagar?", fr: "Pouvez-vous parler plus lentement ?", phonetic: "POH-deh fah-LAR deh-vah-GAR", level: 2 }, + { pt: "Não falo português", fr: "Je ne parle pas portugais", phonetic: "nahng FAH-loo pohr-too-GAYSH", level: 1 }, + { pt: "Fala inglês?", fr: "Parles-tu anglais ?", phonetic: "FAH-lah een-GLAYSH", level: 1 }, + { pt: "Tem água?", fr: "Avez-vous de l'eau ?", phonetic: "teng AH-goo-ah", level: 2 }, + { pt: "Pode ajudar-me?", fr: "Pouvez-vous m'aider ?", phonetic: "POH-deh ah-zhoo-DAR-meh", level: 2 }, + { pt: "Com muito gusto", fr: "Avec beaucoup de plaisir", phonetic: "kong MWEEN-too GOOSH-too", level: 3 }, + { pt: "Agradeço imenso", fr: "Je vous remercie énormément", phonetic: "ah-grah-DEH-soo een-MEN-soo", level: 3 }, + { pt: "Não se incomode", fr: "Ne vous dérangez pas", phonetic: "nahng seh een-KOH-meh-deh", level: 4 }, + ], + "Phrases utiles": [ + { pt: "Como te chamas?", fr: "Comment tu t'appelles ?", phonetic: "KOH-moo teh SHAH-mahsh", level: 1 }, + { pt: "Chamo-me...", fr: "Je m'appelle...", phonetic: "SHAH-moo meh", level: 1 }, + { pt: "De onde és?", fr: "D'où es-tu ?", phonetic: "deh OHN-deh ehsh", level: 1 }, + { pt: "Sou francês", fr: "Je suis français", phonetic: "soh frahn-SESH", level: 1 }, + { pt: "Sou francesa", fr: "Je suis française", phonetic: "soh frahn-SEH-zah", level: 1 }, + { pt: "Onde fica...?", fr: "Où est... ?", phonetic: "OHN-deh FEE-kah", level: 1 }, + { pt: "Quanto custa?", fr: "Combien ça coûte ?", phonetic: "KWAHN-too KOOSH-tah", level: 1 }, + { pt: "A conta, por favor", fr: "L'addition, s'il vous plaît", phonetic: "ah KOHN-tah pohr fah-VOHR", level: 1 }, + { pt: "Não percebo", fr: "Je ne comprends pas", phonetic: "nahng pehr-SEH-boo", level: 1 }, + { pt: "Pode repetir?", fr: "Pouvez-vous répéter ?", phonetic: "POH-deh heh-peh-TEER", level: 1 }, + { pt: "Está delicioso!", fr: "C'est délicieux !", phonetic: "eshtah deh-lee-see-OH-zoo", level: 1 }, + { pt: "Tenho fome", fr: "J'ai faim", phonetic: "TEH-nyoo FOH-meh", level: 1 }, + { pt: "Tenho sede", fr: "J'ai soif", phonetic: "TEH-nyoo SEH-deh", level: 1 }, + { pt: "Ajuda!", fr: "Au secours !", phonetic: "ah-ZHOO-dah", level: 1 }, + { pt: "Preciso de ajuda", fr: "J'ai besoin d'aide", phonetic: "preh-SEE-zoo deh ah-ZHOO-dah", level: 1 }, + { pt: "Onde é a casa de banho?", fr: "Où sont les toilettes ?", phonetic: "OHN-deh eh ah KAH-zah deh bahn-YOO", level: 2 }, + { pt: "Qual é o seu nome?", fr: "Quel est votre nom ?", phonetic: "kwahl eh oh seh NOH-meh", level: 2 }, + { pt: "Gosto muito de Portugal", fr: "J'aime beaucoup le Portugal", phonetic: "GOSH-too MWEEN-too deh pohr-too-GAHL", level: 2 }, + { pt: "Estou a aprender português", fr: "J'apprends le portugais", phonetic: "eshtoh ah ah-prehn-DEHR pohr-too-GAYSH", level: 2 }, + { pt: "Faz calor?", fr: "Il fait chaud ?", phonetic: "fahsh kah-LOHR", level: 2 }, + { pt: "Está a chover", fr: "Il pleut", phonetic: "eshtah ah shoh-VEHR", level: 2 }, + { pt: "Que horas são?", fr: "Quelle heure est-il ?", phonetic: "keh OH-rahsh sang", level: 2 }, + { pt: "Gostaria de reservar uma mesa", fr: "Je voudrais réserver une table", phonetic: "gosh-tah-REE-ah deh heh-zehr-VAHR OO-mah MEH-zah", level: 3 }, + { pt: "Tem menus em inglês?", fr: "Avez-vous un menu en anglais ?", phonetic: "teng meh-NOOSH een-GLAYSH", level: 3 }, + { pt: "Não tenho urgentemente isso", fr: "Je n'ai pas besoin de ça tout de suite", phonetic: "nahng TEH-nyoo oo-ZHEN-teh-MEN-teh EE-soo", level: 3 }, + { pt: "Poderia mostrar-me o caminho?", fr: "Pourriez-vous me montrer le chemin ?", phonetic: "poh-deh-REE-ah moh-SHRAR-meh oh kah-MEE-nyoo", level: 3 }, + { pt: "Estou perdido", fr: "Je suis perdu", phonetic: "eshtoh pehr-DEE-doo", level: 2 }, + { pt: "Quanto tempo faz que mora aqui?", fr: "Depuis combien de temps habitez-vous ici ?", phonetic: "KWAHN-too TEN-poo fahsh keh MOH-rah ah-KEE", level: 4 }, + { pt: "Adoraria visitar o Algarve", fr: "J'adorerais visiter l'Algarve", phonetic: "ah-doh-rah-REE-ah vee-zee-TAR oh AHL-gar-veh", level: 4 }, + { pt: "Portugal é um país maravilhoso", fr: "Le Portugal est un pays merveilleux", phonetic: "pohr-too-GAHL eh oom pah-EESH mah-rah-VEE-lyoo-zoo", level: 3 }, + ], + "Transport & Direction": [ + { pt: "Onde fica a estação de comboio?", fr: "Où est la gare ?", phonetic: "OHN-deh FEE-kah ah eshtah-SAHNG deh kohm-BOY-oo", level: 2 }, + { pt: "Quero ir para Lisboa", fr: "Je veux aller à Lisbonne", phonetic: "KEH-roo eer PAH-rah lee-SHBO-ah", level: 2 }, + { pt: "Quanto custa um bilhete?", fr: "Combien coûte un billet ?", phonetic: "KWAHN-too KOOSH-tah oom bee-LYEHT", level: 2 }, + { pt: "Gire à direita", fr: "Tournez à droite", phonetic: "ZHEE-reh ah dee-RAY-tah", level: 2 }, + { pt: "Gire à esquerda", fr: "Tournez à gauche", phonetic: "ZHEE-reh ah esh-KEHR-dah", level: 2 }, + { pt: "Em frente", fr: "Tout droit", phonetic: "eng FREHN-teh", level: 2 }, + { pt: "Virar à esquerda", fr: "Tourner à gauche", phonetic: "vee-RAR ah esh-KEHR-dah", level: 2 }, + { pt: "Virar à direita", fr: "Tourner à droite", phonetic: "vee-RAR ah dee-RAY-tah", level: 2 }, + { pt: "É muito longe?", fr: "C'est loin ?", phonetic: "eh MWEEN-too LOHN-zheh", level: 2 }, + { pt: "É perto daqui", fr: "C'est près d'ici", phonetic: "eh PEHR-too dah-KEE", level: 2 }, + { pt: "Preciso de um táxi", fr: "J'ai besoin d'un taxi", phonetic: "preh-SEE-zoo deh oom TAK-see", level: 2 }, + { pt: "Onde posso estacionar?", fr: "Où puis-je me garer ?", phonetic: "OHN-deh POH-soo eshtah-see-oh-NAR", level: 3 }, + { pt: "Qual é a melhor rota?", fr: "Quelle est la meilleure route ?", phonetic: "kwahl eh ah meh-LYOHR HOH-tah", level: 3 }, + { pt: "Estou com o carro avariado", fr: "Ma voiture est en panne", phonetic: "eshtoh koh-oo KAH-hoo ah-vah-ree-AH-doo", level: 3 }, + { pt: "Preciso de ajuda mecânica", fr: "J'ai besoin d'aide mécanique", phonetic: "preh-SEE-zoo deh ah-ZHOO-dah meh-KAH-nee-kah", level: 3 }, + ], + "Maison & Vocabulaire": [ + { pt: "Casa", fr: "Maison", phonetic: "KAH-zah", level: 1 }, + { pt: "Cozinha", fr: "Cuisine", phonetic: "koh-ZEE-nyah", level: 1 }, + { pt: "Quarto", fr: "Chambre", phonetic: "KWAHR-too", level: 1 }, + { pt: "Casa de banho", fr: "Salle de bain", phonetic: "KAH-zah deh bahn-YOO", level: 1 }, + { pt: "Sala", fr: "Salon", phonetic: "SAH-lah", level: 1 }, + { pt: "Jardim", fr: "Jardin", phonetic: "zhar-JEEN", level: 1 }, + { pt: "Porta", fr: "Porte", phonetic: "POHR-tah", level: 1 }, + { pt: "Janela", fr: "Fenêtre", phonetic: "zhah-NEH-lah", level: 1 }, + { pt: "Chave", fr: "Clé", phonetic: "SHAH-veh", level: 2 }, + { pt: "Mesa", fr: "Table", phonetic: "MEH-zah", level: 1 }, + { pt: "Cadeira", fr: "Chaise", phonetic: "kah-DAY-rah", level: 1 }, + { pt: "Cama", fr: "Lit", phonetic: "KAH-mah", level: 1 }, + { pt: "Sofá", fr: "Canapé", phonetic: "soh-FAH", level: 2 }, + { pt: "Televisão", fr: "Télévision", phonetic: "teh-leh-vee-ZAHNG", level: 2 }, + { pt: "Frigorífico", fr: "Réfrigérateur", phonetic: "free-goh-REE-fee-koo", level: 3 }, + { pt: "Máquina de lavar", fr: "Machine à laver", phonetic: "MAH-kee-nah deh lah-VAHR", level: 3 }, + { pt: "Aspirador", fr: "Aspirateur", phonetic: "ah-spee-rah-DOHR", level: 3 }, + { pt: "Tomada", fr: "Prise électrique", phonetic: "toh-MAH-dah", level: 2 }, + { pt: "Interruptor", fr: "Interrupteur", phonetic: "een-teh-rrup-TOHR", level: 3 }, + { pt: "Escadas", fr: "Escaliers", phonetic: "ehsh-KAH-dahsh", level: 2 }, + { pt: "Varanda", fr: "Balcon", phonetic: "vah-RAHN-dah", level: 3 }, + { pt: "Garagem", fr: "Garage", phonetic: "gah-RAH-zheng", level: 3 }, + { pt: "Arroz", fr: "Riz", phonetic: "ah-HOHSH", level: 1 }, + { pt: "Sala de estar", fr: "Pièce de vie", phonetic: "SAH-lah deh esh-TAR", level: 2 }, + ], + "Vêtements": [ + { pt: "Camisa", fr: "Chemise", phonetic: "kah-MEE-zah", level: 1 }, + { pt: "Calças", fr: "Pantalon", phonetic: "KAHL-sahsh", level: 1 }, + { pt: "Vestido", fr: "Robe", phonetic: "vehs-TEE-doo", level: 1 }, + { pt: "Sapatos", fr: "Chaussures", phonetic: "sah-PAH-toosh", level: 1 }, + { pt: "Casaco", fr: "Manteau", phonetic: "kah-ZAH-koo", level: 1 }, + { pt: "Chapéu", fr: "Chapeau", phonetic: "shah-PEH-oo", level: 1 }, + { pt: "Meias", fr: "Chaussettes", phonetic: "MAY-ahsh", level: 1 }, + { pt: "Cintura", fr: "Ceinture", phonetic: "seen-TOO-rah", level: 1 }, + { pt: "Bota", fr: "Bottes", phonetic: "BOH-tah", level: 2 }, + { pt: "Fato de banho", fr: "Maillot de bain", phonetic: "FAH-too deh bahn-YOO", level: 2 }, + { pt: "Pijama", fr: "Pyjama", phonetic: "pee-ZHAH-mah", level: 2 }, + { pt: "Blusa", fr: "Pull-over", phonetic: "BLOO-zah", level: 2 }, + { pt: "Saia", fr: "Jupe", phonetic: "SAH-ee-ah", level: 2 }, + { pt: "Ténis", fr: "Baskets", phonetic: "TEH-nish", level: 2 }, + { pt: "Bermudas", fr: "Shorts", phonetic: "behr-MOO-dahsh", level: 2 }, + { pt: "Gorro", fr: "Bonnet", phonetic: "GOH-hoo", level: 3 }, + { pt: "Cachecol", fr: "Écharpe", phonetic: "kah-sheh-KOHL", level: 3 }, + { pt: "Luvas", fr: "Gants", phonetic: "LOO-vahsh", level: 3 }, + { pt: "Sunga", fr: "Maillot de bain (homme)", phonetic: "SOONG-gah", level: 3 }, + { pt: "Brinco", fr: "Boucle d'oreille", phonetic: "BREEN-koo", level: 4 }, + ], + "Temps & Météo": [ + { pt: "Está sol", fr: "Il fait soleil", phonetic: "eshtah sohl", level: 1 }, + { pt: "Está a chover", fr: "Il pleut", phonetic: "eshtah ah shoh-VEHR", level: 1 }, + { pt: "Está frio", fr: "Il fait froid", phonetic: "eshtah FREE-oh", level: 1 }, + { pt: "Está quente", fr: "Il fait chaud", phonetic: "eshtah KWEN-teh", level: 1 }, + { pt: "Está ventoso", fr: "Il fait venteux", phonetic: "eshtah ven-TOH-zoo", level: 2 }, + { pt: "Está nublado", fr: "Il est nuageux", phonetic: "eshtah noo-BLAH-doo", level: 2 }, + { pt: "Está nevoeiro", fr: "Il y a du brouillard", phonetic: "eshtah neh-voh-AY-roh", level: 3 }, + { pt: "Granizo", fr: "Grêle", phonetic: "grah-NEE-zoo", level: 3 }, + { pt: "Trovoada", fr: "Orage", phonetic: "troh-voh-AH-dah", level: 3 }, + { pt: "Arco-íris", fr: "Arc-en-ciel", phonetic: "AR-koh-EE-rish", level: 3 }, + { pt: "Temperatura", fr: "Température", phonetic: "ten-peh-rah-TOO-rah", level: 2 }, + { pt: "Quente", fr: "Chaud", phonetic: "KWEN-teh", level: 1 }, + { pt: "Frio", fr: "Froid", phonetic: "FREE-oh", level: 1 }, + { pt: "Chuvoso", fr: "Pluvieux", phonetic: "shoo-VOH-zoo", level: 2 }, + { pt: "Ensolarado", fr: "Ensoleillé", phonetic: "en-soh-leh-AH-doo", level: 2 }, + ], + "Corps": [ + { pt: "Cabeça", fr: "Tête", phonetic: "kah-BEH-sah", level: 1 }, + { pt: "Olhos", fr: "Yeux", phonetic: "OH-lyohsh", level: 1 }, + { pt: "Boca", fr: "Bouche", phonetic: "BOH-kah", level: 1 }, + { pt: "Mão", fr: "Main", phonetic: "mahng", level: 1 }, + { pt: "Pé", fr: "Pied", phonetic: "peh", level: 1 }, + { pt: "Orelha", fr: "Oreille", phonetic: "oh-REH-lyah", level: 1 }, + { pt: "Nariz", fr: "Nez", phonetic: "nah-REESH", level: 1 }, + { pt: "Barriga", fr: "Ventre", phonetic: "bah-REE-gah", level: 1 }, + { pt: "Braço", fr: "Bras", phonetic: "BRAH-soo", level: 2 }, + { pt: "Perna", fr: "Jambe", phonetic: "PEHR-nah", level: 2 }, + { pt: "Costas", fr: "Dos", phonetic: "KOHSH-tahsh", level: 2 }, + { pt: "Ombro", fr: "Épaule", phonetic: "OHM-broo", level: 2 }, + { pt: "Joelho", fr: "Genou", phonetic: "zhoh-EH-lyoo", level: 2 }, + { pt: "Dedo", fr: "Doigt", phonetic: "DEH-doo", level: 1 }, + { pt: "Dentes", fr: "Dents", phonetic: "DEN-tehsh", level: 2 }, + { pt: "Língua", fr: "Langue", phonetic: "LEENG-gwah", level: 2 }, + { pt: "Pescoço", fr: "Cou", phonetic: "pesh-KOH-soo", level: 3 }, + { pt: "Quadril", fr: "Hanche", phonetic: "kwah-DREEL", level: 4 }, + { pt: "Tornozelo", fr: "Cheville", phonetic: "tohr-noh-ZEH-loo", level: 4 }, + { pt: "Pulso", fr: "Poignet", phonetic: "POOL-soo", level: 3 }, + ], + "Professions": [ + { pt: "Médico", fr: "Médecin", phonetic: "MEH-dee-koo", level: 2 }, + { pt: "Enfermeiro", fr: "Infirmier", phonetic: "en-fehr-MAY-roh", level: 2 }, + { pt: "Professor", fr: "Professeur", phonetic: "proh-feh-SOHR", level: 2 }, + { pt: "Engenheiro", fr: "Ingénieur", phonetic: "en-zheh-NYAY-roh", level: 3 }, + { pt: "Advogado", fr: "Avocat", phonetic: "ahd-voh-GAH-doo", level: 3 }, + { pt: "Policia", fr: "Policier", phonetic: "poh-LEE-see-ah", level: 2 }, + { pt: "Bombeiro", fr: "Pompier", phonetic: "bohm-BAY-roh", level: 2 }, + { pt: "Cozinheiro", fr: "Cuisinier", phonetic: "koh-zee-NYAY-roh", level: 2 }, + { pt: "Motorista", fr: "Chauffeur", phonetic: "moh-toh-REES-tah", level: 2 }, + { pt: "Vendedor", fr: "Vendeur", phonetic: "ven-deh-DOHR", level: 3 }, + { pt: "Jornalista", fr: "Journaliste", phonetic: "zhohr-nah-LEES-tah", level: 3 }, + { pt: "Arquitecto", fr: "Architecte", phonetic: "ar-kee-TEHK-too", level: 4 }, + { pt: "Dentista", fr: "Dentiste", phonetic: "den-TEES-tah", level: 2 }, + { pt: "Farmacêutico", fr: "Pharmacien", phonetic: "far-mah-SEW-tee-koo", level: 4 }, + { pt: "Padeiro", fr: "Boulanger", phonetic: "pah-DAY-roh", level: 3 }, + ], + "Vocabulaire Bébé (3 ans)": [ + { pt: "Mãe", fr: "Maman", emoji: "👩", level: 1 }, + { pt: "Pai", fr: "Papa", emoji: "👨", level: 1 }, + { pt: "Cãozinho", fr: "Petit chien", emoji: "🐶", level: 1 }, + { pt: "Gatinho", fr: "Petit chat", emoji: "🐱", level: 1 }, + { pt: "Bola", fr: "Ballon", emoji: "⚽", level: 1 }, + { pt: "Brinquedo", fr: "Jouet", emoji: "🧸", level: 1 }, + { pt: "Boneca", fr: "Poupée", emoji: "🪆", level: 1 }, + { pt: "Carro", fr: "Voiture", emoji: "🚗", level: 1 }, + { pt: "Beijinho", fr: "Petit bisou", emoji: "💋", level: 1 }, + { pt: "Eu amo-te", fr: "Je t'aime", emoji: "❤️", level: 1 }, + { pt: "Obrigadinho", fr: "Petit merci", emoji: "🙏", level: 1 }, + { pt: "Lindo", fr: "Joli", emoji: "✨", level: 1 }, + { pt: "Grande", fr: "Grand", emoji: "📏", level: 1 }, + { pt: "Pequenino", fr: "Petit", emoji: "🤏", level: 1 }, + { pt: "Quente", fr: "Chaud", emoji: "🔥", level: 1 }, + { pt: "Frio", fr: "Froid", emoji: "❄️", level: 1 }, + ], + "Expressions courantes": [ + { pt: "Claro que sim", fr: "Bien sûr que oui", phonetic: "KLAH-roo keh seeng", level: 2 }, + { pt: "Se calhar", fr: "Peut-être", phonetic: "seh kah-LYAR", level: 2 }, + { pt: "Ena!", fr: "Waouh !", phonetic: "EH-nah", level: 2 }, + { pt: "Fixe!", fr: "Génial !", phonetic: "FEESH", level: 2 }, + { pt: "Bué!", fr: "Beaucoup !", phonetic: "BEH", level: 2 }, + { pt: "Ya!", fr: "Oui (familier)", phonetic: "YAH", level: 2 }, + { pt: "Pois é", fr: "C'est vrai", phonetic: "poysh eh", level: 3 }, + { pt: "Olha só", fr: "Regarde", phonetic: "OH-lyah SOH", level: 2 }, + { pt: "Queres saber?", fr: "Tu veux savoir ?", phonetic: "KEH-rehsh sah-BEHR", level: 3 }, + { pt: "Eu sei lá", fr: "Je ne sais pas", phonetic: "eh seh LAH", level: 3 }, + { pt: "Tem piada", fr: "C'est drôle", phonetic: "teng pee-AH-dah", level: 3 }, + { pt: "Estou a gozar", fr: "Je plaisante", phonetic: "eshtoh ah goh-ZAR", level: 3 }, + { pt: "Ficar a ver navios", fr: "Rester sur sa faim", phonetic: "fee-KAR ah vehr nah-VEE-ohsh", level: 4 }, + { pt: "Meter a conversa em marcha", fr: "Lancer la conversation", phonetic: "MEH-tehr ah kohn-VEHR-sah eng MAR-shah", level: 4 }, + ], +}; + +// ===== ALL GAME DATA ===== +const COLORS_GAME = [ + { color: "#CC0000", pt: "Vermelho", fr: "Rouge" }, + { color: "#0066CC", pt: "Azul", fr: "Bleu" }, + { color: "#009900", pt: "Verde", fr: "Vert" }, + { color: "#FFCC00", pt: "Amarelo", fr: "Jaune" }, + { color: "#FFFFFF", pt: "Branco", fr: "Blanc", border: true }, + { color: "#1a1a2e", pt: "Preto", fr: "Noir" }, + { color: "#FF69B4", pt: "Rosa", fr: "Rose" }, + { color: "#FF8C00", pt: "Laranja", fr: "Orange" }, + { color: "#8B4513", pt: "Castanho", fr: "Marron" }, + { color: "#800080", pt: "Roxo", fr: "Violet" }, + { color: "#808080", pt: "Cinza", fr: "Gris" }, + { color: "#FFD700", pt: "Dourado", fr: "Doré" }, +]; + +const ANIMALS_GAME = [ + { emoji: "🐕", pt: "Cão", fr: "Chien" }, + { emoji: "🐈", pt: "Gato", fr: "Chat" }, + { emoji: "🐦", pt: "Pássaro", fr: "Oiseau" }, + { emoji: "🐟", pt: "Peixe", fr: "Poisson" }, + { emoji: "🐇", pt: "Coelho", fr: "Lapin" }, + { emoji: "🐴", pt: "Cavalo", fr: "Cheval" }, + { emoji: "🐄", pt: "Vaca", fr: "Vache" }, + { emoji: "🐔", pt: "Galinha", fr: "Poule" }, + { emoji: "🦋", pt: "Borboleta", fr: "Papillon" }, + { emoji: "🐜", pt: "Formiga", fr: "Fourmi" }, + { emoji: "🐢", pt: "Tartaruga", fr: "Tortue" }, + { emoji: "🐒", pt: "Macaco", fr: "Singe" }, + { emoji: "🐘", pt: "Elefante", fr: "Éléphant" }, + { emoji: "🦁", pt: "Leão", fr: "Lion" }, + { emoji: "🐻", pt: "Urso", fr: "Ours" }, + { emoji: "🐸", pt: "Rã", fr: "Grenouille" }, + { emoji: "🐑", pt: "Ovelha", fr: "Mouton" }, + { emoji: "🐷", pt: "Porco", fr: "Cochon" }, + { emoji: "🦆", pt: "Pato", fr: "Canard" }, + { emoji: "🐋", pt: "Baleia", fr: "Baleine" }, + { emoji: "🦒", pt: "Girafa", fr: "Girafe" }, + { emoji: "🐊", pt: "Crocodilo", fr: "Crocodile" }, + { emoji: "🦊", pt: "Raposa", fr: "Renard" }, + { emoji: "🐺", pt: "Lobo", fr: "Loup" }, + { emoji: "🦅", pt: "Águia", fr: "Aigle" }, + { emoji: "🦉", pt: "Coruja", fr: "Hibou" }, +]; + +const NUMBERS_GAME = [ + { num: 1, pt: "Um", fr: "Un" }, { num: 2, pt: "Dois", fr: "Deux" }, + { num: 3, pt: "Três", fr: "Trois" }, { num: 4, pt: "Quatro", fr: "Quatre" }, + { num: 5, pt: "Cinco", fr: "Cinq" }, { num: 6, pt: "Seis", fr: "Six" }, + { num: 7, pt: "Sete", fr: "Sept" }, { num: 8, pt: "Oito", fr: "Huit" }, + { num: 9, pt: "Nove", fr: "Neuf" }, { num: 10, pt: "Dez", fr: "Dix" }, + { num: 11, pt: "Onze", fr: "Onze" }, { num: 12, pt: "Doze", fr: "Douze" }, + { num: 13, pt: "Treze", fr: "Treize" }, { num: 14, pt: "Catorze", fr: "Quatorze" }, + { num: 15, pt: "Quinze", fr: "Quinze" }, { num: 20, pt: "Vinte", fr: "Vingt" }, + { num: 30, pt: "Trinta", fr: "Trente" }, { num: 50, pt: "Cinquenta", fr: "Cinquante" }, + { num: 100, pt: "Cem", fr: "Cent" }, { num: 1000, pt: "Mil", fr: "Mille" }, +]; + +const VOCAB = { + "Vêtements": [ + { pt: "Camisa", fr: "Chemise", emoji: "👔", level: 1 }, + { pt: "Calças", fr: "Pantalon", emoji: "👖", level: 1 }, + { pt: "Vestido", fr: "Robe", emoji: "👗", level: 1 }, + { pt: "Sapatos", fr: "Chaussures", emoji: "👟", level: 1 }, + { pt: "Casaco", fr: "Manteau", emoji: "🧥", level: 1 }, + { pt: "Chapéu", fr: "Chapeau", emoji: "🎩", level: 1 }, + { pt: "Bota", fr: "Bottes", emoji: "👢", level: 2 }, + { pt: "Ténis", fr: "Baskets", emoji: "🏃", level: 2 }, + { pt: "Blusa", fr: "Pull", emoji: "🧶", level: 2 }, + { pt: "Fato de banho", fr: "Maillot de bain", emoji: "🩱", level: 2 }, + { pt: "Pijama", fr: "Pyjama", emoji: "😴", level: 2 }, + ], + "Maison": [ + { pt: "Casa", fr: "Maison", emoji: "🏠", level: 1 }, + { pt: "Cozinha", fr: "Cuisine", emoji: "🍳", level: 1 }, + { pt: "Quarto", fr: "Chambre", emoji: "🛏️", level: 1 }, + { pt: "Casa de banho", fr: "Salle de bain", emoji: "🚿", level: 1 }, + { pt: "Sala", fr: "Salon", emoji: "🛋️", level: 1 }, + { pt: "Jardim", fr: "Jardin", emoji: "🌻", level: 1 }, + { pt: "Porta", fr: "Porte", emoji: "🚪", level: 1 }, + { pt: "Janela", fr: "Fenêtre", emoji: "🪟", level: 1 }, + { pt: "Chave", fr: "Clé", emoji: "🔑", level: 2 }, + { pt: "Mesa", fr: "Table", emoji: "🪑", level: 1 }, + { pt: "Cama", fr: "Lit", emoji: "🛏️", level: 1 }, + { pt: "Frigorífico", fr: "Réfrigérateur", emoji: "🧊", level: 3 }, + { pt: "Varanda", fr: "Balcon", emoji: "🌿", level: 3 }, + { pt: "Garagem", fr: "Garage", emoji: "🚗", level: 3 }, + ], + "Corps": [ + { pt: "Cabeça", fr: "Tête", emoji: "🗣️", level: 1 }, + { pt: "Olhos", fr: "Yeux", emoji: "👀", level: 1 }, + { pt: "Boca", fr: "Bouche", emoji: "👄", level: 1 }, + { pt: "Mão", fr: "Main", emoji: "✋", level: 1 }, + { pt: "Pé", fr: "Pied", emoji: "🦶", level: 1 }, + { pt: "Braço", fr: "Bras", emoji: "💪", level: 2 }, + { pt: "Perna", fr: "Jambe", emoji: "🦵", level: 2 }, + { pt: "Dedo", fr: "Doigt", emoji: "☝️", level: 1 }, + { pt: "Dentes", fr: "Dents", emoji: "🦷", level: 2 }, + { pt: "Joelho", fr: "Genou", emoji: "🦿", level: 2 }, + { pt: "Ombro", fr: "Épaule", emoji: "🏋️", level: 2 }, + ], + "Nature": [ + { pt: "Sol", fr: "Soleil", emoji: "☀️", level: 1 }, + { pt: "Lua", fr: "Lune", emoji: "🌙", level: 1 }, + { pt: "Estrela", fr: "Étoile", emoji: "⭐", level: 1 }, + { pt: "Árvore", fr: "Arbre", emoji: "🌳", level: 1 }, + { pt: "Flor", fr: "Fleur", emoji: "🌺", level: 1 }, + { pt: "Mar", fr: "Mer", emoji: "🌊", level: 1 }, + { pt: "Montanha", fr: "Montagne", emoji: "⛰️", level: 1 }, + { pt: "Rio", fr: "Rivière", emoji: "🏞️", level: 1 }, + { pt: "Nuvem", fr: "Nuage", emoji: "☁️", level: 2 }, + { pt: "Chuva", fr: "Pluie", emoji: "🌧️", level: 2 }, + { pt: "Neve", fr: "Neige", emoji: "❄️", level: 2 }, + { pt: "Ventania", fr: "Vent", emoji: "💨", level: 2 }, + { pt: "Relâmpago", fr: "Éclair", emoji: "⚡", level: 3 }, + { pt: "Arco-íris", fr: "Arc-en-ciel", emoji: "🌈", level: 3 }, + { pt: "Areia", fr: "Sable", emoji: "🏖️", level: 2 }, + { pt: "Pedra", fr: "Pierre", emoji: "🪨", level: 2 }, + ], + "Nourriture": [ + { pt: "Maçã", fr: "Pomme", emoji: "🍎", level: 1 }, + { pt: "Banana", fr: "Banane", emoji: "🍌", level: 1 }, + { pt: "Laranja", fr: "Orange", emoji: "🍊", level: 1 }, + { pt: "Leite", fr: "Lait", emoji: "🥛", level: 1 }, + { pt: "Queijo", fr: "Fromage", emoji: "🧀", level: 1 }, + { pt: "Pão", fr: "Pain", emoji: "🍞", level: 1 }, + { pt: "Bolo", fr: "Gâteau", emoji: "🎂", level: 1 }, + { pt: "Gelado", fr: "Glace", emoji: "🍦", level: 1 }, + { pt: "Manteiga", fr: "Beurre", emoji: "🧈", level: 2 }, + { pt: "Ovos", fr: "Œufs", emoji: "🥚", level: 2 }, + { pt: "Batatas", fr: "Pommes de terre", emoji: "🥔", level: 2 }, + { pt: "Cenouras", fr: "Carottes", emoji: "🥕", level: 2 }, + { pt: "Alho", fr: "Ail", emoji: "🧄", level: 2 }, + { pt: "Cebola", fr: "Oignon", emoji: "🧅", level: 2 }, + { pt: "Vinho", fr: "Vin", emoji: "🍷", level: 3 }, + { pt: "Cerveja", fr: "Bière", emoji: "🍺", level: 3 }, + { pt: "Melancia", fr: "Pastèque", emoji: "🍉", level: 2 }, + { pt: "Morangos", fr: "Fraises", emoji: "🍓", level: 2 }, + { pt: "Uvas", fr: "Raisins", emoji: "🍇", level: 2 }, + { pt: "Abacaxi", fr: "Ananas", emoji: "🍍", level: 2 }, + ], + "Bébé (3 ans)": [ + { pt: "Mãe", fr: "Maman", emoji: "👩", level: 1 }, + { pt: "Pai", fr: "Papa", emoji: "👨", level: 1 }, + { pt: "Cãozinho", fr: "Petit chien", emoji: "🐶", level: 1 }, + { pt: "Gatinho", fr: "Petit chat", emoji: "🐱", level: 1 }, + { pt: "Bola", fr: "Ballon", emoji: "⚽", level: 1 }, + { pt: "Brinquedo", fr: "Jouet", emoji: "🧸", level: 1 }, + { pt: "Boneca", fr: "Poupée", emoji: "🪆", level: 1 }, + { pt: "Carro", fr: "Voiture", emoji: "🚗", level: 1 }, + { pt: "Beijinho", fr: "Petit bisou", emoji: "💋", level: 1 }, + { pt: "Eu amo-te", fr: "Je t'aime", emoji: "❤️", level: 1 }, + { pt: "Lindo", fr: "Joli", emoji: "✨", level: 1 }, + { pt: "Grande", fr: "Grand", emoji: "📏", level: 1 }, + ], + "Transports": [ + { pt: "Carro", fr: "Voiture", emoji: "🚗", level: 1 }, + { pt: "Autocarro", fr: "Bus", emoji: "🚌", level: 1 }, + { pt: "Comboio", fr: "Train", emoji: "🚂", level: 1 }, + { pt: "Avião", fr: "Avion", emoji: "✈️", level: 1 }, + { pt: "Barco", fr: "Bateau", emoji: "🚢", level: 1 }, + { pt: "Bicicleta", fr: "Vélo", emoji: "🚲", level: 1 }, + { pt: "Táxi", fr: "Taxi", emoji: "🚕", level: 1 }, + { pt: "Motocicleta", fr: "Moto", emoji: "🏍️", level: 2 }, + { pt: "Embarcação", fr: "Embarcation", emoji: "⛵", level: 3 }, + { pt: "Helicóptero", fr: "Hélicoptère", emoji: "🚁", level: 3 }, + ], +}; + +// ===== EXERCISES PAR NIVEAU ===== +const EXERCISES_FILL = [ + // Niveau 1 + { template: "Bom ___!", answer: "dia", options: ["dia", "noite", "tarde", "bom"], hint: "Bonjour (matin)", level: 1 }, + { template: "Tudo ___?", answer: "bem", options: ["bem", "mal", "bom", "mau"], hint: "Comment vas-tu ?", level: 1 }, + { template: "___-me Chico.", answer: "Chamo", options: ["Chamo", "Falo", "Digo", "Ponho"], hint: "Je m'appelle...", level: 1 }, + { template: "Eu ___ português.", answer: "falo", options: ["falo", "digo", "como", "ponho"], hint: "Je parle portugais", level: 1 }, + { template: "___ favor.", answer: "Por", options: ["Por", "De", "Em", "Com"], hint: "S'il vous plaît", level: 1 }, + { template: "Muito ___.", answer: "obrigado", options: ["obrigado", "desculpe", "por favor", "por nada"], hint: "Merci beaucoup", level: 1 }, + { template: "Onde ___ a casa de banho?", answer: "fica", options: ["fica", "é", "está", "tem"], hint: "Où sont les toilettes ?", level: 1 }, + { template: "Eu tenho ___.", answer: "fome", options: ["fome", "sede", "sono", "medo"], hint: "J'ai faim", level: 1 }, + { template: "___ te chamas?", answer: "Como", options: ["Como", "Onde", "Quando", "Porque"], hint: "Comment tu t'appelles ?", level: 1 }, + { template: "___ é a tua casa?", answer: "Onde", options: ["Onde", "Como", "Quando", "Porque"], hint: "Où est ta maison ?", level: 1 }, + { template: "Eu ___ um café.", answer: "quero", options: ["quero", "tenho", "posso", "vou"], hint: "Je veux un café", level: 1 }, + { template: "___ custa?", answer: "Quanto", options: ["Quanto", "Como", "Onde", "Quando"], hint: "Combien ?", level: 1 }, + { template: "Sim, ___ tudo bem.", answer: "tudo", options: ["tudo", "nada", "bom", "mau"], hint: "Oui, tout va bien", level: 1 }, + { template: "Eu ___ de Lisboa.", answer: "sou", options: ["sou", "estou", "tenho", "vou"], hint: "Je suis de Lisbonne", level: 1 }, + { template: "___ é muito bom.", answer: "Isto", options: ["Isto", "Isso", "Aquilo", "Ele"], hint: "C'est très bon", level: 1 }, + // Niveau 2 + { template: "___ português há dois anos.", answer: "Falo", options: ["Falo", "Digo", "Sei", "Conheço"], hint: "Je parle portugais depuis 2 ans", level: 2 }, + { template: "Eu ___ ao trabalho de autocarro.", answer: "vou", options: ["vou", "venho", "estou", "tenho"], hint: "Je vais au travail en bus", level: 2 }, + { template: "___ uma mesa para dois, por favor.", answer: "Quero", options: ["Quero", "Tenho", "Preciso", "Vou"], hint: "Une table pour deux", level: 2 }, + { template: "___ fala mais devagar, por favor.", answer: "Pode", options: ["Pode", "Deve", "Quer", "Vai"], hint: "Parlez plus lentement", level: 2 }, + { template: "Estou a ___ português.", answer: "aprender", options: ["aprender", "estudar", "falar", "dizer"], hint: "J'apprends le portugais", level: 2 }, + { template: "___ horas são agora?", answer: "Que", options: ["Que", "Como", "Onde", "Quanto"], hint: "Quelle heure est-il ?", level: 2 }, + { template: "A chuva ___ a cair.", answer: "está", options: ["está", "vai", "tem", "faz"], hint: "Il pleut", level: 2 }, + { template: "Eu ___ Lisboa muito.", answer: "gosto", options: ["gosto", "quero", "tenho", "faço"], hint: "J'aime beaucoup Lisbonne", level: 2 }, + { template: "___ tempo faz hoje?", answer: "Que", options: ["Que", "Como", "Quanto", "Quando"], hint: "Quel temps fait-il ?", level: 2 }, + { template: "Preciso de ___ o médico.", answer: "ver", options: ["ver", "vir", "virar", "viver"], hint: "J'ai besoin de voir le médecin", level: 2 }, + // Niveau 3 + { template: "Gostaria de ___ uma reserva.", answer: "fazer", options: ["fazer", "dar", "ter", "pôr"], hint: "Je voudrais faire une réservation", level: 3 }, + { template: "___ que me pode ajudar.", answer: "Penso", options: ["Penso", "Creio", "Digo", "Sei"], hint: "Je pense que vous pouvez m'aider", level: 3 }, + { template: "Eu ___ a Lisboa no próximo mês.", answer: "vou", options: ["vou", "estou", "tenho", "faço"], hint: "Je vais à Lisbonne le mois prochain", level: 3 }, + { template: "___ um bocadinho mais devagar.", answer: "Fale", options: ["Fale", "Diga", "Diga", "Ponha"], hint: "Parlez un peu plus lentement", level: 3 }, + { template: "Já ___ ao Brasil.", answer: "fui", options: ["fui", "vou", "estive", "andei"], hint: "J'ai déjà été au Brésil", level: 3 }, + // Niveau 4 + { template: "___ dispenso a sua ajuda.", answer: "Agradeço", options: ["Agradeço", "Preciso", "Quero", "Aceito"], hint: "Je vous remercie mais je n'ai pas besoin d'aide", level: 4 }, + { template: "Não me ___ com isso.", answer: "incomode", options: ["incomode", "preocupe", "chame", "ligue"], hint: "Ne vous dérangez pas", level: 4 }, + { template: "Tem ___ que me emprestar o carro?", level: 4, answer: "bondade", options: ["bondade", "gusto", "razão", "certeza"], hint: "Avez-vous la gentillesse de me prêter la voiture ?", level: 4 }, +]; + +const EXERCISES_LISTEN = [ + { pt: "Olá", fr: "Bonjour", level: 1 }, + { pt: "Bom dia", fr: "Bonjour (matin)", level: 1 }, + { pt: "Obrigado", fr: "Merci (homme)", level: 1 }, + { pt: "Por favor", fr: "S'il vous plaît", level: 1 }, + { pt: "Como vai?", fr: "Comment vas-tu?", level: 1 }, + { pt: "Até logo", fr: "À bientôt", level: 1 }, + { pt: "Boa noite", fr: "Bonsoir", level: 1 }, + { pt: "Desculpe", fr: "Excusez-moi", level: 1 }, + { pt: "Tudo bem", fr: "Tout va bien", level: 1 }, + { pt: "Adeus", fr: "Au revoir", level: 1 }, + { pt: "Sim", fr: "Oui", level: 1 }, + { pt: "Não", fr: "Non", level: 1 }, + { pt: "Água", fr: "Eau", level: 1 }, + { pt: "Pão", fr: "Pain", level: 1 }, + { pt: "Casa", fr: "Maison", level: 1 }, + { pt: "Onde fica?", fr: "Où est-ce?", level: 2 }, + { pt: "Quanto custa?", fr: "Combien ça coûte?", level: 2 }, + { pt: "Tenho fome", fr: "J'ai faim", level: 2 }, + { pt: "Estou perdido", fr: "Je suis perdu", level: 2 }, + { pt: "Pode repetir?", fr: "Pouvez-vous répéter?", level: 2 }, + { pt: "Bom apetite!", fr: "Bon appétit!", level: 1 }, + { pt: "Com licença", fr: "Avec votre permission", level: 2 }, + { pt: "É muito bom", fr: "C'est très bon", level: 2 }, + { pt: "Que horas são?", fr: "Quelle heure est-il?", level: 2 }, + { pt: "Está a chover", fr: "Il pleut", level: 2 }, + { pt: "Gosto muito de Portugal", fr: "J'aime beaucoup le Portugal", level: 3 }, + { pt: "Faz calor hoje", fr: "Il fait chaud aujourd'hui", level: 2 }, + { pt: "Onde é a estação?", fr: "Où est la gare?", level: 2 }, + { pt: "Preciso de ajuda", fr: "J'ai besoin d'aide", level: 2 }, + { pt: "Tem menus em inglês?", fr: "Avez-vous un menu en anglais?", level: 3 }, + { pt: "Gostaria de reservar", fr: "Je voudrais réserver", level: 3 }, + { pt: "Estou a aprender português", fr: "J'apprends le portugais", level: 2 }, +]; + +const EXERCISES_TRANSLATE = [ + { fr: "Bonjour", pt: "Olá", level: 1 }, + { fr: "Merci", pt: "Obrigado", level: 1 }, + { fr: "S'il vous plaît", pt: "Por favor", level: 1 }, + { fr: "Comment vas-tu?", pt: "Como vais?", level: 1 }, + { fr: "Au revoir", pt: "Adeus", level: 1 }, + { fr: "Oui", pt: "Sim", level: 1 }, + { fr: "Non", pt: "Não", level: 1 }, + { fr: "Bon appétit!", pt: "Bom apetite!", level: 1 }, + { fr: "Je m'appelle...", pt: "Chamo-me...", level: 1 }, + { fr: "D'où es-tu?", pt: "De onde és?", level: 1 }, + { fr: "Où est...?", pt: "Onde fica...?", level: 1 }, + { fr: "Combien ça coûte?", pt: "Quanto custa?", level: 1 }, + { fr: "J'ai faim", pt: "Tenho fome", level: 2 }, + { fr: "J'ai soif", pt: "Tenho sede", level: 2 }, + { fr: "Je ne comprends pas", pt: "Não compreendo", level: 2 }, + { fr: "Il fait chaud", pt: "Está quente", level: 2 }, + { fr: "Il pleut", pt: "Está a chover", level: 2 }, + { fr: "Quelle heure est-il?", pt: "Que horas são?", level: 2 }, + { fr: "Je suis perdu", pt: "Estou perdido", level: 2 }, + { fr: "Pouvez-vous m'aider?", pt: "Pode ajudar-me?", level: 2 }, + { fr: "Parlez plus lentement", pt: "Fale mais devagar", level: 3 }, + { fr: "J'apprends le portugais", pt: "Estou a aprender português", level: 2 }, + { fr: "Je voudrais réserver", pt: "Gostaria de reservar", level: 3 }, + { fr: "C'est délicieux", pt: "Está delicioso", level: 2 }, + { fr: "Je veux un café", pt: "Eu quero um café", level: 2 }, + { fr: "Excusez-moi", pt: "Com licença", level: 2 }, + { fr: "La l'addition", pt: "A conta", level: 2 }, + { fr: "C'est loin?", pt: "É muito longe?", level: 2 }, + { fr: "C'est près", pt: "É perto", level: 2 }, + { fr: "Où sont les toilettes?", pt: "Onde fica a casa de banho?", level: 2 }, + { fr: "Avez-vous un menu?", pt: "Tem uma ementa?", level: 3 }, + { fr: "Je voudrais une table", pt: "Gostaria de uma mesa", level: 3 }, + { fr: "Il fait froid", pt: "Está frio", level: 1 }, + { fr: "Il fait soleil", pt: "Está sol", level: 1 }, + { fr: "Il fait vent", pt: "Está ventoso", level: 2 }, + { fr: "Merci beaucoup", pt: "Muito obrigado", level: 1 }, + { fr: "De rien", pt: "De nada", level: 1 }, + { fr: "Enchanté", pt: "Prazer", level: 1 }, + { fr: "Bonne nuit", pt: "Boa noite", level: 1 }, + { fr: "À demain", pt: "Até amanhã", level: 1 }, +]; + +const CONJUGATIONS = [ + { verb: "Ser (être - permanent)", forms: { eu: "sou", tu: "és", ele: "é", nós: "somos", eles: "são" }, meaning: "Être (nature, identité)", level: 1 }, + { verb: "Estar (être - état)", forms: { eu: "estou", tu: "estás", ele: "está", nós: "estamos", eles: "estão" }, meaning: "Être (état, lieu)", level: 1 }, + { verb: "Ter (avoir)", forms: { eu: "tenho", tu: "tens", ele: "tem", nós: "temos", eles: "têm" }, meaning: "Avoir", level: 1 }, + { verb: "Falar (parler)", forms: { eu: "falo", tu: "falas", ele: "fala", nós: "falamos", eles: "falam" }, meaning: "Parler", level: 1 }, + { verb: "Gostar (aimer)", forms: { eu: "gosto", tu: "gostas", ele: "gosta", nós: "gostamos", eles: "gostam" }, meaning: "Aimer", level: 1 }, + { verb: "Ir (aller)", forms: { eu: "vou", tu: "vais", ele: "vai", nós: "vamos", eles: "vão" }, meaning: "Aller", level: 1 }, + { verb: "Poder (pouvoir)", forms: { eu: "posso", tu: "podes", ele: "pode", nós: "podemos", eles: "podem" }, meaning: "Pouvoir", level: 2 }, + { verb: "Querer (vouloir)", forms: { eu: "quero", tu: "queres", ele: "quer", nós: "queremos", eles: "querem" }, meaning: "Vouloir", level: 1 }, + { verb: "Saber (savoir)", forms: { eu: "sei", tu: "sabes", ele: "sabe", nós: "sabemos", eles: "sabem" }, meaning: "Savoir (faits)", level: 2 }, + { verb: "Conhecer (connaître)", forms: { eu: "conheço", tu: "conheces", ele: "conhece", nós: "conhecemos", eles: "conhecem" }, meaning: "Connaître (personnes, lieux)", level: 2 }, + { verb: "Fazer (faire)", forms: { eu: "faço", tu: "fazes", ele: "faz", nós: "fazemos", eles: "fazem" }, meaning: "Faire", level: 2 }, + { verb: "Dizer (dire)", forms: { eu: "digo", tu: "dizes", ele: "diz", nós: "dizemos", eles: "dizem" }, meaning: "Dire", level: 2 }, + { verb: "Vir (venir)", forms: { eu: "venho", tu: "vens", ele: "vem", nós: "vimos", eles: "vêm" }, meaning: "Venir", level: 2 }, + { verb: "Dar (donner)", forms: { eu: "dou", tu: "dás", ele: "dá", nós: "damos", eles: "dão" }, meaning: "Donner", level: 2 }, + { verb: "Ver (voir)", forms: { eu: "vejo", tu: "ves", ele: "vê", nós: "vemos", eles: "veem" }, meaning: "Voir", level: 2 }, + { verb: "Trabalhar (travailler)", forms: { eu: "trabalho", tu: "trabalhas", ele: "trabalha", nós: "trabalhamos", eles: "trabalham" }, meaning: "Travailler", level: 2 }, + { verb: "Estudar (étudier)", forms: { eu: "estudo", tu: "estudas", ele: "estuda", nós: "estudamos", eles: "estudam" }, meaning: "Étudier", level: 1 }, + { verb: "Comprar (acheter)", forms: { eu: "compro", tu: "compras", ele: "compra", nós: "compramos", eles: "compram" }, meaning: "Acheter", level: 2 }, + { verb: "Viajar (voyager)", forms: { eu: "viajo", tu: "viajas", ele: "viaja", nós: "viajamos", eles: "viajam" }, meaning: "Voyager", level: 2 }, + { verb: "Precisar (avoir besoin)", forms: { eu: "preciso", tu: "precisas", ele: "precisa", nós: "precisamos", eles: "precisam" }, meaning: "Avoir besoin de", level: 2 }, + { verb: "Ajudar (aider)", forms: { eu: "ajudo", tu: "ajudas", ele: "ajuda", nós: "ajudamos", eles: "ajudam" }, meaning: "Aider", level: 2 }, + { verb: "Chegar (arriver)", forms: { eu: "chego", tu: "chegas", ele: "chega", nós: "chegamos", eles: "chegam" }, meaning: "Arriver", level: 3 }, + { verb: "Passar (passer)", forms: { eu: "passo", tu: "passas", ele: "passa", nós: "passamos", eles: "passam" }, meaning: "Passer", level: 3 }, + { verb: "Sentir (sentir)", forms: { eu: "sinto", tu: "sentes", ele: "sente", nós: "sentimos", eles: "sentem" }, meaning: "Sentir", level: 3 }, + { verb: "Pedir (demander)", forms: { eu: "peço", tu: "pedes", ele: "pede", nós: "pedimos", eles: "pedem" }, meaning: "Demander", level: 3 }, + { verb: "Abrir (ouvrir)", forms: { eu: "abro", tu: "abres", ele: "abre", nós: "abrimos", eles: "abrem" }, meaning: "Ouvrir", level: 3 }, + { verb: "Meter (mettre)", forms: { eu: "meto", tu: "metes", ele: "mete", nós: "metemos", eles: "metem" }, meaning: "Mettre", level: 3 }, + { verb: "Trazer (apporter)", forms: { eu: "trago", tu: "trazes", ele: "traz", nós: "trazemos", eles: "trazem" }, meaning: "Apporter", level: 4 }, + { verb: "Conseguir (réussir)", forms: { eu: "consigo", tu: "consegues", ele: "consegue", nós: "conseguimos", eles: "conseguem" }, meaning: "Réussir, parvenir", level: 4 }, + { verb: "Descobrir (découvrir)", forms: { eu: "descubro", tu: "descobres", ele: "descobre", nós: "descobrimos", eles: "descobrem" }, meaning: "Découvrir", level: 4 }, +]; + +// ===== VOCAB EXTRA PAR NIVEAU ===== +const VOCAB_EXTRA = { + 2: [ + { pt: "Relógio", fr: "Montre", emoji: "⌚" }, + { pt: "Telemóvel", fr: "Téléphone", emoji: "📱" }, + { pt: "Computador", fr: "Ordinateur", emoji: "💻" }, + { pt: "Livro", fr: "Livre", emoji: "📚" }, + { pt: "Lápis", fr: "Crayon", emoji: "✏️" }, + { pt: "Escola", fr: "École", emoji: "🏫" }, + { pt: "Dinheiro", fr: "Argent", emoji: "💰" }, + { pt: "Relógio", fr: "Montre", emoji: "⏰" }, + { pt: "Chave", fr: "Clé", emoji: "🔑" }, + { pt: "Guarda-chuva", fr: "Parapluie", emoji: "☂️" }, + { pt: "Mochila", fr: "Sac à dos", emoji: "🎒" }, + { pt: "Caneta", fr: "Stylo", emoji: "🖊️" }, + { pt: "Carteira", fr: "Portefeuille", emoji: "👛" }, + { pt: "Óculos", fr: "Lunettes", emoji: "👓" }, + { pt: "Fato", fr: "Costume", emoji: "🤵" }, + ], + 3: [ + { pt: "Desenvolvimento", fr: "Développement", emoji: "📈" }, + { pt: "Experiência", fr: "Expérience", emoji: "🎓" }, + { pt: "Oportunidade", fr: "Opportunité", emoji: "🚪" }, + { pt: "Responsabilidade", fr: "Responsabilité", emoji: "⚖️" }, + { pt: "Conhecimento", fr: "Connaissance", emoji: "🧠" }, + { pt: "Agricultura", fr: "Agriculture", emoji: "🌾" }, + { pt: "Indústria", fr: "Industrie", emoji: "🏭" }, + { pt: "Comércio", fr: "Commerce", emoji: "🏪" }, + { pt: "Turismo", fr: "Tourisme", emoji: "🗺️" }, + { pt: "Tecnologia", fr: "Technologie", emoji: "🤖" }, + ], + 4: [ + { pt: "Saudade", fr: "Nostalgie (mot PT)", emoji: "💙" }, + { pt: "Fado", fr: "Fado (musique PT)", emoji: "🎵" }, + { pt: "Azulejo", fr: "Azulejo (carreau)", emoji: "🏛️" }, + { pt: "Pastéis de nata", fr: "Pastéis de nata", emoji: "🥧" }, + { pt: "Festas populares", fr: "Fêtes populaires", emoji: "🎉" }, + { pt: "Cortiça", fr: "Liège", emoji: "🍾" }, + { pt: "Serra da Estrela", fr: "Serra da Estrela", emoji: "🏔️" }, + { pt: "Algarve", fr: "Algarve", emoji: "🏖️" }, + { pt: "Madeira", fr: "Madère", emoji: "🌺" }, + { pt: "Açores", fr: "Açores", emoji: "🌋" }, + ], +}; + +// ===== SCRAMBLE GAME DATA ===== +const SCRAMBLE_WORDS = [ + { word: "cão", fr: "Chien", emoji: "🐕", level: 1 }, + { word: "gato", fr: "Chat", emoji: "🐈", level: 1 }, + { word: "sol", fr: "Soleil", emoji: "☀️", level: 1 }, + { word: "lua", fr: "Lune", emoji: "🌙", level: 1 }, + { word: "água", fr: "Eau", emoji: "💧", level: 1 }, + { word: "pão", fr: "Pain", emoji: "🍞", level: 1 }, + { word: "mão", fr: "Main", emoji: "✋", level: 1 }, + { word: "casa", fr: "Maison", emoji: "🏠", level: 1 }, + { word: "flor", fr: "Fleur", emoji: "🌸", level: 1 }, + { word: "bola", fr: "Ballon", emoji: "⚽", level: 1 }, + { word: "peixe", fr: "Poisson", emoji: "🐟", level: 1 }, + { word: "mãe", fr: "Maman", emoji: "👩", level: 1 }, + { word: "pai", fr: "Papa", emoji: "👨", level: 1 }, + { word: "olho", fr: "Œil", emoji: "👁️", level: 1 }, + { word: "boca", fr: "Bouche", emoji: "👄", level: 1 }, + { word: "dedo", fr: "Doigt", emoji: "☝️", level: 1 }, + { word: "pé", fr: "Pied", emoji: "🦶", level: 1 }, + { word: "arroz", fr: "Riz", emoji: "🍚", level: 1 }, + { word: "leite", fr: "Lait", emoji: "🥛", level: 1 }, + { word: "café", fr: "Café", emoji: "☕", level: 1 }, + { word: "carro", fr: "Voiture", emoji: "🚗", level: 1 }, + { word: "escola", fr: "École", emoji: "🏫", level: 2 }, + { word: "livro", fr: "Livre", emoji: "📚", level: 2 }, + { word: "amigo", fr: "Ami", emoji: "🤝", level: 2 }, + { word: "feliz", fr: "Heureux", emoji: "😊", level: 2 }, + { word: "grande", fr: "Grand", emoji: "📏", level: 2 }, + { word: "bonito", fr: "Joli", emoji: "✨", level: 2 }, + { word: "sapato", fr: "Chaussure", emoji: "👟", level: 2 }, + { word: "janela", fr: "Fenêtre", emoji: "🪟", level: 2 }, + { word: "porta", fr: "Porte", emoji: "🚪", level: 2 }, + { word: "banana", fr: "Banane", emoji: "🍌", level: 2 }, + { word: "melancia", fr: "Pastèque", emoji: "🍉", level: 2 }, + { word: "morango", fr: "Fraise", emoji: "🍓", level: 2 }, + { word: "cavalo", fr: "Cheval", emoji: "🐴", level: 2 }, + { word: "borboleta", fr: "Papillon", emoji: "🦋", level: 3 }, + { word: "elefante", fr: "Éléphant", emoji: "🐘", level: 3 }, + { word: "girafa", fr: "Girafe", emoji: "🦒", level: 3 }, + { word: "crocodilo", fr: "Crocodile", emoji: "🐊", level: 3 }, + { word: "raposa", fr: "Renard", emoji: "🦊", level: 3 }, + { word: "montanha", fr: "Montagne", emoji: "⛰️", level: 3 }, +]; + +// ===== BINGO GAME DATA ===== +const BINGO_WORDS = [ + { pt: "Olá", fr: "Bonjour", emoji: "👋" }, + { pt: "Obrigado", fr: "Merci", emoji: "🙏" }, + { pt: "Sim", fr: "Oui", emoji: "✅" }, + { pt: "Não", fr: "Non", emoji: "❌" }, + { pt: "Cão", fr: "Chien", emoji: "🐕" }, + { pt: "Gato", fr: "Chat", emoji: "🐈" }, + { pt: "Pão", fr: "Pain", emoji: "🍞" }, + { pt: "Água", fr: "Eau", emoji: "💧" }, + { pt: "Sol", fr: "Soleil", emoji: "☀️" }, + { pt: "Lua", fr: "Lune", emoji: "🌙" }, + { pt: "Mãe", fr: "Maman", emoji: "👩" }, + { pt: "Pai", fr: "Papa", emoji: "👨" }, + { pt: "Bola", fr: "Ballon", emoji: "⚽" }, + { pt: "Flor", fr: "Fleur", emoji: "🌸" }, + { pt: "Estrela", fr: "Étoile", emoji: "⭐" }, + { pt: "Casa", fr: "Maison", emoji: "🏠" }, + { pt: "Leite", fr: "Lait", emoji: "🥛" }, + { pt: "Café", fr: "Café", emoji: "☕" }, + { pt: "Carro", fr: "Voiture", emoji: "🚗" }, + { pt: "Escola", fr: "École", emoji: "🏫" }, + { pt: "Amigo", fr: "Ami", emoji: "🤝" }, + { pt: "Feliz", fr: "Heureux", emoji: "😊" }, + { pt: "Bonito", fr: "Joli", emoji: "✨" }, + { pt: "Grande", fr: "Grand", emoji: "📏" }, + { pt: "Peixe", fr: "Poisson", emoji: "🐟" }, + { pt: "Árvore", fr: "Arbre", emoji: "🌳" }, + { pt: "Montanha", fr: "Montagne", emoji: "⛰️" }, + { pt: "Mar", fr: "Mer", emoji: "🌊" }, + { pt: "Rio", fr: "Rivière", emoji: "🏞️" }, + { pt: "Nuvem", fr: "Nuage", emoji: "☁️" }, +]; + +// ===== RIDDLE GAME DATA ===== +const RIDDLES = [ + { emoji: "☀️", riddle: "Sou quente e brilho no céu!", answer: "Sol", fr: "Je suis chaud et je brille dans le ciel !", options: ["Sol", "Lua", "Estrela", "Nuvem"], level: 1 }, + { emoji: "🌙", riddle: "Apareço à noite e mudo de forma!", answer: "Lua", fr: "J'apparais la nuit et je change de forme !", options: ["Lua", "Sol", "Nuvem", "Estrela"], level: 1 }, + { emoji: "🐕", riddle: "Sou o melhor amigo do homem e faz 'au au'!", answer: "Cão", fr: "Je suis le meilleur ami de l'homme et fais 'ouaf ouaf' !", options: ["Cão", "Gato", "Pato", "Peixe"], level: 1 }, + { emoji: "🐈", riddle: "Adoro dormir e fazer 'miau'!", answer: "Gato", fr: "J'adore dormir et faire 'miaou' !", options: ["Gato", "Cão", "Rato", "Coelho"], level: 1 }, + { emoji: "💧", riddle: "Bebes-me todos os dias e sou transparente!", answer: "Água", fr: "Tu me bois tous les jours et je suis transparent !", options: ["Água", "Leite", "Sumo", "Café"], level: 1 }, + { emoji: "🍞", riddle: "Comes-me no pequeno-almoço com manteiga!", answer: "Pão", fr: "Tu me manges au petit-déjeuner avec du beurre !", options: ["Pão", "Queijo", "Arroz", "Bolo"], level: 1 }, + { emoji: "🏠", riddle: "Moras dentro de mim e tenho porta e janelas!", answer: "Casa", fr: "Tu vis dans ma maison et j'ai une porte et des fenêtres !", options: ["Casa", "Escola", "Carro", "Jardim"], level: 1 }, + { emoji: "🚗", riddle: "Tenho quatro rodas e levo-te para todo o lado!", answer: "Carro", fr: "J'ai quatre roues et je te mène partout !", options: ["Carro", "Bicicleta", "Autocarro", "Barco"], level: 1 }, + { emoji: "🏫", riddle: "Vens aqui para aprender e estudar!", answer: "Escola", fr: "Tu viens ici pour apprendre et étudier !", options: ["Escola", "Casa", "Parque", "Loja"], level: 1 }, + { emoji: "⚽", riddle: "Golpeas-me com os pés no campo!", answer: "Bola", fr: "Tu me frappes avec tes pieds sur le terrain !", options: ["Bola", "Patins", "Bicicleta", "Skate"], level: 1 }, + { emoji: "🌸", riddle: "Sou bonita, tenho cheiro e cresço no jardim!", answer: "Flor", fr: "Je suis jolie, j'ai une odeur et je pousse dans le jardin !", options: ["Flor", "Árvore", "Relva", "Folha"], level: 1 }, + { emoji: "🐟", riddle: "Vivo na água e faço 'glu glu'!", answer: "Peixe", fr: "Je vis dans l'eau et fais 'glou glou' !", options: ["Peixe", "Golfinho", "Tartaruga", "Rã"], level: 1 }, + { emoji: "⭐", riddle: "Brilho no céu à noite, sou pequena e fixa!", answer: "Estrela", fr: "Je brille dans le ciel la nuit, je suis petite et fixe !", options: ["Estrela", "Lua", "Sol", "Planeta"], level: 1 }, + { emoji: "🌳", riddle: "Tenho folhas, ramos e dou sombra!", answer: "Árvore", fr: "J'ai des feuilles, des branches et je fais de l'ombre !", options: ["Árvore", "Flor", "Relva", "Montanha"], level: 1 }, + { emoji: "🌧️", riddle: "Cai do céu e molha tudo!", answer: "Chuva", fr: "Je tombe du ciel et je mouille tout !", options: ["Chuva", "Neve", "Granizo", "Orvalho"], level: 2 }, + { emoji: "🐄", riddle: "Dai-me leite e faço 'muuu'!", answer: "Vaca", fr: "Je donne du lait et fais 'meuh' !", options: ["Vaca", "Cavalo", "Ovelha", "Porco"], level: 1 }, + { emoji: "🐴", riddle: "Tenho quatro patas e corro muito depressa!", answer: "Cavalo", fr: "J'ai quatre pattes et je cours très vite !", options: ["Cavalo", "Cão", "Gato", "Urso"], level: 1 }, + { emoji: "✋", riddle: "Tenho cinco dedos e sirvo para apertar a mão!", answer: "Mão", fr: "J'ai cinq doigts et je sers à serrer la main !", options: ["Mão", "Pé", "Braço", "Dedo"], level: 1 }, + { emoji: "🦶", riddle: "Ando comigo e estou debaixo de ti!", answer: "Pé", fr: "Je marche avec toi et je suis sous toi !", options: ["Pé", "Mão", "Braço", "Perna"], level: 1 }, + { emoji: "☁️", riddle: "Sou branca, voo no céu e faço sombra!", answer: "Nuvem", fr: "Je suis blanche, je vole dans le ciel et je fais de l'ombre !", options: ["Nuvem", "Sol", "Lua", "Fumaça"], level: 1 }, +]; diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg new file mode 100644 index 0000000..0ae9ffc --- /dev/null +++ b/public/icons/icon-192.svg @@ -0,0 +1 @@ +PT diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg new file mode 100644 index 0000000..fc30c64 --- /dev/null +++ b/public/icons/icon-512.svg @@ -0,0 +1 @@ +PT diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..fea77b9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,273 @@ + + + + + + + + + + Portugais pour Tous + + + + + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..6642b7f --- /dev/null +++ b/public/login.html @@ -0,0 +1,71 @@ + + + + + + + + Connexion - Portugais pour Tous + + + +
+
+
🇵🇹
+

Portugais pour Tous

+

Connecte-toi pour apprendre !

+ +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..5550696 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Portugais pour Tous", + "short_name": "Portugais", + "description": "Apprenez le portugais en t'amusant", + "start_url": "/", + "display": "standalone", + "background_color": "#00873E", + "theme_color": "#00873E", + "orientation": "portrait", + "icons": [ + { "src": "icons/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml" }, + { "src": "icons/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml" } + ] +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..7f4500c --- /dev/null +++ b/public/style.css @@ -0,0 +1,256 @@ +*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent} +:root{--green:#00873E;--green-light:#00A854;--green-dark:#005A2B;--green-bg:#E8F5E9;--red:#E53935;--gold:#FFB300;--bg:#F5F7FA;--card:#FFFFFF;--text:#1A2332;--text-light:#6B7A8D;--border:#E2E8F0;--shadow:0 2px 16px rgba(0,0,0,0.06);--shadow-lg:0 8px 32px rgba(0,0,0,0.1);--radius:16px;--radius-sm:10px;--safe-bottom:env(safe-area-inset-bottom,0px)} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;overflow-x:hidden} +#app{max-width:480px;margin:0 auto;min-height:100dvh;display:flex;flex-direction:column} + +/* Auth */ +.auth-page{background:linear-gradient(160deg,#006600 0%,#004400 40%,#002200 100%);display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:20px} +.auth-container{width:100%;max-width:400px} +.auth-card{background:var(--card);border-radius:24px;padding:40px 28px;box-shadow:var(--shadow-lg);text-align:center} +.auth-flag{font-size:64px;margin-bottom:8px} +.auth-card h1{font-size:1.4em;font-weight:800;color:var(--green-dark);margin-bottom:4px} +.auth-subtitle{color:var(--text-light);font-size:14px;margin-bottom:28px} +.auth-form{display:flex;flex-direction:column;gap:16px} +.form-group{display:flex;flex-direction:column;gap:6px;text-align:left} +.form-group label{font-size:13px;font-weight:600;color:var(--text-light)} +.form-group input{padding:14px 16px;border:2px solid var(--border);border-radius:var(--radius-sm);font-size:16px;font-family:inherit;transition:border-color .2s} +.form-group input:focus{outline:none;border-color:var(--green)} +.form-error{background:#FEE;border:1px solid #FCC;color:var(--red);padding:10px;border-radius:var(--radius-sm);font-size:13px;text-align:center} +.auth-btn{padding:16px;border:none;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--green),var(--green-light));color:#fff;font-size:16px;font-weight:700;cursor:pointer;transition:transform .2s;box-shadow:0 4px 16px rgba(0,135,62,.3)} +.auth-btn:active{transform:scale(.97)} +.auth-footer{text-align:center;margin-top:24px;color:rgba(255,255,255,.7);font-size:13px} + +/* Header */ +header{background:linear-gradient(135deg,var(--green),var(--green-dark));color:#fff;padding:14px 16px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 12px rgba(0,135,62,.25)} +.header-left{display:flex;align-items:center;gap:8px} +.header-flag{font-size:28px} +.header-left h1{font-size:1.1em;font-weight:700} +.header-right{display:flex;align-items:center;gap:10px} +.user-points-badge{display:flex;align-items:center;gap:4px;background:rgba(255,255,255,.2);border-radius:20px;padding:4px 12px;font-size:14px;font-weight:700} +.user-avatar{width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer} + +/* Bottom Nav */ +#bottom-nav{position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:480px;display:flex;background:var(--card);border-top:1px solid var(--border);padding-bottom:var(--safe-bottom);z-index:100;box-shadow:0 -2px 12px rgba(0,0,0,.08)} +.nav-btn{flex:1;display:flex;flex-direction:column;align-items:center;padding:6px 2px;border:none;background:none;cursor:pointer;color:var(--text-light);transition:all .2s;font-size:9px} +.nav-btn.active{color:var(--green);font-weight:700} +.nav-icon{font-size:18px;margin-bottom:1px} +.nav-label{font-size:9px;line-height:1} + +/* Main */ +main{flex:1;padding:16px;padding-bottom:80px} +.tab{display:none;animation:fadeIn .2s ease} +.tab.active{display:block} +@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} + +/* Dashboard */ +.dashboard-welcome{margin-bottom:20px} +.dashboard-welcome h2{font-size:1.3em;font-weight:800;color:var(--green-dark)} +.dashboard-welcome p{color:var(--text-light);font-size:14px} +.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:20px} +.stat-card{background:var(--card);border-radius:var(--radius);padding:16px;text-align:center;box-shadow:var(--shadow);position:relative;overflow:hidden} +.stat-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px} +.stat-points::before{background:var(--gold)} +.stat-streak::before{background:var(--red)} +.stat-today::before{background:var(--green)} +.stat-exercises::before{background:#7C4DFF} +.stat-icon{font-size:24px;margin-bottom:4px} +.stat-value{font-size:28px;font-weight:900;color:var(--text)} +.stat-label{font-size:11px;color:var(--text-light);font-weight:500} +.dash-section{margin-bottom:20px} +.dash-section h3{font-size:15px;font-weight:700;color:var(--text);margin-bottom:10px} +.daily-exercise-card{background:var(--card);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow);text-align:center} +.daily-exercise-card p{color:var(--text-light);font-size:14px} + +/* Leaderboard */ +.leaderboard{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden} +.lb-row{display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);gap:10px} +.lb-row:last-child{border-bottom:none} +.lb-me{background:var(--green-bg)} +.lb-rank{font-size:18px;width:32px;text-align:center} +.lb-avatar{font-size:20px} +.lb-name{flex:1;font-weight:600;font-size:14px} +.lb-points{font-weight:700;color:var(--green);font-size:13px} +.empty-state{text-align:center;color:var(--text-light);padding:20px;font-size:14px} + +/* History Chart */ +.chart-bars{display:flex;gap:6px;align-items:flex-end;height:120px;padding:10px;background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow)} +.chart-col{flex:1;display:flex;flex-direction:column;align-items:center;height:100%} +.chart-bar{width:100%;background:linear-gradient(180deg,var(--green),var(--green-light));border-radius:6px 6px 0 0;margin-top:auto;min-height:4px;transition:height .5s} +.chart-label{font-size:10px;color:var(--text-light);margin-top:4px} +.chart-val{font-size:10px;font-weight:700;color:var(--green)} + +/* Translator */ +.translator-card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden} +.lang-selector{display:flex;align-items:center;padding:12px;background:linear-gradient(135deg,var(--green),var(--green-light));color:#fff} +.lang-btn{flex:1;padding:10px;border:2px solid rgba(255,255,255,.3);border-radius:var(--radius-sm);background:rgba(255,255,255,.15);color:#fff;font-size:14px;font-weight:600;cursor:pointer;transition:all .2s} +.lang-btn:active{transform:scale(.95)} +.swap-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--gold);color:var(--green-dark);font-size:20px;font-weight:900;cursor:pointer;margin:0 8px;transition:transform .3s;box-shadow:0 2px 8px rgba(0,0,0,.2)} +.swap-btn:active{transform:rotate(180deg) scale(.9)} +.translator-body{padding:16px} +.input-group{position:relative;margin-bottom:12px} +textarea,.output-area{width:100%;padding:14px;padding-right:50px;border:2px solid var(--border);border-radius:var(--radius-sm);font-size:16px;font-family:inherit;resize:none;transition:border-color .2s;background:#FAFBFC} +textarea:focus,.output-area:focus{outline:none;border-color:var(--green)} +.output-area{min-height:80px;background:#F0FFF4;cursor:pointer} +.output-group{margin-bottom:0} +.input-actions{position:absolute;right:8px;top:8px;display:flex;flex-direction:column;gap:4px} +.icon-btn{width:36px;height:36px;border:none;border-radius:var(--radius-sm);background:rgba(0,135,62,.1);cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;transition:all .2s} +.icon-btn:active{background:var(--green);color:#fff;transform:scale(.9)} + +/* Buttons */ +.primary-btn{width:100%;padding:14px;border:none;border-radius:var(--radius-sm);background:linear-gradient(135deg,var(--green),var(--green-light));color:#fff;font-size:16px;font-weight:700;cursor:pointer;transition:all .2s;margin-bottom:12px;box-shadow:0 4px 12px rgba(0,135,62,.2)} +.primary-btn:active{transform:scale(.97)} +.secondary-btn{padding:12px 24px;border:2px solid var(--green);border-radius:var(--radius-sm);background:#fff;color:var(--green);font-size:14px;font-weight:600;cursor:pointer;transition:all .2s;margin-top:16px;display:block;width:100%} +.secondary-btn:active{background:var(--green);color:#fff} + +/* Category Tabs */ +.category-tabs{display:flex;gap:8px;overflow-x:auto;padding-bottom:12px;margin-bottom:16px;scrollbar-width:none} +.category-tabs::-webkit-scrollbar{display:none} +.cat-tab{padding:8px 16px;border-radius:20px;border:2px solid var(--border);background:#fff;color:var(--text);font-size:13px;font-weight:500;white-space:nowrap;cursor:pointer;transition:all .2s} +.cat-tab.active{background:var(--green);color:#fff;border-color:var(--green)} + +/* Phrase Cards */ +.card-list{display:flex;flex-direction:column;gap:12px} +.phrase-card{background:var(--card);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow);transition:transform .2s} +.phrase-card:active{transform:scale(.98)} +.phrase-pt{font-size:18px;font-weight:700;color:var(--green-dark);margin-bottom:4px} +.phrase-fr{font-size:15px;color:var(--text-light);margin-bottom:10px} +.phonetic{font-size:12px;color:var(--text-light);font-style:italic;background:var(--green-bg);padding:3px 8px;border-radius:6px;display:inline-block;margin-bottom:8px} +.phrase-actions{display:flex;gap:6px;flex-wrap:wrap} +.phrase-actions button{padding:7px 12px;border:none;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;transition:all .2s} +.btn-listen{background:var(--green);color:#fff} +.btn-listen:active{transform:scale(.9)} +.btn-slow{background:var(--gold);color:var(--green-dark)} +.btn-favorite{background:#FEE;color:var(--red)} +.btn-favorite.active{background:var(--red);color:#fff} + +/* Games */ +h2{text-align:center;margin-bottom:20px;color:var(--green-dark);font-size:1.2em} +.game-menu{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.game-card{background:var(--card);border-radius:var(--radius);padding:20px 12px;text-align:center;box-shadow:var(--shadow);cursor:pointer;transition:all .2s;border:2px solid transparent} +.game-card:active{transform:scale(.95);border-color:var(--green)} +.game-emoji{font-size:36px;margin-bottom:8px} +.game-card h3{font-size:14px;margin-bottom:4px;color:var(--green-dark)} +.game-card p{font-size:11px;color:var(--text-light)} + +/* Game Area */ +#game-area,#exercise-area{background:var(--card);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow);margin-top:16px;min-height:280px} +.hidden{display:none !important} +.game-header{text-align:center;margin-bottom:16px} +.game-score{display:flex;justify-content:center;gap:20px;margin-bottom:12px} +.score-item{text-align:center} +.score-value{font-size:28px;font-weight:900;color:var(--green)} +.score-label{font-size:11px;color:var(--text-light)} +.quiz-sentence{font-size:20px;font-weight:700;color:var(--green);margin:16px auto;text-align:center;cursor:pointer;padding:12px;background:var(--green-bg);border-radius:var(--radius-sm)} + +/* Option Buttons */ +.color-options,.animal-options,.number-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px} +.option-btn{padding:16px;border:3px solid var(--border);border-radius:var(--radius);background:#fff;font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;text-align:center} +.option-btn:active{transform:scale(.95)} +.option-btn.correct{border-color:var(--green);background:#E8F5E9;animation:correctPulse .5s} +.option-btn.wrong{border-color:var(--red);background:#FFEBEE;animation:shake .4s} +@keyframes correctPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.05)}} +@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-8px)}75%{transform:translateX(8px)}} + +/* Memory */ +.memory-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} +.memory-card{aspect-ratio:1;border:3px solid var(--border);border-radius:var(--radius);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;cursor:pointer;transition:all .3s;background:linear-gradient(135deg,var(--green),var(--green-light));color:#fff} +.memory-card.flipped{background:#fff;color:var(--text);border-color:var(--green)} +.memory-card.matched{background:var(--gold);color:var(--green-dark);border-color:var(--gold);pointer-events:none} + +/* Drag & Drop */ +.drag-items{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:20px} +.drag-item{padding:10px 18px;background:var(--green);color:#fff;border-radius:var(--radius-sm);font-weight:600;cursor:pointer;transition:all .2s;user-select:none;-webkit-user-select:none} +.drag-item:active{transform:scale(.95)} +.drop-zone{min-height:50px;border:3px dashed var(--border);border-radius:var(--radius);display:flex;align-items:center;justify-content:center;margin-bottom:12px;padding:12px;transition:all .2s} +.drop-zone-label{font-size:14px;font-weight:600;color:var(--text);margin-bottom:8px} +.drop-zone-word{font-size:14px;font-weight:600;color:var(--text-light)} +.dropped-word{padding:8px 16px;background:var(--green);color:#fff;border-radius:var(--radius-sm);font-weight:600} + +/* Exercises */ +.exercise-hint{text-align:center;color:var(--text-light);margin-bottom:12px;font-size:14px} +.exercise-question{font-size:22px;font-weight:700;text-align:center;margin-bottom:16px;color:var(--green-dark)} +.fill-options{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:16px} +.fill-word{padding:10px 16px;border:2px solid var(--border);border-radius:var(--radius-sm);background:#fff;font-size:15px;cursor:pointer;transition:all .2s} +.fill-word:active{transform:scale(.95)} +.fill-blank{display:inline-block;min-width:80px;padding:4px 10px;border-bottom:3px solid var(--green);font-weight:700;color:var(--green);margin:0 4px} +.fill-blank.correct-blank{background:#E8F5E9;border-radius:6px} +.fill-blank.wrong-blank{background:#FFEBEE;border-color:var(--red)} +.exercise-input{width:100%;padding:14px;border:2px solid var(--border);border-radius:var(--radius-sm);font-size:18px;text-align:center;font-family:inherit;margin-top:12px;transition:border-color .2s} +.exercise-input:focus{outline:none;border-color:var(--green)} + +/* Vocab */ +.vocab-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px} +.vocab-card{background:var(--card);border-radius:var(--radius);padding:16px;text-align:center;box-shadow:var(--shadow);cursor:pointer;transition:all .2s} +.vocab-card:active{transform:scale(.95)} +.vocab-word{font-size:16px;font-weight:700;color:var(--green-dark);margin-bottom:4px} +.vocab-translation{font-size:13px;color:var(--text-light);margin-bottom:8px} +.vocab-emoji{font-size:30px} + +/* Progress Bar */ +.progress-bar{width:100%;height:6px;background:var(--border);border-radius:3px;margin:10px 0;overflow:hidden} +.progress-fill{height:100%;background:linear-gradient(90deg,var(--green),var(--gold));border-radius:3px;transition:width .5s ease} + +/* Admin */ +.admin-section{background:var(--card);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow);margin-bottom:16px} +.admin-section h3{font-size:16px;font-weight:700;color:var(--green-dark);margin-bottom:12px} +.admin-form{display:flex;flex-direction:column;gap:12px} +.admin-form input,.admin-form select{padding:12px;border:2px solid var(--border);border-radius:var(--radius-sm);font-size:15px;font-family:inherit} +.admin-form input:focus,.admin-form select:focus{outline:none;border-color:var(--green)} +.admin-users{display:flex;flex-direction:column;gap:8px} +.admin-user-card{display:flex;align-items:center;justify-content:space-between;padding:12px;background:var(--bg);border-radius:var(--radius-sm);gap:8px} +.admin-user-info{display:flex;align-items:center;gap:10px} +.admin-avatar{font-size:24px} +.admin-user-info strong{font-size:14px;display:block} +.admin-user-info small{color:var(--text-light);font-size:12px} +.admin-user-actions{display:flex;align-items:center;gap:8px} +.admin-points{font-size:13px;font-weight:700;color:var(--green)} +.admin-btn-toggle{padding:6px 10px;border:none;border-radius:6px;background:var(--green-bg);color:var(--green);font-size:11px;font-weight:600;cursor:pointer} +.admin-btn-delete{padding:6px 8px;border:none;border-radius:6px;background:#FFEBEE;color:var(--red);font-size:14px;cursor:pointer} + +/* Toast */ +.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);padding:12px 24px;border-radius:12px;background:var(--green-dark);color:#fff;font-weight:600;font-size:14px;z-index:1000;box-shadow:0 4px 16px rgba(0,0,0,.3);animation:toastIn .3s ease;white-space:nowrap} +@keyframes toastIn{from{opacity:0;transform:translateX(-50%) translateY(20px)}to{opacity:1;transform:translateX(-50%) translateY(0)}} + +/* Celebration */ +.celebration{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:999} +.confetti{position:absolute;width:10px;height:10px;border-radius:2px;animation:confettiFall 2s ease forwards} +@keyframes confettiFall{0%{transform:translateY(-20px) rotate(0);opacity:1}100%{transform:translateY(100vh) rotate(720deg);opacity:0}} + +@media(max-width:380px){.game-menu{grid-template-columns:1fr}.vocab-grid{grid-template-columns:1fr}} + +/* Scramble Game */ +.scramble-display{display:flex;gap:8px;justify-content:center;margin:16px 0} +.scramble-letter{width:48px;height:56px;border:3px solid var(--green);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:900;color:var(--green-dark);background:var(--green-bg);animation:letterPop .3s ease} +@keyframes letterPop{from{transform:scale(0);opacity:0}to{transform:scale(1);opacity:1}} +.scramble-input{text-align:center;font-size:22px;font-weight:700;letter-spacing:2px} + +/* Bingo Game */ +.bingo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:16px} +.bingo-card{padding:16px 8px;border:3px solid var(--border);border-radius:var(--radius);background:#fff;cursor:pointer;transition:all .2s;text-align:center;display:flex;flex-direction:column;align-items:center;gap:6px} +.bingo-card:active{transform:scale(.95);border-color:var(--green)} +.bingo-card.correct{border-color:var(--green);background:#E8F5E9;animation:correctPulse .5s} +.bingo-card.wrong{border-color:var(--red);background:#FFEBEE;animation:shake .4s} +.bingo-emoji{font-size:32px} +.bingo-word{font-size:13px;font-weight:700;color:var(--text)} +.bingo-listen-btn{background:linear-gradient(135deg,var(--green),var(--green-light));color:#fff;border:none;cursor:pointer;box-shadow:0 4px 16px rgba(0,135,62,.3);transition:all .2s} +.bingo-listen-btn:active{transform:scale(.9);box-shadow:0 2px 8px rgba(0,135,62,.2)} + +/* Catch Words Game */ +.catch-zone{position:relative;width:100%;height:400px;border:3px dashed var(--border);border-radius:var(--radius);overflow:hidden;background:linear-gradient(180deg,#E8F5E9 0%,#fff 100%)} +.catch-word{position:absolute;top:-60px;display:flex;align-items:center;gap:6px;padding:10px 16px;background:var(--green);color:#fff;border-radius:var(--radius-sm);font-weight:700;font-size:16px;cursor:pointer;transition:all .3s;animation:catchFall 3s linear forwards;z-index:10} +.catch-word:active{transform:scale(1.1)} +.catch-word.caught{background:var(--gold);color:var(--green-dark);animation:none;transform:scale(1.2);opacity:0;transition:all .3s} +.catch-word.missed-word{background:var(--red);opacity:0.5;animation:none} +.catch-emoji{font-size:24px} +.catch-text{font-size:16px} +@keyframes catchFall{0%{top:-60px}100%{top:420px}} + +/* Riddle Game */ +.riddle-card{background:linear-gradient(135deg,var(--green-bg),#fff);border:2px solid var(--green);border-radius:var(--radius);padding:20px;margin:16px 0;text-align:center} +.riddle-text{font-size:18px;font-weight:700;color:var(--green-dark);line-height:1.4} +.riddle-fr{margin-top:8px} +.riddle-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px} +.riddle-opt{padding:14px;border:3px solid var(--border);border-radius:var(--radius);background:#fff;font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;text-align:center} +.riddle-opt:active{transform:scale(.95)} +.riddle-opt.correct{border-color:var(--green);background:#E8F5E9;animation:correctPulse .5s} +.riddle-opt.wrong{border-color:var(--red);background:#FFEBEE;animation:shake .4s} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..112e1df --- /dev/null +++ b/public/sw.js @@ -0,0 +1,5 @@ +const CACHE_NAME='pt-v5'; +const urls=['/','/login.html']; +self.addEventListener('install',e=>{e.waitUntil(caches.open(CACHE_NAME).then(c=>c.addAll(urls)));self.skipWaiting()}); +self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(n=>Promise.all(n.filter(x=>x!==CACHE_NAME).map(x=>caches.delete(x)))).then(()=>self.clients.claim()))}); +self.addEventListener('fetch',e=>{if(e.request.method!=='GET')return;e.respondWith(fetch(e.request).then(r=>{const clone=r.clone();caches.open(CACHE_NAME).then(c=>c.put(e.request,clone));return r}).catch(()=>caches.match(e.request)))}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..6a095ae --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "portugais-server", + "version": "2.0.0", + "private": true, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "express": "^4.21.0", + "cors": "^2.8.5" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..d99df87 --- /dev/null +++ b/server/server.js @@ -0,0 +1,228 @@ +const express = require('express'); +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const path = require('path'); +const cors = require('cors'); + +const app = express(); +const PORT = process.env.PORT || 3007; +const JWT_SECRET = process.env.JWT_SECRET || 'portugais-secret-key-2026-change-me'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Pierre2026!'; + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, '../public'))); + +// ===== DATABASE ===== +const db = new Database('/data/portugais.db'); +db.pragma('journal_mode = WAL'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + display_name TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + avatar TEXT DEFAULT '🇵🇹', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + points INTEGER DEFAULT 0, + activity TEXT, + activity_type TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS daily_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + date TEXT NOT NULL, + exercises_done INTEGER DEFAULT 0, + games_played INTEGER DEFAULT 0, + points_earned INTEGER DEFAULT 0, + streak INTEGER DEFAULT 0, + UNIQUE(user_id, date), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + phrase TEXT NOT NULL, + UNIQUE(user_id, phrase), + FOREIGN KEY (user_id) REFERENCES users(id) + ); +`); + +// Create default admin +const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('Pierre'); +if (!existingAdmin) { + const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10); + db.prepare('INSERT INTO users (username, password, display_name, is_admin, avatar) VALUES (?, ?, ?, ?, ?)').run('Pierre', hash, 'Pierre', 1, '👨‍💼'); + console.log('Admin user Pierre created'); +} + +// ===== AUTH MIDDLEWARE ===== +function authMiddleware(req, res, next) { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) return res.status(401).json({ error: 'Token requis' }); + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = db.prepare('SELECT id, username, display_name, is_admin, avatar FROM users WHERE id = ?').get(decoded.userId); + if (!user) return res.status(401).json({ error: 'Utilisateur introuvable' }); + req.user = user; + next(); + } catch { + res.status(401).json({ error: 'Token invalide' }); + } +} + +function adminMiddleware(req, res, next) { + if (!req.user.is_admin) return res.status(403).json({ error: 'Accès admin requis' }); + next(); +} + +// ===== AUTH ROUTES ===== +app.post('/api/login', (req, res) => { + const { username, password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ error: 'Identifiant ou mot de passe incorrect' }); + } + const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ + token, + user: { id: user.id, username: user.username, display_name: user.display_name, is_admin: user.is_admin, avatar: user.avatar } + }); +}); + +app.post('/api/register', authMiddleware, adminMiddleware, (req, res) => { + const { username, password, display_name, avatar } = req.body; + if (!username || !password || !display_name) { + return res.status(400).json({ error: 'Tous les champs sont requis' }); + } + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username); + if (existing) return res.status(400).json({ error: 'Ce nom d\'utilisateur existe déjà' }); + const hash = bcrypt.hashSync(password, 10); + const result = db.prepare('INSERT INTO users (username, password, display_name, avatar) VALUES (?, ?, ?, ?)').run(username, hash, display_name, avatar || '🇵🇹'); + res.json({ id: result.lastInsertRowid, username, display_name, avatar: avatar || '🇵🇹' }); +}); + +app.get('/api/me', authMiddleware, (req, res) => { + res.json({ user: req.user }); +}); + +// ===== ADMIN ROUTES ===== +app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { + const users = db.prepare(` + SELECT u.id, u.username, u.display_name, u.is_admin, u.avatar, u.created_at, + COALESCE(SUM(p.points), 0) as total_points + FROM users u + LEFT JOIN points p ON p.user_id = u.id + GROUP BY u.id + ORDER BY total_points DESC + `).all(); + res.json({ users }); +}); + +app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, (req, res) => { + const userId = parseInt(req.params.id); + if (userId === req.user.id) return res.status(400).json({ error: 'Vous ne pouvez pas vous supprimer' }); + db.prepare('DELETE FROM favorites WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM daily_progress WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM points WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + res.json({ ok: true }); +}); + +app.put('/api/admin/users/:id/toggle-admin', authMiddleware, adminMiddleware, (req, res) => { + const userId = parseInt(req.params.id); + const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId); + if (!user) return res.status(404).json({ error: 'Utilisateur introuvable' }); + db.prepare('UPDATE users SET is_admin = ? WHERE id = ?').run(user.is_admin ? 0 : 1, userId); + res.json({ is_admin: !user.is_admin }); +}); + +// ===== POINTS ROUTES ===== +app.post('/api/points/add', authMiddleware, (req, res) => { + const { points, activity, activity_type } = req.body; + const today = new Date().toISOString().split('T')[0]; + + db.prepare('INSERT INTO points (user_id, points, activity, activity_type) VALUES (?, ?, ?, ?)').run(req.user.id, points, activity, activity_type); + + const existing = db.prepare('SELECT * FROM daily_progress WHERE user_id = ? AND date = ?').get(req.user.id, today); + if (existing) { + db.prepare('UPDATE daily_progress SET exercises_done = exercises_done + ?, points_earned = points_earned + ? WHERE user_id = ? AND date = ?').run(activity_type === 'exercise' ? 1 : 0, points, req.user.id, today); + } else { + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + const yesterdayProgress = db.prepare('SELECT streak FROM daily_progress WHERE user_id = ? AND date = ?').get(req.user.id, yesterday); + const newStreak = (yesterdayProgress && yesterdayProgress.streak > 0) ? yesterdayProgress.streak + 1 : 1; + db.prepare('INSERT INTO daily_progress (user_id, date, exercises_done, games_played, points_earned, streak) VALUES (?, ?, ?, ?, ?, ?)').run(req.user.id, today, activity_type === 'exercise' ? 1 : 0, activity_type === 'game' ? 1 : 0, points, newStreak); + } + + const total = db.prepare('SELECT COALESCE(SUM(points), 0) as total FROM points WHERE user_id = ?').get(req.user.id); + res.json({ total_points: total.total }); +}); + +app.get('/api/points/leaderboard', authMiddleware, (req, res) => { + const leaderboard = db.prepare(` + SELECT u.id, u.display_name, u.avatar, + COALESCE(SUM(p.points), 0) as total_points + FROM users u + LEFT JOIN points p ON p.user_id = u.id + GROUP BY u.id + ORDER BY total_points DESC + LIMIT 20 + `).all(); + res.json({ leaderboard }); +}); + +app.get('/api/points/me', authMiddleware, (req, res) => { + const total = db.prepare('SELECT COALESCE(SUM(points), 0) as total FROM points WHERE user_id = ?').get(req.user.id); + const today = new Date().toISOString().split('T')[0]; + const progress = db.prepare('SELECT * FROM daily_progress WHERE user_id = ? AND date = ?').get(req.user.id, today); + const recentPoints = db.prepare('SELECT * FROM points WHERE user_id = ? ORDER BY created_at DESC LIMIT 10').all(req.user.id); + res.json({ total_points: total.total, today: progress || { exercises_done: 0, games_played: 0, points_earned: 0, streak: 0 }, recent: recentPoints }); +}); + +app.get('/api/points/history', authMiddleware, (req, res) => { + const history = db.prepare(` + SELECT date, exercises_done, games_played, points_earned, streak + FROM daily_progress WHERE user_id = ? + ORDER BY date DESC LIMIT 30 + `).all(req.user.id); + res.json({ history }); +}); + +// ===== FAVORITES ===== +app.get('/api/favorites', authMiddleware, (req, res) => { + const favs = db.prepare('SELECT phrase FROM favorites WHERE user_id = ?').all(req.user.id); + res.json({ favorites: favs.map(f => f.phrase) }); +}); + +app.post('/api/favorites/toggle', authMiddleware, (req, res) => { + const { phrase } = req.body; + const existing = db.prepare('SELECT id FROM favorites WHERE user_id = ? AND phrase = ?').get(req.user.id, phrase); + if (existing) { + db.prepare('DELETE FROM favorites WHERE id = ?').run(existing.id); + res.json({ favorited: false }); + } else { + db.prepare('INSERT INTO favorites (user_id, phrase) VALUES (?, ?)').run(req.user.id, phrase); + res.json({ favorited: true }); + } +}); + +// ===== SPA FALLBACK ===== +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../public/index.html')); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Portugais app running on port ${PORT}`); +});