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:
2026-05-14 22:15:51 +02:00
commit a708152fc1
45 changed files with 6188 additions and 0 deletions

270
src/api/directus.js Normal file
View 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
}