diff --git a/src/App.jsx b/src/App.jsx index 1159d82..48297e7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 } diff --git a/src/api/directus.js b/src/api/directus.js index 086625f..a05e5f6 100644 --- a/src/api/directus.js +++ b/src/api/directus.js @@ -1,65 +1,57 @@ +// snakkimo-API client (Modulname bleibt directus.js aus historischen Gründen) const BASE = import.meta.env.VITE_API_URL -const json = { 'Content-Type': 'application/json' } -const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }) +const json = { 'Content-Type': 'application/json' } +const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }) // ── Auth ────────────────────────────────────────────────────────────────────── 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, body: JSON.stringify({ email, password }), }) 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.') - 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) { - const res = await fetch(`${BASE}/languparent/auth/register`, { + const res = await fetch(`${BASE}/auth/register`, { method: 'POST', headers: json, body: JSON.stringify({ email, password }), }) const data = await res.json() 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( - `${BASE}/languparent/auth/check-username?username=${encodeURIComponent(username)}`, + `${BASE}/auth/check-username?username=${encodeURIComponent(username)}`, + { headers: auth(userToken) }, ) const data = await res.json() if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.') return data.available } -// Erstellt Profil über API-Server (Admin-Token bleibt server-seitig) -// userToken ist das kurzlebige Registration-Token aus registerUser() -// Gibt { token, expiresIn } zurück — muss via saveToken gespeichert werden +// Legt Profil an (user_names + users_public). userToken = JWT aus register/login. export async function createProfile({ username, nativeLang, targetLang, userToken }) { - const res = await fetch(`${BASE}/languparent/auth/profile`, { - method: 'POST', - headers: { ...json, 'X-Registration-Token': userToken }, + const res = await fetch(`${BASE}/auth/profile`, { + method: 'POST', headers: auth(userToken), body: JSON.stringify({ username, nativeLang, targetLang }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.') - return data // { token, expiresIn } + return data } // ── Sprachen ────────────────────────────────────────────────────────────────── @@ -67,18 +59,18 @@ export async function createProfile({ username, nativeLang, targetLang, userToke const LANG_META = { de: { flag: '🇩🇪', speech: 'de-DE' }, en: { flag: '🇬🇧', speech: 'en-US' }, - se: { flag: '🇸🇪', speech: 'sv-SE' }, + sv: { flag: '🇸🇪', speech: 'sv-SE' }, } export async function getLanguageOptions() { - const res = await fetch(`${BASE}/languparent/languages`) + const res = await fetch(`${BASE}/auth/languages`) const data = await res.json() if (!res.ok) throw new Error('Sprachen konnten nicht geladen werden.') return (data || []).map(l => ({ id: l.id, - label: l.title_de, - suffix: l.short, - ...(LANG_META[l.short] || { flag: '🌐', speech: l.short }), + label: l.titel_de, + suffix: l.short_en, + ...(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 } -// ── Learning Pair ───────────────────────────────────────────────────────────── +// ── Feed ────────────────────────────────────────────────────────────────────── -export async function getActiveLearningPair(_profileId, userToken) { - // 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') { +export async function getFeedPairs(userToken, lang = 'de', limit = 20) { const res = await fetch( - `${BASE}/languparent/qa-pairs?level=${level}&lang=${langSuffix}`, - { headers: auth(userToken) }, + `${BASE}/auth/feed?lang=${encodeURIComponent(lang)}&limit=${limit}`, + { headers: auth(userToken) } ) const data = await res.json() - return data || [] -} - -// ── 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 + if (!res.ok) throw new Error(data.error || 'Feed konnte nicht geladen werden.') return data } -// ── Assets ──────────────────────────────────────────────────────────────────── +// ── Stubs (content-Endpunkte kommen später) ─────────────────────────────────── -// Bilder via API-Server proxied — kein Directus-Token im Browser nötig -export function assetUrl(fileId /*, _userToken — nicht mehr nötig */) { - if (!fileId) return null - return `${BASE}/languparent/assets/${fileId}` -} - -// ── Profil ──────────────────────────────────────────────────────────────────── - -export async function getProfilData(userToken) { - return getMe(userToken) -} - -// Stub — Punkte laufen über addPointsToPair +export async function getActiveLearningPair() { return null } +export async function addPointsToPair() { return false } +export async function getWords() { return [] } +export async function getQuestions() { return [] } +export async function getQAPairsAtLevel() { return [] } +export async function getUserProgress() { return [] } +export async function saveProgress() { return null } +export function assetUrl(fileId) { return fileId || null } +export async function getProfilData(userToken) { return getMe(userToken) } export async function addPointsToUser() { return true } diff --git a/src/components/auth/LoginForm.jsx b/src/components/auth/LoginForm.jsx index 53f4077..bb2685f 100644 --- a/src/components/auth/LoginForm.jsx +++ b/src/components/auth/LoginForm.jsx @@ -17,7 +17,8 @@ export default function LoginForm({ onNeedsProfile }) { try { const result = await login(email, pw) if (result.needsProfile) { - onNeedsProfile(result.userId, null) + // JWT mitgeben — Profil-Step braucht es als Auth + onNeedsProfile(result.userId, result.access_token) return } saveToken(result.access_token) diff --git a/src/components/auth/RegisterStep1.jsx b/src/components/auth/RegisterStep1.jsx index 31594fc..26c39b8 100644 --- a/src/components/auth/RegisterStep1.jsx +++ b/src/components/auth/RegisterStep1.jsx @@ -14,8 +14,8 @@ export default function RegisterStep1({ onSuccess }) { if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return } setError(''); setLoading(true) try { - const { registrationToken } = await registerUser(email, pw) - onSuccess(null, registrationToken) // Token statt userId — AuthScreen speichert es als pendingToken + const { token, userId } = await registerUser(email, pw) + onSuccess(userId, token) // JWT direkt aus Register } catch (err) { setError(err.message) } finally { diff --git a/src/components/auth/RegisterStep2.jsx b/src/components/auth/RegisterStep2.jsx index 7aaad29..4c8ba4a 100644 --- a/src/components/auth/RegisterStep2.jsx +++ b/src/components/auth/RegisterStep2.jsx @@ -1,5 +1,5 @@ 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 { FormGroup, Input, Select, Button, Alert, StepDots } from './ui' @@ -35,10 +35,11 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) { if (!available) { setError('Dieser Username ist bereits vergeben.'); setLoading(false); return } - // userToken ist das kurzlebige Registration-Token aus Schritt 1 - const { token } = await createProfile({ username, nativeLang, targetLang, userToken }) - saveToken(token) - setUser({ id: userId, username, language_native: nativeLang, language_target: targetLang }) + // userToken = JWT aus register/login. Profil anlegen, dann Token persistieren. + await createProfile({ username, nativeLang, targetLang, userToken }) + saveToken(userToken) + const me = await getMe(userToken) + setUser(me) onSuccess(username) } catch (err) { setError(err.message) diff --git a/src/pages/Feed.jsx b/src/pages/Feed.jsx index 3e38053..de12d06 100644 --- a/src/pages/Feed.jsx +++ b/src/pages/Feed.jsx @@ -1,289 +1,49 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, 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' +import { getFeedPairs } from '../api/directus' +import PairSentenceCard from '../components/PairSentenceCard' +import PairYesNoCard from '../components/PairYesNoCard' +import PairWordCard from '../components/PairWordCard' -// 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 +// Points per answer_type +const POINTS = { text: 2, yes_no: 2, word: 3, question: 3 } -// 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]] +function buildCard(pair) { + return { + type: pair.answer_type, + meta: { pairId: pair.id, points: pair.difficulty_level || POINTS[pair.answer_type] || 2, cardType: pair.answer_type }, + card: pair, } - 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) + const [cards, setCards] = useState([]) + const [done, setDone] = useState(new Set()) + const [loading, setLoading] = useState(true) + 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 + // Target language from user profile, fall back to 'de' + const lang = user?.language_target_short || 'de' 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 {} + getFeedPairs(token, lang, 20) + .then(pairs => { + const built = pairs.map(buildCard) + setCards(built) + setEmpty(built.length === 0) }) - } + .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 - // 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) - } - } - } + function handleComplete(item) { + setDone(prev => new Set([...prev, item.meta.pairId])) } + const visible = cards.filter(c => !done.has(c.meta.pairId)) + if (loading) { return (
@@ -294,11 +54,13 @@ export default function Feed() { ) } - if (empty) { + if (empty || visible.length === 0) { return (
- 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. 🎉'}
) @@ -306,16 +68,15 @@ export default function Feed() { return (
- {cards.map((item, i) => { - const enrichedCard = { ...item.card, totalPoints: runningPoints } - const handler = (r) => handleComplete(item, r) + {visible.map((item) => { + const cardWithMeta = { ...item.card, meta: item.meta } + const handler = () => handleComplete(item) return ( -
- {item.type === 'text' && } - {item.type === 'voice' && } - {item.type === 'letter' && } - {item.type === 'sentence' && } - {item.type === 'languparent' && } +
+ {item.type === 'text' && } + {item.type === 'yes_no' && } + {item.type === 'word' && } + {item.type === 'question'&& }
) })} diff --git a/src/pages/Profil.jsx b/src/pages/Profil.jsx index 800a650..9bccd4e 100644 --- a/src/pages/Profil.jsx +++ b/src/pages/Profil.jsx @@ -3,6 +3,28 @@ import './Profil.css' import { useAuth } from '../context/AuthContext' import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus' +function LogoutButton() { + const { logout } = useAuth() + return ( + + ) +} + const SKILLS = [ { label: 'Vokabular', value: 0.78 }, { label: 'Grammatik', value: 0.65 }, @@ -124,7 +146,8 @@ export default function Profil() { const streak = profil?.streak_days ?? 0 return ( -
+
+ {/* ── Header ── */}