init: HejYou Language Learning App (React + Vite)
React SPA with Vite, Directus backend, canvas-confetti. Includes Dockerfile (multi-stage Node → nginx) for Coolify deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
324
src/pages/Feed.jsx
Normal file
324
src/pages/Feed.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './Feed.css'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
getActiveLearningPair, getWords, getQuestions, getUserProgress,
|
||||
getLanguageOptions, langById,
|
||||
saveProgress, addPointsToPair,
|
||||
getQAPairsAtLevel, assetUrl,
|
||||
} from '../api/directus'
|
||||
import NewWordTextCard from '../components/NewWordTextCard'
|
||||
import NewWordVoiceCard from '../components/NewWordVoiceCard'
|
||||
import LetterOrderCard from '../components/LetterOrderCard'
|
||||
import SentenceFillCard from '../components/SentenceFillCard'
|
||||
import LanguageParentCard from '../components/LanguageParentCard'
|
||||
|
||||
// Ein Wort gilt als gemeistert, wenn es in der aktiven Sprachrichtung
|
||||
// mindestens MASTERY_THRESHOLD korrekt beantwortete Kacheln gesammelt hat.
|
||||
const MASTERY_THRESHOLD = 3
|
||||
// Wie viele verschiedene Wörter gleichzeitig im Feed erscheinen
|
||||
const FEED_WORD_BUDGET = 6
|
||||
|
||||
// Punkteformel: selbes/niedrigeres Level = 1 Punkt, jeder Level höher = +1 Punkt
|
||||
function computePoints(cardLevel, userLevel) {
|
||||
return Math.max(1, 1 + ((cardLevel || 1) - (userLevel || 1)))
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
const pickN = (arr, n) => shuffle(arr).slice(0, Math.max(0, n))
|
||||
|
||||
function pickWordsToLearn(unmastered, userLevel, budget) {
|
||||
if (unmastered.length === 0) return []
|
||||
const same = unmastered.filter(w => (w.level || 1) === userLevel)
|
||||
const higher = unmastered.filter(w => (w.level || 1) > userLevel && (w.level || 1) <= userLevel + 2)
|
||||
const farHigher = unmastered.filter(w => (w.level || 1) > userLevel + 2)
|
||||
const lower = unmastered.filter(w => (w.level || 1) < userLevel)
|
||||
|
||||
if (same.length > 0) {
|
||||
const sameN = Math.ceil(budget * 0.8)
|
||||
const higherN = budget - sameN
|
||||
let result = [
|
||||
...pickN(same, Math.min(sameN, same.length)),
|
||||
...pickN(higher, Math.min(higherN, higher.length)),
|
||||
]
|
||||
if (result.length < budget) {
|
||||
const used = new Set(result.map(w => w.id))
|
||||
const rest = unmastered.filter(w => !used.has(w.id))
|
||||
result = [...result, ...pickN(rest, budget - result.length)]
|
||||
}
|
||||
return shuffle(result)
|
||||
}
|
||||
if (higher.length > 0) return pickN(higher, budget)
|
||||
if (farHigher.length > 0) return pickN(farHigher, budget)
|
||||
return pickN(lower, budget)
|
||||
}
|
||||
|
||||
function buildWordCards(words, userLevel, fromLang, toLang) {
|
||||
const cards = []
|
||||
words.forEach(w => {
|
||||
const word = w[`title_${toLang.suffix}`]
|
||||
const translation = w[`title_${fromLang.suffix}`]
|
||||
if (!word || !translation) return
|
||||
const level = w.level || 1
|
||||
const points = computePoints(level, userLevel)
|
||||
|
||||
const base = { language: toLang.label, points, word, translation, level }
|
||||
|
||||
cards.push({
|
||||
type: 'text',
|
||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
||||
card: { ...base, baseForm: word, prompt: `Schreib das Wort auf ${toLang.label}` },
|
||||
})
|
||||
cards.push({
|
||||
type: 'voice',
|
||||
meta: { wordId: w.id, cardType: 'speak', points, level },
|
||||
card: { ...base, baseForm: word, prompt: `Sprich das Wort auf ${toLang.label}`, speechLang: toLang.speech },
|
||||
})
|
||||
if (word.length >= 4) {
|
||||
cards.push({
|
||||
type: 'letter',
|
||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
||||
card: { ...base, prompt: 'Tippe die Buchstaben in der richtigen Reihenfolge' },
|
||||
})
|
||||
}
|
||||
})
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildLanguageParentCards(qaPairs, userLevel, toLang, token) {
|
||||
return qaPairs.map(qp => {
|
||||
const points = computePoints(qp.level, userLevel)
|
||||
return {
|
||||
type: 'languparent',
|
||||
meta: { pairId: qp.pairId, cardType: 'speak', points, level: qp.level },
|
||||
card: {
|
||||
language: toLang.label,
|
||||
points,
|
||||
level: qp.level,
|
||||
statement: qp.statement,
|
||||
imageUrl: assetUrl(qp.pictureFileId, token),
|
||||
primaryWord: qp.primaryWord,
|
||||
speechLang: toLang.speech,
|
||||
selections: qp.selections,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildQuestionCardsFor(questions, masteredWordIds, userLevel, toLang) {
|
||||
const cards = []
|
||||
questions.forEach(q => {
|
||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
||||
if (wordIds.length === 0) return
|
||||
if (!wordIds.every(id => masteredWordIds.has(id))) return
|
||||
|
||||
const qText = q[`question_${toLang.suffix}`]
|
||||
const answer = q[`answer_${toLang.suffix}`]
|
||||
if (!qText || !answer) return
|
||||
|
||||
const level = q.level || 1
|
||||
const points = computePoints(level, userLevel)
|
||||
|
||||
cards.push({
|
||||
type: 'sentence',
|
||||
meta: { questionId: q.id, wordIds, cardType: 'sentence_fill', points, level },
|
||||
card: {
|
||||
language: toLang.label, points, level,
|
||||
word: answer,
|
||||
translation: qText,
|
||||
prompt: 'Antworte auf die Frage',
|
||||
},
|
||||
})
|
||||
})
|
||||
return cards
|
||||
}
|
||||
|
||||
export default function Feed() {
|
||||
const { user, token } = useAuth()
|
||||
const [cards, setCards] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ctx, setCtx] = useState(null)
|
||||
const [runningPoints, setRunningPoints] = useState(0)
|
||||
const [empty, setEmpty] = useState(false)
|
||||
|
||||
// Laufende Mastery-Verwaltung für diese Session
|
||||
const correctsRef = useRef({}) // wordId -> Anzahl korrekter Antworten (persistiert + session)
|
||||
const masteredRef = useRef(new Set())
|
||||
const questionsRef = useRef([])
|
||||
const appendedQRef = useRef(new Set()) // bereits angehängte questionIds
|
||||
const userLevelRef = useRef(1)
|
||||
const toLangRef = useRef(null)
|
||||
const pointsQueueRef = useRef(Promise.resolve()) // serialisiert Punkte-Updates
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [pair, langs] = await Promise.all([
|
||||
getActiveLearningPair(user.username, token),
|
||||
getLanguageOptions(),
|
||||
])
|
||||
if (!pair) { setEmpty(true); setLoading(false); return }
|
||||
|
||||
const fromLang = langById(pair.language_from, langs)
|
||||
const toLang = langById(pair.language_to, langs)
|
||||
if (!fromLang || !toLang) { setEmpty(true); setLoading(false); return }
|
||||
|
||||
const userLevel = pair.current_level || 1
|
||||
|
||||
const [words, questions, progress, qaPairs] = await Promise.all([
|
||||
getWords(token),
|
||||
getQuestions(token),
|
||||
getUserProgress(user.username, token, pair.language_to),
|
||||
getQAPairsAtLevel(userLevel, token, toLang.suffix),
|
||||
])
|
||||
|
||||
const correctsByWord = {}
|
||||
progress.forEach(p => {
|
||||
if (p.result === 'correct' && p.word) {
|
||||
correctsByWord[p.word] = (correctsByWord[p.word] || 0) + 1
|
||||
}
|
||||
})
|
||||
const mastered = new Set(
|
||||
Object.entries(correctsByWord)
|
||||
.filter(([, c]) => c >= MASTERY_THRESHOLD)
|
||||
.map(([id]) => id)
|
||||
)
|
||||
|
||||
correctsRef.current = correctsByWord
|
||||
masteredRef.current = mastered
|
||||
questionsRef.current = questions
|
||||
userLevelRef.current = userLevel
|
||||
toLangRef.current = toLang
|
||||
|
||||
const unmastered = words.filter(w => !mastered.has(w.id))
|
||||
const chosen = pickWordsToLearn(unmastered, userLevel, FEED_WORD_BUDGET)
|
||||
|
||||
const wordCards = buildWordCards(chosen, userLevel, fromLang, toLang)
|
||||
const questionCards = buildQuestionCardsFor(questions, mastered, userLevel, toLang)
|
||||
const lpCards = buildLanguageParentCards(qaPairs, userLevel, toLang, token)
|
||||
|
||||
// bereits angehängte Frage-IDs merken, damit wir sie nicht doppelt einstreuen
|
||||
questionCards.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
||||
|
||||
const allCards = [...lpCards, ...wordCards, ...questionCards]
|
||||
setCards(allCards)
|
||||
setEmpty(allCards.length === 0)
|
||||
setCtx({
|
||||
pair,
|
||||
fromLangId: pair.language_from,
|
||||
toLangId: pair.language_to,
|
||||
profileId: user.username,
|
||||
})
|
||||
setRunningPoints(pair.points || 0)
|
||||
} catch (err) {
|
||||
console.error('Feed load error', err)
|
||||
setEmpty(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [user.username, token])
|
||||
|
||||
async function handleComplete(item, result) {
|
||||
if (!ctx) return
|
||||
const earned = result === 'correct' ? (item.meta.points || 1) : 0
|
||||
|
||||
saveProgress({
|
||||
user: ctx.profileId,
|
||||
word: item.meta.wordId || null,
|
||||
question: item.meta.questionId || null,
|
||||
card_type: item.meta.cardType,
|
||||
result,
|
||||
points_earned: earned,
|
||||
language_from: ctx.fromLangId,
|
||||
language_to: ctx.toLangId,
|
||||
}, token).catch(() => {})
|
||||
|
||||
// Punkte serialisiert patchen, damit parallele Karten nicht denselben Basiswert überschreiben
|
||||
if (earned > 0) {
|
||||
setRunningPoints(p => p + earned)
|
||||
pointsQueueRef.current = pointsQueueRef.current.then(async () => {
|
||||
ctx.pair.points = (ctx.pair.points || 0) + earned
|
||||
try { await addPointsToPair(ctx.pair.id, ctx.pair.points, token) } catch {}
|
||||
})
|
||||
}
|
||||
|
||||
// In-Session-Mastery: korrekte Wort-Antwort erhöht Zähler; neu gemasterte
|
||||
// Wörter können Frage-Kacheln freischalten.
|
||||
if (result === 'correct' && item.meta.wordId) {
|
||||
const wid = item.meta.wordId
|
||||
const newCount = (correctsRef.current[wid] || 0) + 1
|
||||
correctsRef.current[wid] = newCount
|
||||
|
||||
if (!masteredRef.current.has(wid) && newCount >= MASTERY_THRESHOLD) {
|
||||
masteredRef.current.add(wid)
|
||||
|
||||
const newQuestions = questionsRef.current.filter(q => {
|
||||
if (appendedQRef.current.has(q.id)) return false
|
||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
||||
if (wordIds.length === 0) return false
|
||||
return wordIds.every(id => masteredRef.current.has(id))
|
||||
})
|
||||
|
||||
if (newQuestions.length > 0) {
|
||||
const extra = buildQuestionCardsFor(
|
||||
newQuestions,
|
||||
masteredRef.current,
|
||||
userLevelRef.current,
|
||||
toLangRef.current,
|
||||
)
|
||||
extra.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
||||
setCards(prev => [...prev, ...extra])
|
||||
setEmpty(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="feed">
|
||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
||||
Lade Karten…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="feed">
|
||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
||||
Super! Du hast alle Wörter deines Levels gemeistert. Neue Wörter kommen bald.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="feed">
|
||||
{cards.map((item, i) => {
|
||||
const enrichedCard = { ...item.card, totalPoints: runningPoints }
|
||||
const handler = (r) => handleComplete(item, r)
|
||||
return (
|
||||
<div key={i} className="feed-slot">
|
||||
{item.type === 'text' && <NewWordTextCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'voice' && <NewWordVoiceCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'letter' && <LetterOrderCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'sentence' && <SentenceFillCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'languparent' && <LanguageParentCard card={enrichedCard} onComplete={handler} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user