From bccec179a4ac42b2fa9b88222e71a07e53019050 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 14 May 2026 22:43:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20migrate=20from=20Directus=20direct=20?= =?UTF-8?q?=E2=86=92=20API=20server=20(api.hejyou.com)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.production | 2 +- src/api/directus.js | 292 ++++++++------------------ src/components/auth/LoginForm.jsx | 13 +- src/components/auth/RegisterStep1.jsx | 11 +- src/components/auth/RegisterStep2.jsx | 7 +- 5 files changed, 108 insertions(+), 217 deletions(-) diff --git a/.env.production b/.env.production index e5edbbb..ba6125e 100644 --- a/.env.production +++ b/.env.production @@ -1 +1 @@ -VITE_DIRECTUS_URL=https://db.hejyou.com +VITE_API_URL=https://api.hejyou.com diff --git a/src/api/directus.js b/src/api/directus.js index 32678c4..aaa8919 100644 --- a/src/api/directus.js +++ b/src/api/directus.js @@ -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 } diff --git a/src/components/auth/LoginForm.jsx b/src/components/auth/LoginForm.jsx index b8a4fd4..53f4077 100644 --- a/src/components/auth/LoginForm.jsx +++ b/src/components/auth/LoginForm.jsx @@ -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 { diff --git a/src/components/auth/RegisterStep1.jsx b/src/components/auth/RegisterStep1.jsx index bd2c02b..6ff183e 100644 --- a/src/components/auth/RegisterStep1.jsx +++ b/src/components/auth/RegisterStep1.jsx @@ -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 { diff --git a/src/components/auth/RegisterStep2.jsx b/src/components/auth/RegisterStep2.jsx index 60acd29..c4e8f22 100644 --- a/src/components/auth/RegisterStep2.jsx +++ b/src/components/auth/RegisterStep2.jsx @@ -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)