|
|
|
|
@@ -1,98 +1,78 @@
|
|
|
|
|
const BASE = import.meta.env.VITE_DIRECTUS_URL
|
|
|
|
|
const BASE = import.meta.env.VITE_API_URL
|
|
|
|
|
|
|
|
|
|
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}/auth/login`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: json,
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/auth/login`, {
|
|
|
|
|
method: 'POST', headers: json,
|
|
|
|
|
body: JSON.stringify({ email, password }),
|
|
|
|
|
})
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.')
|
|
|
|
|
return data.data
|
|
|
|
|
// 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 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getMe(userToken) {
|
|
|
|
|
const res = await fetch(`${BASE}/users/me?fields=id,username,language_native,language_target`, {
|
|
|
|
|
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.data
|
|
|
|
|
return data // bereits vom API-Server entpackt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nutzt den öffentlichen Registrierungsendpunkt — kein Admin-Token nötig
|
|
|
|
|
// Registriert den Directus-User; gibt { userId } zurück
|
|
|
|
|
export async function registerUser(email, password) {
|
|
|
|
|
const res = await fetch(`${BASE}/users/register`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: json,
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/auth/register`, {
|
|
|
|
|
method: 'POST', headers: json,
|
|
|
|
|
body: JSON.stringify({ email, password }),
|
|
|
|
|
})
|
|
|
|
|
if (res.status === 204) return null
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.')
|
|
|
|
|
return data.data
|
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen.')
|
|
|
|
|
return data // { userId }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function checkUsername(username, userToken) {
|
|
|
|
|
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
|
|
|
|
export async function checkUsername(username /*, _userToken — nicht mehr nötig */) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
`${BASE}/languparent/auth/check-username?username=${encodeURIComponent(username)}`,
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return Array.isArray(data.data) && data.data.length === 0
|
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Username-Check fehlgeschlagen.')
|
|
|
|
|
return data.available
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) {
|
|
|
|
|
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
|
|
|
|
const h = auth(userToken)
|
|
|
|
|
|
|
|
|
|
const profileRes = await fetch(`${BASE}/items/users_language`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: h,
|
|
|
|
|
body: JSON.stringify({ username_public: username, username_lowercases: clean }),
|
|
|
|
|
// Erstellt Profil über API-Server (Admin-Token bleibt server-seitig)
|
|
|
|
|
// Gibt { token, expiresIn } zurück — muss via saveToken gespeichert werden
|
|
|
|
|
export async function createProfile({ userId, username, nativeLang, targetLang /*, userToken — ignoriert */ }) {
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/auth/profile`, {
|
|
|
|
|
method: 'POST', headers: json,
|
|
|
|
|
body: JSON.stringify({ userId, username, nativeLang, targetLang }),
|
|
|
|
|
})
|
|
|
|
|
const profileData = await profileRes.json()
|
|
|
|
|
if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.')
|
|
|
|
|
const profileId = profileData.data.id
|
|
|
|
|
|
|
|
|
|
await fetch(`${BASE}/users/${userId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: h,
|
|
|
|
|
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await fetch(`${BASE}/items/users_language/${profileId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: h,
|
|
|
|
|
body: JSON.stringify({ user: userId }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await fetch(`${BASE}/items/learning_pairs`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: h,
|
|
|
|
|
body: JSON.stringify({ user: profileId, language_from: nativeLang, language_to: targetLang, active: true, current_level: 1, points: 0 }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return profileId
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) throw new Error(data.error || 'Profilerstellung fehlgeschlagen.')
|
|
|
|
|
return data // { token, expiresIn }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Sprachen ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const LANG_META = {
|
|
|
|
|
de: { flag: '🇩🇪', speech: 'de-DE' },
|
|
|
|
|
en: { flag: '🇬🇧', speech: 'en-US' },
|
|
|
|
|
se: { flag: '🇸🇪', speech: 'sv-SE' },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lädt Sprachen aus Directus (public, kein Token nötig)
|
|
|
|
|
export async function getLanguageOptions() {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/language_options?filter[status][_eq]=published&fields=id,title_de,short&sort=title_en`
|
|
|
|
|
)
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/languages`)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return (data.data || []).map(l => ({
|
|
|
|
|
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,
|
|
|
|
|
@@ -104,167 +84,81 @@ export function langById(id, options) {
|
|
|
|
|
return (options || []).find(l => l.id === id) || null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getActiveLearningPair(profileId, userToken) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/learning_pairs?filter[user][_eq]=${profileId}&filter[active][_eq]=true` +
|
|
|
|
|
`&fields=id,language_from,language_to,current_level,points&limit=1`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return data.data?.[0] || null
|
|
|
|
|
}
|
|
|
|
|
// ── Learning Pair ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getWords(userToken, limit = 100) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/words?filter[status][_eq]=published` +
|
|
|
|
|
`&fields=id,title_de,title_en,title_se,level&limit=${limit}`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return data.data || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getQuestions(userToken, limit = 100) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/questions?filter[status][_eq]=published` +
|
|
|
|
|
`&fields=id,question_de,question_en,question_se,answer_de,answer_en,answer_se,level,related_words.words_id` +
|
|
|
|
|
`&limit=${limit}`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return data.data || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Holt Progress für einen Nutzer, optional gefiltert auf eine Zielsprache
|
|
|
|
|
export async function getUserProgress(profileId, userToken, toLangId = null) {
|
|
|
|
|
let url = `${BASE}/items/user_progress?filter[user][_eq]=${profileId}` +
|
|
|
|
|
`&fields=id,word,question,card_type,result,language_from,language_to&limit=-1`
|
|
|
|
|
if (toLangId) url += `&filter[language_to][_eq]=${toLangId}`
|
|
|
|
|
const res = await fetch(url, { headers: auth(userToken) })
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return data.data || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function saveProgress(payload, userToken) {
|
|
|
|
|
const res = await fetch(`${BASE}/items/user_progress`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: auth(userToken),
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
})
|
|
|
|
|
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.data
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addPointsToPair(pairId, newPoints, userToken) {
|
|
|
|
|
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: auth(userToken),
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/pair/${pairId}/points`, {
|
|
|
|
|
method: 'PATCH', headers: auth(userToken),
|
|
|
|
|
body: JSON.stringify({ points: newPoints }),
|
|
|
|
|
})
|
|
|
|
|
return res.ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addPointsToUser(userId, newTotal, userToken) {
|
|
|
|
|
const res = await fetch(`${BASE}/users/${userId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: auth(userToken),
|
|
|
|
|
body: JSON.stringify({ points_total: newTotal }),
|
|
|
|
|
})
|
|
|
|
|
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 || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Asset-URL inkl. User-Token, da Directus Hetzner-Storage standardmäßig nicht öffentlich ist
|
|
|
|
|
export function assetUrl(fileId, userToken) {
|
|
|
|
|
if (!fileId) return null
|
|
|
|
|
const base = `${BASE}/assets/${fileId}`
|
|
|
|
|
return userToken ? `${base}?access_token=${encodeURIComponent(userToken)}` : base
|
|
|
|
|
export async function getQuestions(userToken) {
|
|
|
|
|
const res = await fetch(`${BASE}/languparent/questions`, { headers: auth(userToken) })
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
return data || []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Lädt qa_pairs (db_pairs) auf einem bestimmten Level und liefert pro Pair
|
|
|
|
|
// das zugehörige Bild + den fertig aufgelösten Statement-Satz zurück.
|
|
|
|
|
export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') {
|
|
|
|
|
const fields = [
|
|
|
|
|
'id',
|
|
|
|
|
'picture.picture',
|
|
|
|
|
'selections',
|
|
|
|
|
'word_main.db_words_id.id',
|
|
|
|
|
'word_main.db_words_id.titel_de',
|
|
|
|
|
'word_main.db_words_id.titel_en',
|
|
|
|
|
'word_main.db_words_id.titel_se',
|
|
|
|
|
'pairs.db_pairs_id.id',
|
|
|
|
|
'pairs.db_pairs_id.level',
|
|
|
|
|
'pairs.db_pairs_id.status',
|
|
|
|
|
'pairs.db_pairs_id.statement.db_statement_id.statement_de',
|
|
|
|
|
'pairs.db_pairs_id.statement.db_statement_id.statement_en',
|
|
|
|
|
'pairs.db_pairs_id.statement.db_statement_id.statement_se',
|
|
|
|
|
].join(',')
|
|
|
|
|
|
|
|
|
|
const filter = encodeURIComponent(
|
|
|
|
|
JSON.stringify({ pairs: { db_pairs_id: { level: { _eq: level } } } })
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/items/db_objects?fields=${fields}&filter=${filter}&limit=-1`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
`${BASE}/languparent/qa-pairs?level=${level}&lang=${langSuffix}`,
|
|
|
|
|
{ headers: auth(userToken) },
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) return []
|
|
|
|
|
|
|
|
|
|
const statementKey = `statement_${langSuffix}`
|
|
|
|
|
const titelKey = `titel_${langSuffix}`
|
|
|
|
|
const result = []
|
|
|
|
|
|
|
|
|
|
for (const obj of data.data || []) {
|
|
|
|
|
const wordsById = {}
|
|
|
|
|
let primaryWord = ''
|
|
|
|
|
for (const w of obj.word_main || []) {
|
|
|
|
|
const word = w.db_words_id
|
|
|
|
|
if (!word) continue
|
|
|
|
|
const text = word[titelKey] || word.titel_de || ''
|
|
|
|
|
if (text) wordsById[word.id] = text
|
|
|
|
|
if (!primaryWord && text) primaryWord = text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const p of obj.pairs || []) {
|
|
|
|
|
const pair = p.db_pairs_id
|
|
|
|
|
if (!pair) continue
|
|
|
|
|
if (pair.level !== level) continue
|
|
|
|
|
if (pair.status === 'archived') continue
|
|
|
|
|
|
|
|
|
|
const stmtRow = pair.statement?.[0]?.db_statement_id
|
|
|
|
|
if (!stmtRow) continue
|
|
|
|
|
const raw = stmtRow[statementKey] || stmtRow.statement_de || ''
|
|
|
|
|
if (!raw) continue
|
|
|
|
|
|
|
|
|
|
const statement = raw.replace(/\{([^}]+)\}/g, (_m, ref) => {
|
|
|
|
|
const parts = ref.split('.')
|
|
|
|
|
const wid = parts[parts.length - 1]
|
|
|
|
|
return wordsById[wid] || primaryWord
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
result.push({
|
|
|
|
|
pairId: pair.id,
|
|
|
|
|
level: pair.level,
|
|
|
|
|
statement,
|
|
|
|
|
pictureFileId: obj.picture?.picture || null,
|
|
|
|
|
objectId: obj.id,
|
|
|
|
|
primaryWord,
|
|
|
|
|
selections: obj.selections || null,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
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
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Assets ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BASE}/users/me?fields=id,username.id,username.username_public,` +
|
|
|
|
|
`language_native,language_target,points_total,streak_days`,
|
|
|
|
|
{ headers: auth(userToken) }
|
|
|
|
|
)
|
|
|
|
|
const data = await res.json()
|
|
|
|
|
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
|
|
|
|
return data.data
|
|
|
|
|
return getMe(userToken)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stub — Punkte laufen über addPointsToPair
|
|
|
|
|
export async function addPointsToUser() { return true }
|
|
|
|
|
|