feat: migrate from Directus direct → API server (api.hejyou.com)

- directus.js: all calls now go to /languparent/* on api.hejyou.com
- Admin token and Directus URL no longer in the browser
- JWT issued by API server (30 days), stored in localStorage
- register: single call returns userId, profile step gets fresh JWT
- login: handles needsProfile flag (403) cleanly
- assetUrl: proxy via /languparent/assets/:fileId (no token in URL)
- .env.production: VITE_DIRECTUS_URL → VITE_API_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:43:36 +02:00
parent 0730fe6e8e
commit bccec179a4
5 changed files with 108 additions and 217 deletions

View File

@@ -1 +1 @@
VITE_DIRECTUS_URL=https://db.hejyou.com
VITE_API_URL=https://api.hejyou.com

View File

@@ -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}` })
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 }

View File

@@ -15,13 +15,14 @@ export default function LoginForm({ onNeedsProfile }) {
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
setError(''); setLoading(true)
try {
const { access_token } = await login(email, pw)
saveToken(access_token)
const me = await getMe(access_token)
setUser(me)
if (!me.username || !me.language_native || !me.language_target) {
onNeedsProfile(me.id, access_token)
const result = await login(email, pw)
if (result.needsProfile) {
onNeedsProfile(result.userId, null)
return
}
saveToken(result.access_token)
const me = await getMe(result.access_token)
setUser(me)
} catch (err) {
setError(err.message)
} finally {

View File

@@ -1,10 +1,8 @@
import { useState } from 'react'
import { registerUser, login, getMe } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { registerUser } from '../../api/directus'
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
export default function RegisterStep1({ onSuccess }) {
const { saveToken } = useAuth()
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [error, setError] = useState('')
@@ -16,11 +14,8 @@ export default function RegisterStep1({ onSuccess }) {
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
setError(''); setLoading(true)
try {
await registerUser(email, pw)
const { access_token } = await login(email, pw)
saveToken(access_token)
const me = await getMe(access_token)
onSuccess(me.id, access_token)
const { userId } = await registerUser(email, pw)
onSuccess(userId, null)
} catch (err) {
setError(err.message)
} finally {

View File

@@ -4,7 +4,7 @@ import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
export default function RegisterStep2({ userId, userToken, onSuccess }) {
const { setUser } = useAuth()
const { setUser, saveToken } = useAuth()
const [username, setUsername] = useState('')
const [nativeLang, setNativeLang] = useState('')
const [targetLang, setTargetLang] = useState('')
@@ -35,8 +35,9 @@ export default function RegisterStep2({ userId, userToken, onSuccess }) {
if (!available) {
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
}
await createProfile({ userId, username, nativeLang, targetLang, userToken })
setUser({ id: userId, username: userId, language_native: nativeLang, language_target: targetLang })
const { token } = await createProfile({ userId, username, nativeLang, targetLang })
saveToken(token)
setUser({ id: userId, username, language_native: nativeLang, language_target: targetLang })
onSuccess(username)
} catch (err) {
setError(err.message)