import React, { useEffect, useMemo, useRef, useState } from 'react'
import professeurTopImage from './assets/professeur-top.png'
const API_BASE = '/api'
const AUTO_STOP_SILENCE_MS = 2500
const SPEECH_START_THRESHOLD = 0.02
const SILENCE_THRESHOLD = 0.012
const DEBUG_AUDIO = false
const TTS_PROFILE_STORAGE_KEY = 'professeur-top-tts-profile'
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}`, {
credentials: 'same-origin',
...options,
})
return parseApiResponse(res)
}
async function fetchAudio(path, options) {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'same-origin',
...options,
})
if (!res.ok) {
let detail = `Erreur API (${res.status})`
const contentType = res.headers.get('content-type') || ''
const bodyText = await res.text()
if (contentType.includes('application/json') && bodyText) {
try {
const data = JSON.parse(bodyText)
detail = data?.detail || data?.message || detail
} catch {
if (bodyText.trim()) detail = bodyText.trim()
}
} else if (bodyText.trim()) {
detail = bodyText.trim()
}
throw new Error(detail)
}
return res.blob()
}
function Avatar({ speaking }) {
return (
)
}
function ProgressCard({ item }) {
const pct = Math.round(item.mastery_score)
return (
{item.label}
{pct}%
{item.subject} · preuves: {item.evidence_count}
)
}
function flattenProgramTree(node, depth = 0, rows = []) {
if (!node) return rows
rows.push({ node, depth })
;(node.children || []).forEach((child) => flattenProgramTree(child, depth + 1, rows))
return rows
}
function flattenVisibleProgramTree(node, collapsedKeys, depth = 0, rows = []) {
if (!node) return rows
rows.push({ node, depth })
if (!collapsedKeys.has(node.key)) {
;(node.children || []).forEach((child) => flattenVisibleProgramTree(child, collapsedKeys, depth + 1, rows))
}
return rows
}
function collectCollapsibleProgramKeys(node, depth = 0, keys = []) {
if (!node) return keys
if (depth > 0 && node.children?.length) keys.push(node.key)
;(node.children || []).forEach((child) => collectCollapsibleProgramKeys(child, depth + 1, keys))
return keys
}
function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview, readOnly = false, onOpenValidation }) {
const [isFullscreen, setIsFullscreen] = useState(false)
if (!selectedNode) {
return (
Programme
Choisis un élément de l’arborescence pour le relire, le valider ou laisser un commentaire.
)
}
const status = selectedNode.status || {}
const review = selectedNode.review || {}
return (
{selectedNode.type}
{selectedNode.label}
{selectedNode.path || selectedNode.key}
{review.status &&
{review.status}}
{Object.keys(status).length > 0 && (
Micro-fiches: {status.micro_fiches ?? 0}
SVG leçon: {status.lesson_svg ?? 0}
Cards: {status.cards ?? 0}
Contextes: {status.contexts ?? 0}
Exercices: {status.text_exercises ?? 0}
SVG exercices: {status.exercise_svg ?? 0}
Tests adaptatifs: {status.adaptive_tests ?? 0}
Parcours: {status.adaptive_profiles ?? 0}
SVG kit: {status.adaptive_svg ?? 0}
)}
{selectedNode.url && (
)}
{selectedNode.content && !selectedNode.url && (
)}
{review.comment && (
Dernier commentaire
{review.comment}
{review.reviewer} · {review.updated_at}
)}
{readOnly ? (
Validation centralisée
Cet onglet sert à explorer le programme. Les décisions éditoriales se font dans l’onglet Validation contenus.
) : (
)}
{isFullscreen && (
{selectedNode.type}
{selectedNode.label}
{selectedNode.path || selectedNode.key}
)}
)
}
function getSupportedRecordingMimeType() {
if (typeof MediaRecorder === 'undefined') return ''
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
]
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || ''
}
function formatVoiceLabel(profile) {
if (!profile?.label) return ''
return profile.label.toLowerCase().startsWith('voix ')
? profile.label
: `Voix ${profile.label}`
}
function LoginPage({ onLogin }) {
const [credentials, setCredentials] = useState({ username: '', password: '' })
const [status, setStatus] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitLogin(e) {
e.preventDefault()
if (!credentials.username.trim() || !credentials.password) return
setIsSubmitting(true)
setStatus('')
try {
const data = await apiFetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: credentials.username.trim(),
password: credentials.password,
}),
})
onLogin(data.user)
} catch (error) {
setStatus(error.message || 'Connexion impossible.')
} finally {
setIsSubmitting(false)
}
}
return (
)
}
function TutorApp({ currentUser, onLogout }) {
const isStudentUser = currentUser.role === 'student'
const [students, setStudents] = useState([])
const [selectedStudentId, setSelectedStudentId] = useState('')
const [form, setForm] = useState({
first_name: '',
age: 8,
grade: 'CM1',
username: '',
password: '',
})
const [messages, setMessages] = useState([])
const [currentInstruction, setCurrentInstruction] = useState('')
const [lastStudentTransmission, setLastStudentTransmission] = useState('')
const [sessionActive, setSessionActive] = useState(false)
const [currentFocusSkill, setCurrentFocusSkill] = useState(null)
const [exerciseStatus, setExerciseStatus] = useState('not-started')
const [input, setInput] = useState('')
const [progress, setProgress] = useState([])
const [assessment, setAssessment] = useState(null)
const [assessmentAnswer, setAssessmentAnswer] = useState('')
const [speaking, setSpeaking] = useState(false)
const [isRecording, setIsRecording] = useState(false)
const [isAutoListening, setIsAutoListening] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false)
const [voiceStatus, setVoiceStatus] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [micLevel, setMicLevel] = useState(0)
const [audioDebug, setAudioDebug] = useState([])
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const [programLessons, setProgramLessons] = useState([])
const [programStatus, setProgramStatus] = useState(null)
const [programTree, setProgramTree] = useState(null)
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [activeAdminTab, setActiveAdminTab] = useState('overview')
const [collapsedProgramKeys, setCollapsedProgramKeys] = useState(() => new Set())
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
const [programStart, setProgramStart] = useState({ asset_index: 0, section_index: 0 })
const [cardProgress, setCardProgress] = useState([])
const [adaptiveProfile, setAdaptiveProfile] = useState(null)
const [adaptiveProfileForm, setAdaptiveProfileForm] = useState({
diagnostic_id: '',
profile_id: '',
notes: '',
})
const [sessionCardStates, setSessionCardStates] = useState({})
const [sessionLogs, setSessionLogs] = useState([])
const [selectedSessionLog, setSelectedSessionLog] = useState(null)
const [sessionMessageStartIndex, setSessionMessageStartIndex] = useState(0)
const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null)
const mediaStreamRef = useRef(null)
const recordedChunksRef = useRef([])
const recordingMimeTypeRef = useRef('')
const audioContextRef = useRef(null)
const analyserRef = useRef(null)
const sourceNodeRef = useRef(null)
const processorNodeRef = useRef(null)
const monitorGainRef = useRef(null)
const silenceStartedAtRef = useRef(null)
const hasSpeechInSegmentRef = useRef(false)
const activeAssetIndexRef = useRef(0)
const activeSectionIndexRef = useRef(0)
const isRecordingRef = useRef(false)
const isAutoListeningRef = useRef(false)
const isTranscribingRef = useRef(false)
const speakingRef = useRef(false)
const levelFrameCountRef = useRef(0)
const audioPlaybackRef = useRef(null)
const audioPlaybackUrlRef = useRef('')
const selectedStudent = useMemo(
() => students.find((student) => String(student.id) === String(selectedStudentId)),
[students, selectedStudentId]
)
const selectedVoiceProfile = useMemo(
() => voiceProfiles.find((profile) => profile.id === selectedVoiceProfileId) || null,
[voiceProfiles, selectedVoiceProfileId]
)
const averageScore = useMemo(() => {
if (!progress.length) return 0
const total = progress.reduce((sum, item) => sum + item.mastery_score, 0)
return Math.round(total / progress.length)
}, [progress])
const lessonFocus = useMemo(() => {
if (currentFocusSkill) return currentFocusSkill
if (!progress.length) return null
return [...progress].sort((a, b) => a.mastery_score - b.mastery_score)[0]
}, [currentFocusSkill, progress])
const lessonPercent = lessonFocus ? Math.round(lessonFocus.mastery_score) : averageScore
const programRows = useMemo(() => flattenProgramTree(programTree).filter((row) => row.node.type !== 'root'), [programTree])
const visibleProgramRows = useMemo(
() => flattenVisibleProgramTree(programTree, collapsedProgramKeys).filter((row) => row.node.type !== 'root'),
[programTree, collapsedProgramKeys]
)
const selectedProgramNode = useMemo(
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
[programRows, selectedProgramNodeKey]
)
const exerciseLabel = {
'not-started': 'Pas encore lance',
'in-progress': 'En cours',
resolved: 'Resolu',
'needs-review': 'A revoir',
}[exerciseStatus]
useEffect(() => {
loadStudents()
if (!isStudentUser) {
loadProgramTree()
loadProgramStatus()
}
}, [currentUser])
useEffect(() => {
if (selectedProgramNode) {
setProgramReviewForm({
status: selectedProgramNode.review?.status || 'needs_changes',
comment: selectedProgramNode.review?.comment || '',
})
}
}, [selectedProgramNodeKey, selectedProgramNode?.review?.updated_at])
function pushAudioDebug(message) {
if (!DEBUG_AUDIO) return
const timestamp = new Date().toLocaleTimeString('fr-FR', { hour12: false })
setAudioDebug((prev) => [`${timestamp} ${message}`, ...prev].slice(0, 12))
}
function setLessonPosition(assetIndex, sectionIndex) {
activeAssetIndexRef.current = assetIndex
activeSectionIndexRef.current = sectionIndex
setActiveAssetIndex(assetIndex)
setActiveSectionIndex(sectionIndex)
}
function toggleProgramNode(node) {
if (!node?.children?.length) {
setSelectedProgramNodeKey(node?.key || '')
return
}
setSelectedProgramNodeKey(node.key)
setCollapsedProgramKeys((current) => {
const next = new Set(current)
if (next.has(node.key)) next.delete(node.key)
else next.add(node.key)
return next
})
}
useEffect(() => {
isRecordingRef.current = isRecording
}, [isRecording])
useEffect(() => {
activeAssetIndexRef.current = activeAssetIndex
}, [activeAssetIndex])
useEffect(() => {
activeSectionIndexRef.current = activeSectionIndex
}, [activeSectionIndex])
useEffect(() => {
setInput('')
setAssessmentAnswer('')
setLastStudentTransmission('')
}, [activeAssetIndex, activeSectionIndex])
useEffect(() => {
isAutoListeningRef.current = isAutoListening
}, [isAutoListening])
useEffect(() => {
isTranscribingRef.current = isTranscribing
}, [isTranscribing])
useEffect(() => {
speakingRef.current = speaking
}, [speaking])
useEffect(() => {
setCurrentInstruction('')
setSessionActive(false)
setAssessment(null)
setAssessmentAnswer('')
setLastStudentTransmission('')
setCurrentFocusSkill(null)
setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
setProgramStart({ asset_index: 0, section_index: 0 })
setCardProgress([])
setAdaptiveProfile(null)
setAdaptiveProfileForm({ diagnostic_id: '', profile_id: '', notes: '' })
setSessionCardStates({})
setSessionLogs([])
setSelectedSessionLog(null)
if (selectedStudentId) {
loadProgress(selectedStudentId)
loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
loadCardProgress(selectedStudentId)
if (!isStudentUser) {
loadProgramStatus()
loadSessionLogs(selectedStudentId)
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
} else {
setMessages([])
}
}, [selectedStudentId, students])
useEffect(() => {
if (selectedVoiceProfileId) {
window.localStorage.setItem(TTS_PROFILE_STORAGE_KEY, selectedVoiceProfileId)
}
}, [selectedVoiceProfileId])
useEffect(() => {
loadVoiceProfiles()
return () => {
stopSpeechPlayback()
deactivateAutoListening(false)
}
}, [])
useEffect(() => {
if (selectedStudentId && studentProgram?.id) {
loadCardProgress(selectedStudentId, studentProgram.id)
if (!isStudentUser) {
loadAdaptiveProfile(selectedStudentId, studentProgram.id)
}
}
}, [selectedStudentId, studentProgram?.id])
async function loadStudents() {
if (isStudentUser) {
if (currentUser.student) {
setStudents([currentUser.student])
setSelectedStudentId(String(currentUser.student.id))
} else {
setStudents([])
setSelectedStudentId('')
setErrorMessage('Ce compte élève n’est pas encore rattaché à une fiche élève.')
}
return
}
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 loadVoiceProfiles() {
try {
const data = await apiFetch('/tts/profiles')
const nextProfiles = data.profiles || []
setVoiceProfiles(nextProfiles)
setSelectedVoiceProfileId((currentId) => {
if (currentId && nextProfiles.some((profile) => profile.id === currentId)) {
return currentId
}
const storedId = window.localStorage.getItem(TTS_PROFILE_STORAGE_KEY)
if (storedId && nextProfiles.some((profile) => profile.id === storedId)) {
return storedId
}
return nextProfiles[0]?.id || ''
})
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger les voix du professeur.')
}
}
async function createStudent(e) {
e.preventDefault()
if (isStudentUser) return
if (!form.first_name.trim()) {
setErrorMessage('Le prénom est obligatoire.')
return
}
if (!form.username.trim() || !form.password) {
setErrorMessage('Identifiant et mot de passe élève obligatoires.')
return
}
try {
setErrorMessage('')
const data = await apiFetch('/admin/student-accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...form,
first_name: form.first_name.trim(),
username: form.username.trim(),
age: Number(form.age),
}),
})
await loadStudents()
setSelectedStudentId(String(data.student.id))
setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' })
} catch (error) {
setErrorMessage(error.message || 'Impossible de créer le compte élève.')
}
}
async function startSession() {
if (!selectedStudentId) return
try {
setErrorMessage('')
setInput('')
setAssessmentAnswer('')
setLastStudentTransmission('')
setVoiceStatus('')
setSessionMessageStartIndex(messages.length)
setSessionCardStates({})
const data = await apiFetch(`/session/start?student_id=${selectedStudentId}`, { method: 'POST' })
appendMessage('assistant', data.reply)
setCurrentInstruction(data.reply)
setSessionActive(true)
setExerciseStatus('not-started')
setCurrentFocusSkill((current) => current || lessonFocus)
speak(data.reply)
await loadProgress(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible de démarrer la séance.')
}
}
function appendMessage(role, content) {
setMessages((prev) => [...prev, { role, content, id: crypto.randomUUID() }])
}
async function loadProgress(studentId) {
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
try {
setErrorMessage('')
const data = await apiFetch(`/assessment/next/${selectedStudentId}`)
setAssessment(data)
setAssessmentAnswer('')
setCurrentInstruction(data.question)
setSessionActive(true)
setExerciseStatus('in-progress')
setCurrentFocusSkill(
progress.find((item) => item.code === data.skill_code) || {
code: data.skill_code,
label: data.skill_label,
mastery_score: lessonPercent,
}
)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger le mini-test.')
}
}
async function submitAssessment(e) {
e.preventDefault()
if (!assessment || !assessmentAnswer.trim()) return
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('user', `Reponse au mini-test: ${assessmentAnswer}`)
setLastStudentTransmission(assessmentAnswer)
appendMessage('assistant', data.feedback)
setCurrentInstruction(data.feedback)
setExerciseStatus(data.correct ? 'resolved' : 'needs-review')
speak(data.feedback)
setAssessment(null)
setAssessmentAnswer('')
await loadProgress(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible de valider la réponse.')
}
}
function stopSpeechPlayback() {
if (audioPlaybackRef.current) {
audioPlaybackRef.current.pause()
audioPlaybackRef.current.src = ''
audioPlaybackRef.current = null
}
if (audioPlaybackUrlRef.current) {
URL.revokeObjectURL(audioPlaybackUrlRef.current)
audioPlaybackUrlRef.current = ''
}
setSpeaking(false)
}
async function saveLessonSession() {
if (!selectedStudentId || !studentProgram) return
const currentCardState = getActiveCardState(40, false)
const sessionMessages = messages.slice(sessionMessageStartIndex)
const conversation = sessionMessages.map((message) => ({
id: message.id,
role: message.role,
content: message.content,
created_at: message.created_at || null,
}))
const lastConversationItem = conversation[conversation.length - 1]
if (currentInstruction && lastConversationItem?.content !== currentInstruction) {
conversation.push({
id: 'current-instruction',
role: 'assistant',
content: currentInstruction,
created_at: null,
})
}
await apiFetch('/session/end', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
student_id: Number(selectedStudentId),
lesson_id: studentProgram.id,
lesson_title: studentProgram.title,
conversation,
card_states: getMergedCardStates(currentCardState),
}),
})
await loadCardProgress(selectedStudentId, studentProgram.id)
await loadStudentProgram(selectedStudentId)
if (!isStudentUser) await loadSessionLogs(selectedStudentId)
}
async function endSession() {
try {
await saveLessonSession()
} catch (error) {
setErrorMessage(error.message || 'Impossible d’enregistrer la séance.')
}
stopSpeechPlayback()
deactivateAutoListening(false)
setSessionActive(false)
setProgressPanelOpen(false)
setSettingsPanelOpen(false)
setCurrentInstruction('')
setAssessment(null)
setAssessmentAnswer('')
setInput('')
setLastStudentTransmission('')
setSessionCardStates({})
}
async function toggleSession() {
if (sessionActive) {
endSession()
return
}
await startSession()
}
async function loadMessages(studentId) {
try {
setErrorMessage('')
const data = await apiFetch(`/students/${studentId}/messages?limit=80`)
setMessages(
(data || []).map((message) => ({
id: message.id,
role: message.role,
content: message.content,
created_at: message.created_at,
}))
)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la conversation.')
}
}
async function loadProgramLessons(grade) {
if (!grade) return
try {
const data = await apiFetch(`/program/lessons?grade=${encodeURIComponent(grade)}`)
setProgramLessons(data.lessons || [])
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger le programme.')
}
}
async function loadProgramStatus() {
try {
const data = await apiFetch('/program/status')
setProgramStatus(data)
} catch {
setProgramStatus(null)
}
}
async function loadProgramTree() {
if (isStudentUser) return
try {
const data = await apiFetch('/admin/program/tree')
setProgramTree(data)
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
setCollapsedProgramKeys(new Set(collectCollapsibleProgramKeys(data)))
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger l’arborescence du programme.')
}
}
async function saveProgramReview(event) {
event.preventDefault()
if (!selectedProgramNode) return
try {
await apiFetch('/admin/program/review', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_key: selectedProgramNode.key,
status: programReviewForm.status,
comment: programReviewForm.comment,
}),
})
await loadProgramTree()
} catch (error) {
setErrorMessage(error.message || 'Impossible d’enregistrer la revue du programme.')
}
}
async function loadStudentProgram(studentId) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setLessonPosition(nextStart.asset_index, nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la leçon de l’élève.')
}
}
async function loadCardProgress(studentId, lessonId = studentProgram?.id) {
if (!studentId) return
try {
const suffix = lessonId ? `?lesson_id=${encodeURIComponent(lessonId)}` : ''
const data = await apiFetch(`/students/${studentId}/card-progress${suffix}`)
setCardProgress(data || [])
} catch {
setCardProgress([])
}
}
async function loadAdaptiveProfile(studentId, lessonId) {
if (!studentId || !lessonId || isStudentUser) return
try {
const data = await apiFetch(`/admin/students/${studentId}/adaptive-profile?lesson_id=${encodeURIComponent(lessonId)}`)
setAdaptiveProfile(data || null)
setAdaptiveProfileForm({
diagnostic_id: data?.diagnostic_id || '',
profile_id: data?.profile_id || '',
notes: data?.notes || '',
})
} catch {
setAdaptiveProfile(null)
setAdaptiveProfileForm({ diagnostic_id: '', profile_id: '', notes: '' })
}
}
async function saveAdaptiveProfile(event) {
event.preventDefault()
if (!selectedStudentId || !studentProgram?.id || !adaptiveProfileForm.profile_id) return
const profile = (studentProgram.adaptive_kit?.profiles || []).find((item) => item.id === adaptiveProfileForm.profile_id)
if (!profile) return
try {
const data = await apiFetch(`/admin/students/${selectedStudentId}/adaptive-profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lesson_id: studentProgram.id,
diagnostic_id: adaptiveProfileForm.diagnostic_id || null,
profile_id: profile.id,
profile_title: profile.title,
notes: adaptiveProfileForm.notes,
source: 'teacher',
}),
})
setAdaptiveProfile(data)
} catch (error) {
setErrorMessage(error.message || 'Impossible d’enregistrer le profil adaptatif.')
}
}
async function loadSessionLogs(studentId) {
if (!studentId || isStudentUser) return
try {
const data = await apiFetch(`/admin/students/${studentId}/session-logs`)
setSessionLogs(data || [])
} catch {
setSessionLogs([])
}
}
async function assignProgramLesson(lessonId) {
if (!selectedStudentId || !lessonId) return
try {
setErrorMessage('')
const data = await apiFetch(`/admin/students/${selectedStudentId}/program`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_id: lessonId }),
})
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setLessonPosition(nextStart.asset_index, nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible d’affecter cette leçon.')
}
}
async function updateProgramStart(assetIndex, sectionIndex) {
if (!selectedStudentId || !studentProgram) return
try {
const data = await apiFetch(`/admin/students/${selectedStudentId}/program/start`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_index: Number(assetIndex),
section_index: Number(sectionIndex),
}),
})
setStudentProgram(data.lesson || null)
const nextStart = {
asset_index: data.start_asset_index || 0,
section_index: data.start_section_index || 0,
}
setProgramStart(nextStart)
setLessonPosition(nextStart.asset_index, nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible de programmer la reprise.')
}
}
async function deleteSessionLog(logId) {
try {
await apiFetch(`/admin/session-logs/${logId}`, { method: 'DELETE' })
setSelectedSessionLog((current) => (current?.id === logId ? null : current))
await loadSessionLogs(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible d’effacer ce log.')
}
}
function parseLogJson(value) {
try {
const parsed = JSON.parse(value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function getLessonPosition() {
const assetIndex = activeAssetIndexRef.current
const sectionIndex = activeSectionIndexRef.current
const asset = lessonAssets[assetIndex] || lessonAssets[0] || null
const sections = asset?.sections || []
const section = sections[sectionIndex] || null
return { assetIndex, sectionIndex, asset, sections, section }
}
function goToPreviousLessonStep() {
const position = getLessonPosition()
if (position.sectionIndex > 0) {
setLessonPosition(position.assetIndex, position.sectionIndex - 1)
return
}
if (position.assetIndex > 0) {
const previousAsset = lessonAssets[position.assetIndex - 1]
setLessonPosition(position.assetIndex - 1, Math.max((previousAsset?.sections?.length || 1) - 1, 0))
}
}
function goToNextLessonStep() {
const nextStep = getNextLessonStep()
if (!nextStep) return
setLessonPosition(nextStep.assetIndex, nextStep.sectionIndex)
}
function getNextLessonStep() {
const position = getLessonPosition()
const sectionCount = position.sections.length || 1
if (position.sectionIndex < sectionCount - 1) {
return {
assetIndex: position.assetIndex,
sectionIndex: position.sectionIndex + 1,
asset: position.asset,
section: position.sections[position.sectionIndex + 1],
}
}
if (position.assetIndex < lessonAssets.length - 1) {
const nextAsset = lessonAssets[position.assetIndex + 1]
return {
assetIndex: position.assetIndex + 1,
sectionIndex: 0,
asset: nextAsset,
section: nextAsset?.sections?.[0] || null,
}
}
return null
}
function buildNextCardInstruction(nextStep) {
const title = nextStep?.section?.title || nextStep?.asset?.title || 'la suite'
const key = nextStep?.section?.context_key || ''
const prompts = {
Fiche1Card2: 'Très bien, on passe à Exemple. Lis le nombre 345 208.',
Fiche1Card3: 'Très bien, on passe à À retenir. Dis-moi ce qu’on lit en premier.',
Fiche1Card4: 'Très bien, on passe à Exemple 1. Lis le nombre 7 425.',
Fiche1Card5: 'Très bien, on passe à Exemple 2. Lis le nombre 38 090.',
Fiche1Card6: 'Très bien, on passe à Exemple 3. Lis le nombre 506 012.',
}
if (prompts[key]) return prompts[key]
if (title.toLowerCase().startsWith('exemple')) {
return `Très bien, on passe à ${title}. Lis seulement le nombre affiché.`
}
return `Très bien, on passe à ${title}. Regarde cette card.`
}
function normalizeAnswer(text) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[’']/g, ' ')
}
function answerValidatesCurrentCard(text) {
const answer = normalizeAnswer(text)
const { section } = getLessonPosition()
const key = section?.context_key || ''
if (/^(ok|oui|d accord|daccord|j ai compris|compris)[.!?\s]*$/.test(answer.trim())) return true
if (/\b(oui|ok|d accord)\b/.test(answer) && /compr/.test(answer)) return true
if (key === 'Fiche1Card1') {
return /compr/.test(answer) || (answer.includes('345') && answer.includes('208'))
}
if (key === 'Fiche1Card2') {
const digitRead = answer.replace(/\D/g, '') === '345208'
const wordRead = answer.includes('trois') && answer.includes('quarante') && answer.includes('mille') && answer.includes('huit')
return digitRead || wordRead
}
if (key === 'Fiche1Card3') {
return answer.includes('milliers') && (answer.includes('unites') || answer.includes('simples'))
}
if (key === 'Fiche1Card4') {
return answer.includes('sept') && answer.includes('mille') && answer.includes('vingt') && answer.includes('cinq')
}
if (key === 'Fiche1Card5') {
return answer.includes('trente') && answer.includes('huit') && answer.includes('mille') && answer.includes('quatre') && answer.includes('dix')
}
if (key === 'Fiche1Card6') {
return answer.includes('cinq') && answer.includes('six') && answer.includes('mille') && answer.includes('douze')
}
return false
}
function getSectionKey(asset, section) {
return section?.context_key || `${asset?.path || 'asset'}:${section?.index ?? 0}`
}
function getActiveCardState(score = 0, validated = false) {
const { asset, section } = getLessonPosition()
if (!studentProgram || !asset) return null
const sectionTitle = section?.title || asset.title
const sectionKey = getSectionKey(asset, section)
return {
lesson_id: studentProgram.id,
asset_path: asset.path,
asset_title: asset.title,
section_key: sectionKey,
section_title: sectionTitle,
comprehension_score: score,
attempts: 1,
validated,
}
}
function rememberActiveCard({ score = 100, validated = true } = {}) {
const state = getActiveCardState(score, validated)
if (!state) return
setSessionCardStates((current) => {
const previous = current[state.section_key] || {}
return {
...current,
[state.section_key]: {
...state,
comprehension_score: Math.max(previous.comprehension_score || 0, state.comprehension_score),
attempts: Math.max(previous.attempts || 0, state.attempts),
validated: Boolean(previous.validated || state.validated),
},
}
})
}
function getMergedCardStates(extraState = null) {
const fromProgress = cardProgress
.filter((item) => item.lesson_id === studentProgram?.id)
.map((item) => ({
lesson_id: item.lesson_id,
asset_path: item.asset_path,
asset_title: item.asset_title,
section_key: item.section_key,
section_title: item.section_title,
comprehension_score: item.comprehension_score,
attempts: item.attempts,
validated: Boolean(item.validated_at),
}))
const merged = {}
for (const item of fromProgress) merged[item.section_key] = item
for (const item of Object.values(sessionCardStates)) merged[item.section_key] = item
if (extraState) merged[extraState.section_key] = extraState
return Object.values(merged)
}
function getLessonContextPayload() {
const { asset, section } = getLessonPosition()
return {
lesson_id: studentProgram?.id || null,
lesson_title: activeLessonTitle,
asset_title: asset?.title || null,
section_title: section?.title || asset?.title || null,
section_context_key: section?.context_key || null,
section_context: section?.context || null,
step_percent: lessonStepPercent,
}
}
function compactInstruction(text) {
const cleanText = text.trim()
if (!cleanText) return ''
const sentences = cleanText.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [cleanText]
return sentences.slice(0, 3).join(' ').trim()
}
async function finishSpeechPlayback(debugMessage) {
setSpeaking(false)
if (audioPlaybackUrlRef.current) {
URL.revokeObjectURL(audioPlaybackUrlRef.current)
audioPlaybackUrlRef.current = ''
}
audioPlaybackRef.current = null
if (debugMessage) {
pushAudioDebug(debugMessage)
}
if (isAutoListeningRef.current && !isRecordingRef.current && !isTranscribingRef.current) {
await startSegmentRecording()
}
}
async function speak(text) {
const cleanText = text.trim()
if (!cleanText) return
if (!selectedVoiceProfileId) {
setVoiceStatus('Aucune voix Professeur TOP disponible.')
return
}
stopSpeechPlayback()
setVoiceStatus(`Professeur TOP parle en mode ${selectedVoiceProfile?.label || selectedVoiceProfileId}...`)
try {
const audioBlob = await fetchAudio('/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: cleanText,
profile_id: selectedVoiceProfileId,
}),
})
const objectUrl = URL.createObjectURL(audioBlob)
const audio = new Audio(objectUrl)
audioPlaybackRef.current = audio
audioPlaybackUrlRef.current = objectUrl
audio.onplay = () => setSpeaking(true)
audio.onended = () => {
setVoiceStatus('')
void finishSpeechPlayback('Fin de voix prof, réarmement du segment micro')
}
audio.onerror = () => {
setVoiceStatus('Lecture audio impossible.')
void finishSpeechPlayback('Erreur voix prof, réarmement du segment micro')
}
await audio.play()
} catch (error) {
setVoiceStatus(error.message || 'Impossible de générer la voix du professeur.')
await finishSpeechPlayback('Erreur synthèse vocale, réarmement du segment micro')
}
}
function stopMediaStream() {
pushAudioDebug('Arrêt et nettoyage du flux micro')
if (sourceNodeRef.current) {
sourceNodeRef.current.disconnect()
sourceNodeRef.current = null
}
if (processorNodeRef.current) {
processorNodeRef.current.disconnect()
processorNodeRef.current.onaudioprocess = null
processorNodeRef.current = null
}
if (monitorGainRef.current) {
monitorGainRef.current.disconnect()
monitorGainRef.current = null
}
if (audioContextRef.current) {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
analyserRef.current = null
silenceStartedAtRef.current = null
hasSpeechInSegmentRef.current = false
levelFrameCountRef.current = 0
setMicLevel(0)
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
mediaStreamRef.current = null
}
mediaRecorderRef.current = null
recordedChunksRef.current = []
recordingMimeTypeRef.current = ''
}
async function submitUserMessage(text) {
if (!selectedStudentId) {
throw new Error('Choisis un élève avant d’envoyer un message.')
}
appendMessage('user', text)
setLastStudentTransmission(text)
setInput('')
setErrorMessage('')
if (answerValidatesCurrentCard(text)) {
rememberActiveCard({ score: 100, validated: true })
const nextStep = getNextLessonStep()
const transitionReply = nextStep
? buildNextCardInstruction(nextStep)
: 'Très bien, cette fiche est terminée.'
if (nextStep) {
setLessonPosition(nextStep.assetIndex, nextStep.sectionIndex)
}
appendMessage('assistant', transitionReply)
setCurrentInstruction(transitionReply)
speak(transitionReply)
return
}
const data = await apiFetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
student_id: Number(selectedStudentId),
message: text,
...getLessonContextPayload(),
}),
})
const shouldAdvance = data.advance_lesson_step
if (shouldAdvance) {
rememberActiveCard({ score: 100, validated: true })
const nextStep = getNextLessonStep()
const transitionReply = nextStep
? buildNextCardInstruction(nextStep)
: 'Très bien, cette fiche est terminée.'
if (nextStep) {
setLessonPosition(nextStep.assetIndex, nextStep.sectionIndex)
}
appendMessage('assistant', transitionReply)
setCurrentInstruction(transitionReply)
speak(transitionReply)
} else {
rememberActiveCard({ score: 40, validated: false })
appendMessage('assistant', data.reply)
setCurrentInstruction(data.reply)
speak(data.reply)
}
}
async function transcribeRecording(audioBlob, mimeType, { autoSend = false } = {}) {
const extension = mimeType.includes('mp4') ? 'mp4' : mimeType.includes('ogg') ? 'ogg' : 'webm'
const formData = new FormData()
formData.append('file', new File([audioBlob], `voice-input.${extension}`, { type: mimeType || 'audio/webm' }))
pushAudioDebug(`Transcription demandée, taille=${audioBlob.size}, mime=${mimeType || 'inconnu'}`)
setIsTranscribing(true)
setVoiceStatus('Transcription en cours...')
try {
const data = await apiFetch('/transcribe', {
method: 'POST',
body: formData,
})
const transcript = (data.text || '').trim()
setInput(transcript)
pushAudioDebug(`Transcription reçue, longueur=${transcript.length}`)
if (!transcript) {
setVoiceStatus('Aucun texte reconnu.')
return
}
setLastStudentTransmission(transcript)
if (autoSend && selectedStudentId) {
setVoiceStatus('Texte reconnu, envoi automatique...')
pushAudioDebug('Envoi automatique du message transcrit')
await submitUserMessage(transcript)
setVoiceStatus('Message vocal envoyé automatiquement.')
} else {
setVoiceStatus('Texte dicté prêt à être envoyé.')
}
} catch (error) {
pushAudioDebug(`Erreur transcription: ${error.message || 'inconnue'}`)
setVoiceStatus(error.message || 'Impossible de transcrire cet enregistrement.')
} finally {
setIsTranscribing(false)
}
}
function calculateVolume(floatData) {
let sumSquares = 0
for (const value of floatData) {
sumSquares += value * value
}
return Math.sqrt(sumSquares / floatData.length)
}
async function startSegmentRecording() {
if (!mediaStreamRef.current || isRecordingRef.current || isTranscribingRef.current) return
const mimeType = getSupportedRecordingMimeType()
const recorder = mimeType
? new MediaRecorder(mediaStreamRef.current, { mimeType })
: new MediaRecorder(mediaStreamRef.current)
mediaRecorderRef.current = recorder
recordedChunksRef.current = []
recordingMimeTypeRef.current = mimeType || recorder.mimeType || 'audio/webm'
silenceStartedAtRef.current = null
hasSpeechInSegmentRef.current = false
pushAudioDebug(`Démarrage enregistrement segment, mime=${recordingMimeTypeRef.current}`)
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunksRef.current.push(event.data)
pushAudioDebug(`Chunk audio reçu, taille=${event.data.size}`)
}
}
recorder.onstop = async () => {
const finalMimeType = recordingMimeTypeRef.current || recorder.mimeType || 'audio/webm'
const audioBlob = new Blob(recordedChunksRef.current, { type: finalMimeType })
recordedChunksRef.current = []
mediaRecorderRef.current = null
setIsRecording(false)
pushAudioDebug(`Enregistrement arrêté, taille totale=${audioBlob.size}, parole détectée=${hasSpeechInSegmentRef.current ? 'oui' : 'non'}`)
if (audioBlob.size > 0 && hasSpeechInSegmentRef.current) {
await transcribeRecording(audioBlob, finalMimeType, { autoSend: true })
} else if (isAutoListeningRef.current) {
setVoiceStatus('Micro actif. Parle quand tu veux.')
}
hasSpeechInSegmentRef.current = false
if (isAutoListeningRef.current && !isTranscribingRef.current && !speakingRef.current) {
pushAudioDebug('Segment terminé, relance écoute micro')
await startSegmentRecording()
}
}
recorder.onerror = () => {
pushAudioDebug('Erreur MediaRecorder')
setVoiceStatus('Le navigateur a rencontré une erreur pendant l’enregistrement.')
setIsRecording(false)
}
recorder.start()
setIsRecording(true)
setVoiceStatus('Je t’écoute...')
}
function stopSegmentRecording(statusMessage = 'Silence détecté, transcription...') {
const recorder = mediaRecorderRef.current
if (recorder && recorder.state !== 'inactive') {
silenceStartedAtRef.current = null
pushAudioDebug('Arrêt du segment demandé après silence')
recorder.stop()
setVoiceStatus(statusMessage)
}
}
function handleMicrophoneLevel(volume) {
if (!isAutoListeningRef.current) return
const now = Date.now()
setMicLevel(volume)
levelFrameCountRef.current += 1
if (levelFrameCountRef.current === 1) {
pushAudioDebug(`Premier niveau audio reçu=${volume.toFixed(4)}`)
} else if (levelFrameCountRef.current % 50 === 0) {
pushAudioDebug(`Niveau audio=${volume.toFixed(4)}`)
}
if (volume >= SPEECH_START_THRESHOLD) {
if (!hasSpeechInSegmentRef.current) {
pushAudioDebug(`Parole détectée, niveau=${volume.toFixed(4)}`)
}
hasSpeechInSegmentRef.current = true
silenceStartedAtRef.current = null
} else if (isRecordingRef.current && hasSpeechInSegmentRef.current && volume <= SILENCE_THRESHOLD) {
if (!silenceStartedAtRef.current) {
pushAudioDebug(`Début silence, niveau=${volume.toFixed(4)}`)
silenceStartedAtRef.current = now
} else if (now - silenceStartedAtRef.current >= AUTO_STOP_SILENCE_MS) {
pushAudioDebug('Silence confirmé, arrêt du segment')
stopSegmentRecording()
}
} else if (volume > SILENCE_THRESHOLD) {
silenceStartedAtRef.current = null
}
}
async function activateAutoListening() {
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
setVoiceStatus('Le micro automatique n’est pas pris en charge par ce navigateur.')
pushAudioDebug('Navigateur incompatible micro auto')
return
}
try {
pushAudioDebug('Demande accès micro')
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
const AudioContextClass = window.AudioContext || window.webkitAudioContext
const audioContext = new AudioContextClass()
if (audioContext.state === 'suspended') {
await audioContext.resume()
}
pushAudioDebug(`Micro autorisé, AudioContext=${audioContext.state}`)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 2048
analyser.smoothingTimeConstant = 0.85
const sourceNode = audioContext.createMediaStreamSource(stream)
const processorNode = audioContext.createScriptProcessor(2048, 1, 1)
const monitorGain = audioContext.createGain()
monitorGain.gain.value = 0
processorNode.onaudioprocess = (event) => {
const channelData = event.inputBuffer.getChannelData(0)
const volume = calculateVolume(channelData)
handleMicrophoneLevel(volume)
}
sourceNode.connect(analyser)
sourceNode.connect(processorNode)
processorNode.connect(monitorGain)
analyser.connect(monitorGain)
monitorGain.connect(audioContext.destination)
audioContextRef.current = audioContext
analyserRef.current = analyser
sourceNodeRef.current = sourceNode
processorNodeRef.current = processorNode
monitorGainRef.current = monitorGain
mediaStreamRef.current = stream
setIsAutoListening(true)
setVoiceStatus('Micro actif. Parle quand tu veux, j’enverrai après 2,5 s de silence.')
pushAudioDebug(`Piste micro active=${stream.getAudioTracks()[0]?.readyState || 'inconnue'}`)
await startSegmentRecording()
} catch {
pushAudioDebug('Accès micro refusé ou indisponible')
setVoiceStatus('Accès au micro refusé ou indisponible.')
stopMediaStream()
}
}
function deactivateAutoListening(resetStatus = true) {
pushAudioDebug('Désactivation micro auto')
setIsAutoListening(false)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.onstop = null
mediaRecorderRef.current.stop()
}
setIsRecording(false)
stopMediaStream()
if (resetStatus) {
setVoiceStatus('Micro automatique désactivé.')
}
}
async function startVoiceInput() {
if (isAutoListening) {
deactivateAutoListening()
return
}
await activateAutoListening()
}
async function sendMessage(e) {
e.preventDefault()
if (!selectedStudentId || !input.trim()) return
const text = input.trim()
try {
await submitUserMessage(text)
} catch (error) {
setInput(text)
setErrorMessage(error.message || 'Impossible d’envoyer le message.')
}
}
const lastAssistantMessage = [...messages].reverse().find((message) => message.role === 'assistant')
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user')
const activeLessonTitle = studentProgram?.title || lessonFocus?.label || 'Cours du jour'
const lessonAssets = studentProgram?.assets || []
const adaptiveKit = studentProgram?.adaptive_kit || null
const activeAsset = lessonAssets[activeAssetIndex] || lessonAssets[0] || null
const activeSections = activeAsset?.sections || []
const activeSection = activeSections[activeSectionIndex] || null
const totalLessonSteps = lessonAssets.reduce(
(total, asset) => total + Math.max(asset.sections?.length || 0, 1),
0
)
const completedBeforeAsset = lessonAssets
.slice(0, activeAssetIndex)
.reduce((total, asset) => total + Math.max(asset.sections?.length || 0, 1), 0)
const activeLessonStep = totalLessonSteps
? completedBeforeAsset + (activeSection ? activeSectionIndex + 1 : 1)
: 0
const lessonStepPercent = totalLessonSteps
? Math.min(100, Math.round((activeLessonStep / totalLessonSteps) * 100))
: 0
const mergedCardStates = getMergedCardStates()
const validatedLessonCards = mergedCardStates.filter((item) => item.validated).length
const lessonComprehensionPercent = totalLessonSteps
? Math.min(100, Math.round((validatedLessonCards / totalLessonSteps) * 100))
: lessonPercent
const displayedLessonPercent = studentProgram ? lessonComprehensionPercent : lessonPercent
const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne séance trouvée pour le moment. Professeur TOP commencera par une reprise courte.'
const lastStudentWork = lastUserMessage?.content || 'Aucune réponse récente enregistrée.'
const nextLessonAdvice = studentProgram
? adaptiveKit
? `Conseil: si l’élève hésite, lancer le diagnostic adaptatif ${adaptiveKit.title.toLowerCase()} avant de poursuivre.`
: `Conseil: démarrer la leçon ${studentProgram.title} avec les fiches choisies par le professeur.`
: lessonFocus
? `Conseil: reprendre ${lessonFocus.label}, puis lancer un exercice court pour équilibrer la progression.`
: 'Conseil: démarrer une séance pour laisser Professeur TOP choisir la prochaine leçon.'
if (isStudentUser) {
return (
{!sessionActive ? (
Bonjour
{selectedStudent?.first_name || currentUser.username}
{selectedStudent?.grade || 'Classe non renseignée'}
Dernière séance
{lessonSummary}
Dernière réponse
{lastStudentWork}
Avancement
{displayedLessonPercent}%
{activeLessonTitle}
Prochaine leçon conseillée
{nextLessonAdvice}
{voiceStatus && {voiceStatus}
}
{errorMessage && {errorMessage}
}
) : (
Professeur TOP
{activeAsset && (
{lessonStepPercent}% · Fiche {activeAssetIndex + 1}/{lessonAssets.length}
)}
{currentInstruction ? (
{compactInstruction(currentInstruction)}
) : (
Professeur TOP prépare la suite de la leçon.
)}
{activeAsset && (
)}
{(voiceStatus || errorMessage || isAutoListening) && (
{errorMessage || voiceStatus || `Niveau micro: ${Math.round(Math.min(micLevel * 1200, 100))}%`}
)}
{progressPanelOpen && (
setProgressPanelOpen(false)}>
)}
{settingsPanelOpen && (
setSettingsPanelOpen(false)}>
)}
)}
)
}
if (!isStudentUser) {
const validatedCards = cardProgress.filter((item) => item.validated).length
const averageComprehension = cardProgress.length
? Math.round(cardProgress.reduce((sum, item) => sum + item.comprehension_score, 0) / cardProgress.length)
: 0
const reviewRows = programRows.filter(({ node }) =>
['lesson', 'fiche', 'card', 'kit', 'diagnostic', 'parcours', 'support'].includes(node.type)
)
const reviewQueue = reviewRows.filter(({ node }) => ['needs_changes', 'blocked', 'draft', undefined].includes(node.review?.status))
const selectedNodeStatus = selectedProgramNode?.status || {}
const totalLessonCards = studentProgram?.assets?.reduce((sum, asset) => sum + (asset.sections?.length || 0), 0) || 0
const remainingCards = Math.max(totalLessonCards - validatedCards, 0)
const adminTabs = [
{ id: 'overview', label: 'Vue d’ensemble' },
{ id: 'students', label: 'Élèves' },
{ id: 'program', label: 'Programme global' },
{ id: 'validation', label: 'Validation contenus' },
{ id: 'factory', label: 'Atelier génération' },
{ id: 'settings', label: 'Paramètres globaux' },
]
return (
{activeAdminTab === 'overview' && (
<>
Élèves actifs
{students.length}
{selectedStudent ? `${selectedStudent.first_name} sélectionné` : 'Aucun élève sélectionné'}
Leçon active
{studentProgram?.title || 'À affecter'}
{studentProgram ? `${studentProgram.asset_count} fiche(s) · ${totalLessonCards} cards` : 'Passe par l’onglet Élèves'}
Compréhension
{averageComprehension}%
{validatedCards} card(s) validée(s), {remainingCards} restante(s)
File de revue
{reviewQueue.length}
Contenus à relire, modifier ou intégrer
Priorités
À traiter maintenant
Production
État du programme
Leçons: {programStatus?.lesson_count ?? 0}
CM1: {programStatus?.lessons_by_grade?.CM1 ?? 0}
Dossier: {programStatus?.content_root_exists === false ? 'absent' : 'OK'}
>
)}
{activeAdminTab === 'students' && (
{selectedStudent ? (
<>
Fiche élève
{selectedStudent.first_name}
{selectedStudent.grade} · {selectedStudent.age} ans
Leçon courante
{studentProgram?.title || 'Aucune leçon'}
{studentProgram ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)` : 'Affecte une leçon ci-dessous.'}
Résultats
{averageComprehension}%
{cardProgress.length} card(s) observée(s)
Contenu fait
{validatedCards}
{remainingCards} card(s) encore à travailler
Kit adaptatif
{adaptiveProfile?.profile_title || (adaptiveKit ? 'Disponible' : 'Absent')}
{adaptiveProfile
? 'Profil de reprise enregistré'
: adaptiveKit
? `${adaptiveKit.diagnostics?.length || 0} test(s), ${adaptiveKit.profiles?.length || 0} parcours`
: 'Aucun diagnostic relié à cette leçon.'}
{studentProgram?.assets?.length > 0 && (
<>
>
)}
{adaptiveKit && (
Adaptatif
Diagnostic et parcours disponibles
Tests diagnostics
{(adaptiveKit.diagnostics || []).map((item) => (
{item.title}
))}
Parcours profils
{(adaptiveKit.profiles || []).map((item) => (
{item.title}
))}
Supports visuels
{(adaptiveKit.supports || []).map((item) => (
{item.title}
))}
)}
Compréhension des cards
{cardProgress.length ? (
{cardProgress.map((item) => (
{item.section_title}
{Math.round(item.comprehension_score)}% · essais: {item.attempts}
))}
) : Aucune card validée pour le moment.
}
Logs de séance
{sessionLogs.length ? (
{sessionLogs.map((log) => (
{log.lesson_title}
{new Date(log.ended_at).toLocaleString('fr-FR')}
))}
) : Aucun log enregistré.
}
{selectedSessionLog && (
Détail de séance
Résultats des cards
{parseLogJson(selectedSessionLog.card_results).map((item) => (
{item.section_title}
{Math.round(item.comprehension_score)}% · {item.validated ? 'validée' : 'vue/tentée'}
))}
Conversation
{parseLogJson(selectedSessionLog.conversation).map((message, index) => (
{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}: {message.content}
))}
)}
>
) : (
Aucun élève sélectionné
Choisis un élève ou crée un compte pour configurer son programme.
)}
)}
{activeAdminTab === 'program' && (
Arborescence du programme
{visibleProgramRows.map(({ node, depth }) => (
))}
setActiveAdminTab('validation')}
/>
)}
{activeAdminTab === 'validation' && (
{selectedProgramNode?.type || 'Sélection'}
{selectedProgramNode?.label || 'Choisis une card ou une fiche'}
{selectedProgramNode?.path || selectedProgramNode?.key || 'La zone centrale affiche le contenu à analyser en grand.'}
{selectedProgramNode?.review?.status &&
{selectedProgramNode.review.status}}
{selectedProgramNode?.url ? (

) : selectedProgramNode?.content ? (
{selectedProgramNode.content}
) : (
Aucun visuel direct pour cet élément.
)}
)}
{activeAdminTab === 'factory' && (
Atelier
Planifier une génération
Pipeline
Assimilation des contenus
1. Génération nocturne
2. File de validation
3. Remarque ou validation
4. Assimilation au programme
5. Disponibilité dans le mode élève
)}
{activeAdminTab === 'settings' && (
Application
Paramètres globaux
Système
Diagnostic
API: active
Contenus: {programStatus?.content_root_exists === false ? 'à vérifier' : 'OK'}
Leçons: {programStatus?.lesson_count ?? 0}
)}
{errorMessage && {errorMessage}
}
)
}
return (
Arborescence du programme
{programRows.map(({ node, depth }) => (
))}
{selectedStudent ? (
Élève sélectionné
{selectedStudent.first_name}
{selectedStudent.grade} · {selectedStudent.age} ans
Leçon active
{studentProgram?.title || 'Aucune leçon'}
{studentProgram
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
: 'Choisis une leçon dans la colonne de gauche.'}
{studentProgram?.assets?.length > 0 && (
)}
) : (
Aucun élève sélectionné
Choisis un élève ou crée un compte pour configurer son programme.
)}
{studentProgram?.assets?.length > 0 && (
Fiches de la leçon
{studentProgram.assets.map((asset) => (
{asset.title}
))}
)}
{selectedStudent && (
Compréhension des cards
{cardProgress.length ? (
{cardProgress.map((item) => (
{item.section_title}
{Math.round(item.comprehension_score)}% · essais: {item.attempts}
))}
) : (
Aucune card validée pour le moment.
)}
)}
{selectedStudent && (
Logs de séance
{sessionLogs.length ? (
{sessionLogs.map((log) => (
{log.lesson_title}
{new Date(log.ended_at).toLocaleString('fr-FR')}
))}
) : (
Aucun log enregistré.
)}
)}
{selectedSessionLog && (
Détail de séance
Résultats des cards
{parseLogJson(selectedSessionLog.card_results).map((item) => (
{item.section_title}
{Math.round(item.comprehension_score)}% · {item.validated ? 'validée' : 'vue/tentée'}
))}
Conversation
{parseLogJson(selectedSessionLog.conversation).map((message, index) => (
{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}: {message.content}
))}
)}
{errorMessage && {errorMessage}
}
)
}
export default function App() {
const [currentUser, setCurrentUser] = useState(null)
const [isCheckingSession, setIsCheckingSession] = useState(true)
useEffect(() => {
let isMounted = true
async function loadSession() {
try {
const user = await apiFetch('/auth/me')
if (isMounted) setCurrentUser(user)
} catch {
if (isMounted) setCurrentUser(null)
} finally {
if (isMounted) setIsCheckingSession(false)
}
}
loadSession()
return () => {
isMounted = false
}
}, [])
async function logout() {
try {
await apiFetch('/auth/logout', { method: 'POST' })
} finally {
setCurrentUser(null)
}
}
if (isCheckingSession) {
return (
Verification de la session...
)
}
if (!currentUser) {
return
}
return
}