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:
270
src/api/directus.js
Normal file
270
src/api/directus.js
Normal file
@@ -0,0 +1,270 @@
|
||||
const BASE = import.meta.env.VITE_DIRECTUS_URL
|
||||
|
||||
const json = { 'Content-Type': 'application/json' }
|
||||
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
||||
|
||||
export async function login(email, password) {
|
||||
const res = await fetch(`${BASE}/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
|
||||
}
|
||||
|
||||
export async function getMe(userToken) {
|
||||
const res = await fetch(`${BASE}/users/me?fields=id,username,language_native,language_target`, {
|
||||
headers: auth(userToken),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
||||
return data.data
|
||||
}
|
||||
|
||||
// Nutzt den öffentlichen Registrierungsendpunkt — kein Admin-Token nötig
|
||||
export async function registerUser(email, password) {
|
||||
const res = await fetch(`${BASE}/users/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
|
||||
}
|
||||
|
||||
export async function checkUsername(username, userToken) {
|
||||
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
||||
const res = await fetch(
|
||||
`${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
return Array.isArray(data.data) && data.data.length === 0
|
||||
}
|
||||
|
||||
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 }),
|
||||
})
|
||||
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 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 data = await res.json()
|
||||
return (data.data || []).map(l => ({
|
||||
id: l.id,
|
||||
label: l.title_de,
|
||||
suffix: l.short,
|
||||
...(LANG_META[l.short] || { flag: '🌐', speech: l.short }),
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) return null
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function addPointsToPair(pairId, newPoints, userToken) {
|
||||
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) }
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user