diff --git a/docker-compose.yml b/docker-compose.yml
index 590aa0c..79e7a58 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,7 +24,7 @@ services:
APP_ENV: development
FRONTEND_ORIGIN: http://localhost:3000
ports:
- - "8001:8000"
+ - "8000:8000"
depends_on:
- postgres
- redis
@@ -34,9 +34,13 @@ services:
frontend:
build: ./frontend
environment:
- VITE_API_BASE_URL: http://localhost:8000
+ VITE_ALLOWED_HOST: prof.open-squared.tech
+ VITE_DEV_API_PROXY_TARGET: http://backend:8000
+ VITE_HMR_HOST: prof.open-squared.tech
+ VITE_HMR_PROTOCOL: wss
+ VITE_HMR_CLIENT_PORT: 443
ports:
- - "3001:3000"
+ - "3000:3000"
depends_on:
- backend
volumes:
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7d38afa..c003afa 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -2,6 +2,33 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
const API_BASE = '/api'
+async function parseApiResponse(res) {
+ const contentType = res.headers.get('content-type') || ''
+ const bodyText = await res.text()
+ const isJson = contentType.includes('application/json')
+ const data = isJson && bodyText ? JSON.parse(bodyText) : null
+
+ if (!res.ok) {
+ const detail =
+ data?.detail ||
+ data?.message ||
+ bodyText.trim() ||
+ `Erreur API (${res.status})`
+ throw new Error(detail)
+ }
+
+ if (!isJson) {
+ throw new Error(`Réponse API invalide (${res.status})`)
+ }
+
+ return data
+}
+
+async function apiFetch(path, options) {
+ const res = await fetch(`${API_BASE}${path}`, options)
+ return parseApiResponse(res)
+}
+
function Avatar({ speaking }) {
return (
@@ -59,6 +86,7 @@ export default function App() {
const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)
const [voiceStatus, setVoiceStatus] = useState('')
+ const [errorMessage, setErrorMessage] = useState('')
const recognitionRef = useRef(null)
const mediaRecorderRef = useRef(null)
const mediaStreamRef = useRef(null)
@@ -90,33 +118,50 @@ export default function App() {
}, [])
async function loadStudents() {
- const res = await fetch(`${API_BASE}/students`)
- const data = await res.json()
- setStudents(data)
- if (data.length && !selectedStudentId) {
- setSelectedStudentId(String(data[0].id))
+ try {
+ setErrorMessage('')
+ const data = await apiFetch('/students')
+ setStudents(data)
+ if (data.length && !selectedStudentId) {
+ setSelectedStudentId(String(data[0].id))
+ }
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de charger les élèves.')
}
}
async function createStudent(e) {
e.preventDefault()
- const res = await fetch(`${API_BASE}/students`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...form, age: Number(form.age) }),
- })
- const data = await res.json()
- await loadStudents()
- setSelectedStudentId(String(data.id))
+ if (!form.first_name.trim()) {
+ setErrorMessage('Le prénom est obligatoire.')
+ return
+ }
+
+ try {
+ setErrorMessage('')
+ const data = await apiFetch('/students', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...form, first_name: form.first_name.trim(), age: Number(form.age) }),
+ })
+ await loadStudents()
+ setSelectedStudentId(String(data.id))
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de créer l’élève.')
+ }
}
async function startSession() {
if (!selectedStudentId) return
- const res = await fetch(`${API_BASE}/session/start?student_id=${selectedStudentId}`, { method: 'POST' })
- const data = await res.json()
- appendMessage('assistant', data.reply)
- speak(data.reply)
- await loadProgress(selectedStudentId)
+ try {
+ setErrorMessage('')
+ const data = await apiFetch(`/session/start?student_id=${selectedStudentId}`, { method: 'POST' })
+ appendMessage('assistant', data.reply)
+ speak(data.reply)
+ await loadProgress(selectedStudentId)
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de démarrer la séance.')
+ }
}
function appendMessage(role, content) {
@@ -129,48 +174,64 @@ export default function App() {
const text = input.trim()
appendMessage('user', text)
setInput('')
- const res = await fetch(`${API_BASE}/chat`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }),
- })
- const data = await res.json()
- appendMessage('assistant', data.reply)
- speak(data.reply)
+ try {
+ setErrorMessage('')
+ const data = await apiFetch('/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }),
+ })
+ appendMessage('assistant', data.reply)
+ speak(data.reply)
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible d’envoyer le message.')
+ }
}
async function loadProgress(studentId) {
- const res = await fetch(`${API_BASE}/progress/${studentId}`)
- const data = await res.json()
- setProgress(data.progress || [])
+ try {
+ setErrorMessage('')
+ const data = await apiFetch(`/progress/${studentId}`)
+ setProgress(data.progress || [])
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de charger la progression.')
+ }
}
async function fetchAssessment() {
if (!selectedStudentId) return
- const res = await fetch(`${API_BASE}/assessment/next/${selectedStudentId}`)
- const data = await res.json()
- setAssessment(data)
- setAssessmentAnswer('')
+ try {
+ setErrorMessage('')
+ const data = await apiFetch(`/assessment/next/${selectedStudentId}`)
+ setAssessment(data)
+ setAssessmentAnswer('')
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de charger le mini-test.')
+ }
}
async function submitAssessment(e) {
e.preventDefault()
if (!assessment || !assessmentAnswer.trim()) return
- const res = await fetch(`${API_BASE}/assessment/answer`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- student_id: Number(selectedStudentId),
- skill_code: assessment.skill_code,
- answer: assessmentAnswer,
- }),
- })
- const data = await res.json()
- appendMessage('assistant', `Quiz: ${data.feedback}`)
- speak(data.feedback)
- setAssessment(null)
- setAssessmentAnswer('')
- await loadProgress(selectedStudentId)
+ try {
+ setErrorMessage('')
+ const data = await apiFetch('/assessment/answer', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ student_id: Number(selectedStudentId),
+ skill_code: assessment.skill_code,
+ answer: assessmentAnswer,
+ }),
+ })
+ appendMessage('assistant', `Quiz: ${data.feedback}`)
+ speak(data.feedback)
+ setAssessment(null)
+ setAssessmentAnswer('')
+ await loadProgress(selectedStudentId)
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de valider la réponse.')
+ }
}
function speak(text) {
@@ -203,17 +264,10 @@ export default function App() {
setVoiceStatus('Transcription en cours...')
try {
- const res = await fetch(`${API_BASE}/transcribe`, {
+ const data = await apiFetch('/transcribe', {
method: 'POST',
body: formData,
})
-
- if (!res.ok) {
- const error = await res.json().catch(() => ({}))
- throw new Error(error.detail || 'La transcription a échoué.')
- }
-
- const data = await res.json()
setInput(data.text || '')
setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.')
} catch (error) {
@@ -416,7 +470,8 @@ export default function App() {
{voiceStatus &&
{voiceStatus}
}
+ {errorMessage &&
{errorMessage}
}
)
-}
\ No newline at end of file
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 08dba1b..a32acd7 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,10 +1,32 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
-export default defineConfig({
- plugins: [react()],
- server: {
- host: true,
- allowedHosts: ['prof.open-squared.tech']
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const allowedHost = env.VITE_ALLOWED_HOST || 'prof.open-squared.tech'
+ const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || 'http://backend:8000'
+ const hmrHost = env.VITE_HMR_HOST
+ const hmrProtocol = env.VITE_HMR_PROTOCOL
+ const hmrClientPort = env.VITE_HMR_CLIENT_PORT ? Number(env.VITE_HMR_CLIENT_PORT) : undefined
+
+ return {
+ plugins: [react()],
+ server: {
+ host: true,
+ allowedHosts: [allowedHost],
+ proxy: {
+ '/api': {
+ target: proxyTarget,
+ changeOrigin: true,
+ },
+ },
+ hmr: hmrHost
+ ? {
+ host: hmrHost,
+ protocol: hmrProtocol || 'ws',
+ clientPort: hmrClientPort,
+ }
+ : undefined,
+ },
}
})