fix: remove /languparent path prefix and align auth/feed flow with API
- Remove /languparent prefix from all API calls (routes are at /auth/* now) - Align register/login token handling (JWT returned directly, no registration token) - Feed rewritten to use new getFeedPairs endpoint with PairSentenceCard/YesNoCard/WordCard - App.jsx: check language_native_id / language_target_id for profile completeness - Profil.jsx: add logout button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ function AppContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !user.username || !user.language_native || !user.language_target) {
|
if (!user || !user.username || !user.language_native_id || !user.language_target_id) {
|
||||||
return <AuthScreen />
|
return <AuthScreen />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,57 @@
|
|||||||
|
// snakkimo-API client (Modulname bleibt directus.js aus historischen Gründen)
|
||||||
const BASE = import.meta.env.VITE_API_URL
|
const BASE = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
const json = { 'Content-Type': 'application/json' }
|
const json = { 'Content-Type': 'application/json' }
|
||||||
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(email, password) {
|
export async function login(email, password) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/login`, {
|
const res = await fetch(`${BASE}/auth/login`, {
|
||||||
method: 'POST', headers: json,
|
method: 'POST', headers: json,
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Kein Profil vorhanden → UI zum Profil-Setup leiten
|
|
||||||
if (res.status === 403 && data.needsProfile) {
|
|
||||||
return { access_token: null, needsProfile: true, userId: data.userId }
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Login fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Login fehlgeschlagen.')
|
||||||
return { access_token: data.token }
|
return { access_token: data.token, needsProfile: !!data.needsProfile, userId: data.user?.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/auth/me`, {
|
|
||||||
headers: auth(userToken),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
|
||||||
return data // bereits vom API-Server entpackt
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registriert den Directus-User; gibt { registrationToken } zurück (10 Min gültig)
|
|
||||||
export async function registerUser(email, password) {
|
export async function registerUser(email, password) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/register`, {
|
const res = await fetch(`${BASE}/auth/register`, {
|
||||||
method: 'POST', headers: json,
|
method: 'POST', headers: json,
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
|
||||||
return data // { registrationToken }
|
return { token: data.token, userId: data.user?.id, needsProfile: !!data.needsProfile }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUsername(username /*, _userToken — nicht mehr nötig */) {
|
export async function getMe(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/me`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Profil konnte nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUsername(username, userToken) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE}/languparent/auth/check-username?username=${encodeURIComponent(username)}`,
|
`${BASE}/auth/check-username?username=${encodeURIComponent(username)}`,
|
||||||
|
{ headers: auth(userToken) },
|
||||||
)
|
)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
|
||||||
return data.available
|
return data.available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erstellt Profil über API-Server (Admin-Token bleibt server-seitig)
|
// Legt Profil an (user_names + users_public). userToken = JWT aus register/login.
|
||||||
// userToken ist das kurzlebige Registration-Token aus registerUser()
|
|
||||||
// Gibt { token, expiresIn } zurück — muss via saveToken gespeichert werden
|
|
||||||
export async function createProfile({ username, nativeLang, targetLang, userToken }) {
|
export async function createProfile({ username, nativeLang, targetLang, userToken }) {
|
||||||
const res = await fetch(`${BASE}/languparent/auth/profile`, {
|
const res = await fetch(`${BASE}/auth/profile`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: auth(userToken),
|
||||||
headers: { ...json, 'X-Registration-Token': userToken },
|
|
||||||
body: JSON.stringify({ username, nativeLang, targetLang }),
|
body: JSON.stringify({ username, nativeLang, targetLang }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
|
if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
|
||||||
return data // { token, expiresIn }
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sprachen ──────────────────────────────────────────────────────────────────
|
// ── Sprachen ──────────────────────────────────────────────────────────────────
|
||||||
@@ -67,18 +59,18 @@ export async function createProfile({ username, nativeLang, targetLang, userToke
|
|||||||
const LANG_META = {
|
const LANG_META = {
|
||||||
de: { flag: '🇩🇪', speech: 'de-DE' },
|
de: { flag: '🇩🇪', speech: 'de-DE' },
|
||||||
en: { flag: '🇬🇧', speech: 'en-US' },
|
en: { flag: '🇬🇧', speech: 'en-US' },
|
||||||
se: { flag: '🇸🇪', speech: 'sv-SE' },
|
sv: { flag: '🇸🇪', speech: 'sv-SE' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLanguageOptions() {
|
export async function getLanguageOptions() {
|
||||||
const res = await fetch(`${BASE}/languparent/languages`)
|
const res = await fetch(`${BASE}/auth/languages`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.')
|
if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.')
|
||||||
return (data || []).map(l => ({
|
return (data || []).map(l => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
label: l.title_de,
|
label: l.titel_de,
|
||||||
suffix: l.short,
|
suffix: l.short_en,
|
||||||
...(LANG_META[l.short] || { flag: '🌐', speech: l.short }),
|
...(LANG_META[l.short_en] || { flag: '🌐', speech: l.short_en }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,81 +78,27 @@ export function langById(id, options) {
|
|||||||
return (options || []).find(l => l.id === id) || null
|
return (options || []).find(l => l.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Learning Pair ─────────────────────────────────────────────────────────────
|
// ── Feed ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getActiveLearningPair(_profileId, userToken) {
|
export async function getFeedPairs(userToken, lang = 'de', limit = 20) {
|
||||||
// profileId steckt im JWT — der API-Server liest ihn von dort
|
|
||||||
const res = await fetch(`${BASE}/languparent/pair`, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) return null
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addPointsToPair(pairId, newPoints, userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/pair/${pairId}/points`, {
|
|
||||||
method: 'PATCH', headers: auth(userToken),
|
|
||||||
body: JSON.stringify({ points: newPoints }),
|
|
||||||
})
|
|
||||||
return res.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWords(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/words`, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuestions(userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/questions`, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') {
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE}/languparent/qa-pairs?level=${level}&lang=${langSuffix}`,
|
`${BASE}/auth/feed?lang=${encodeURIComponent(lang)}&limit=${limit}`,
|
||||||
{ headers: auth(userToken) },
|
{ headers: auth(userToken) }
|
||||||
)
|
)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data || []
|
if (!res.ok) throw new Error(data.error || 'Feed konnte nicht geladen werden.')
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fortschritt ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getUserProgress(_profileId, userToken, toLangId = null) {
|
|
||||||
// profileId steckt im JWT — der API-Server filtert danach
|
|
||||||
let url = `${BASE}/languparent/progress`
|
|
||||||
if (toLangId) url += `?lang=${toLangId}`
|
|
||||||
const res = await fetch(url, { headers: auth(userToken) })
|
|
||||||
const data = await res.json()
|
|
||||||
return data || []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveProgress(payload, userToken) {
|
|
||||||
const res = await fetch(`${BASE}/languparent/progress`, {
|
|
||||||
method: 'POST', headers: auth(userToken),
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) return null
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Assets ────────────────────────────────────────────────────────────────────
|
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
||||||
|
|
||||||
// Bilder via API-Server proxied — kein Directus-Token im Browser nötig
|
export async function getActiveLearningPair() { return null }
|
||||||
export function assetUrl(fileId /*, _userToken — nicht mehr nötig */) {
|
export async function addPointsToPair() { return false }
|
||||||
if (!fileId) return null
|
export async function getWords() { return [] }
|
||||||
return `${BASE}/languparent/assets/${fileId}`
|
export async function getQuestions() { return [] }
|
||||||
}
|
export async function getQAPairsAtLevel() { return [] }
|
||||||
|
export async function getUserProgress() { return [] }
|
||||||
// ── Profil ────────────────────────────────────────────────────────────────────
|
export async function saveProgress() { return null }
|
||||||
|
export function assetUrl(fileId) { return fileId || null }
|
||||||
export async function getProfilData(userToken) {
|
export async function getProfilData(userToken) { return getMe(userToken) }
|
||||||
return getMe(userToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stub — Punkte laufen über addPointsToPair
|
|
||||||
export async function addPointsToUser() { return true }
|
export async function addPointsToUser() { return true }
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export default function LoginForm({ onNeedsProfile }) {
|
|||||||
try {
|
try {
|
||||||
const result = await login(email, pw)
|
const result = await login(email, pw)
|
||||||
if (result.needsProfile) {
|
if (result.needsProfile) {
|
||||||
onNeedsProfile(result.userId, null)
|
// JWT mitgeben — Profil-Step braucht es als Auth
|
||||||
|
onNeedsProfile(result.userId, result.access_token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saveToken(result.access_token)
|
saveToken(result.access_token)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export default function RegisterStep1({ onSuccess }) {
|
|||||||
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
|
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
|
||||||
setError(''); setLoading(true)
|
setError(''); setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { registrationToken } = await registerUser(email, pw)
|
const { token, userId } = await registerUser(email, pw)
|
||||||
onSuccess(null, registrationToken) // Token statt userId — AuthScreen speichert es als pendingToken
|
onSuccess(userId, token) // JWT direkt aus Register
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { checkUsername, createProfile, getLanguageOptions } from '../../api/directus'
|
import { checkUsername, createProfile, getLanguageOptions, getMe } from '../../api/directus'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
|
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
|
||||||
|
|
||||||
@@ -35,10 +35,11 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
|
|||||||
if (!available) {
|
if (!available) {
|
||||||
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
|
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
|
||||||
}
|
}
|
||||||
// userToken ist das kurzlebige Registration-Token aus Schritt 1
|
// userToken = JWT aus register/login. Profil anlegen, dann Token persistieren.
|
||||||
const { token } = await createProfile({ username, nativeLang, targetLang, userToken })
|
await createProfile({ username, nativeLang, targetLang, userToken })
|
||||||
saveToken(token)
|
saveToken(userToken)
|
||||||
setUser({ id: userId, username, language_native: nativeLang, language_target: targetLang })
|
const me = await getMe(userToken)
|
||||||
|
setUser(me)
|
||||||
onSuccess(username)
|
onSuccess(username)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|||||||
@@ -1,289 +1,49 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import {
|
import { getFeedPairs } from '../api/directus'
|
||||||
getActiveLearningPair, getWords, getQuestions, getUserProgress,
|
import PairSentenceCard from '../components/PairSentenceCard'
|
||||||
getLanguageOptions, langById,
|
import PairYesNoCard from '../components/PairYesNoCard'
|
||||||
saveProgress, addPointsToPair,
|
import PairWordCard from '../components/PairWordCard'
|
||||||
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
|
// Points per answer_type
|
||||||
// mindestens MASTERY_THRESHOLD korrekt beantwortete Kacheln gesammelt hat.
|
const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 }
|
||||||
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 buildCard(pair) {
|
||||||
function computePoints(cardLevel, userLevel) {
|
return {
|
||||||
return Math.max(1, 1 + ((cardLevel || 1) - (userLevel || 1)))
|
type: pair.answer_type,
|
||||||
}
|
meta: { pairId: pair.id, points: pair.difficulty_level || POINTS[pair.answer_type] || 2, cardType: pair.answer_type },
|
||||||
|
card: pair,
|
||||||
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() {
|
export default function Feed() {
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
const [cards, setCards] = useState([])
|
const [cards, setCards] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [done, setDone] = useState(new Set())
|
||||||
const [ctx, setCtx] = useState(null)
|
const [loading, setLoading] = useState(true)
|
||||||
const [runningPoints, setRunningPoints] = useState(0)
|
const [empty, setEmpty] = useState(false)
|
||||||
const [empty, setEmpty] = useState(false)
|
|
||||||
|
|
||||||
// Laufende Mastery-Verwaltung für diese Session
|
// Target language from user profile, fall back to 'de'
|
||||||
const correctsRef = useRef({}) // wordId -> Anzahl korrekter Antworten (persistiert + session)
|
const lang = user?.language_target_short || 'de'
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
getFeedPairs(token, lang, 20)
|
||||||
try {
|
.then(pairs => {
|
||||||
const [pair, langs] = await Promise.all([
|
const built = pairs.map(buildCard)
|
||||||
getActiveLearningPair(user.username, token),
|
setCards(built)
|
||||||
getLanguageOptions(),
|
setEmpty(built.length === 0)
|
||||||
])
|
|
||||||
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 {}
|
|
||||||
})
|
})
|
||||||
}
|
.catch(err => { console.error('Feed load error', err); setEmpty(true) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [token, lang])
|
||||||
|
|
||||||
// In-Session-Mastery: korrekte Wort-Antwort erhöht Zähler; neu gemasterte
|
function handleComplete(item) {
|
||||||
// Wörter können Frage-Kacheln freischalten.
|
setDone(prev => new Set([...prev, item.meta.pairId]))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed">
|
||||||
@@ -294,11 +54,13 @@ export default function Feed() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty) {
|
if (empty || visible.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed">
|
||||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
<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.
|
{cards.length === 0
|
||||||
|
? 'Noch keine Inhalte verfügbar.'
|
||||||
|
: 'Super! Alle Karten abgeschlossen. 🎉'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -306,16 +68,15 @@ export default function Feed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed">
|
||||||
{cards.map((item, i) => {
|
{visible.map((item) => {
|
||||||
const enrichedCard = { ...item.card, totalPoints: runningPoints }
|
const cardWithMeta = { ...item.card, meta: item.meta }
|
||||||
const handler = (r) => handleComplete(item, r)
|
const handler = () => handleComplete(item)
|
||||||
return (
|
return (
|
||||||
<div key={i} className="feed-slot">
|
<div key={item.meta.pairId} className="feed-slot">
|
||||||
{item.type === 'text' && <NewWordTextCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'text' && <PairSentenceCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'voice' && <NewWordVoiceCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'yes_no' && <PairYesNoCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'letter' && <LetterOrderCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'word' && <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'sentence' && <SentenceFillCard card={enrichedCard} onComplete={handler} />}
|
{item.type === 'question'&& <PairWordCard card={cardWithMeta} onComplete={handler} />}
|
||||||
{item.type === 'languparent' && <LanguageParentCard card={enrichedCard} onComplete={handler} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,6 +3,28 @@ import './Profil.css'
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus'
|
import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus'
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
return (
|
||||||
|
<button onClick={logout} title="Abmelden" style={{
|
||||||
|
position: 'absolute', top: '20px', right: '4px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
padding: '6px', borderRadius: '8px', color: '#9A8878',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'color 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.color = '#C0544A'; e.currentTarget.style.background = '#FBF0EF' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.color = '#9A8878'; e.currentTarget.style.background = 'none' }}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const SKILLS = [
|
const SKILLS = [
|
||||||
{ label: 'Vokabular', value: 0.78 },
|
{ label: 'Vokabular', value: 0.78 },
|
||||||
{ label: 'Grammatik', value: 0.65 },
|
{ label: 'Grammatik', value: 0.65 },
|
||||||
@@ -124,7 +146,8 @@ export default function Profil() {
|
|||||||
const streak = profil?.streak_days ?? 0
|
const streak = profil?.streak_days ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profil">
|
<div className="profil" style={{ position: 'relative' }}>
|
||||||
|
<LogoutButton />
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="profil-header">
|
<div className="profil-header">
|
||||||
<div className="avatar-wrap">
|
<div className="avatar-wrap">
|
||||||
|
|||||||
Reference in New Issue
Block a user