Files
open-school/frontend/src/App.jsx
2026-05-06 19:55:37 +02:00

2723 lines
107 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="avatar-shell">
<div className={`avatar ${speaking ? 'speaking' : ''}`}>
<img
className="avatar-image"
src={professeurTopImage}
alt="Professeur TOP"
/>
</div>
<div className="avatar-caption">Professeur TOP</div>
</div>
)
}
function ProgressCard({ item }) {
const pct = Math.round(item.mastery_score)
return (
<div className="progress-card">
<div className="progress-head">
<strong>{item.label}</strong>
<span>{pct}%</span>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<small>{item.subject} · preuves: {item.evidence_count}</small>
</div>
)
}
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 (
<section className="admin-empty">
<h2>Programme</h2>
<p className="muted">Choisis un élément de larborescence pour le relire, le valider ou laisser un commentaire.</p>
</section>
)
}
const status = selectedNode.status || {}
const review = selectedNode.review || {}
return (
<section className="admin-program-review">
<div className="admin-log-detail-head">
<div>
<span className="stage-label">{selectedNode.type}</span>
<h2>{selectedNode.label}</h2>
<p className="muted">{selectedNode.path || selectedNode.key}</p>
</div>
{review.status && <span className={`review-pill ${review.status}`}>{review.status}</span>}
</div>
{Object.keys(status).length > 0 && (
<div className="program-status-grid">
<span>Micro-fiches: {status.micro_fiches ?? 0}</span>
<span>SVG leçon: {status.lesson_svg ?? 0}</span>
<span>Cards: {status.cards ?? 0}</span>
<span>Contextes: {status.contexts ?? 0}</span>
<span>Exercices: {status.text_exercises ?? 0}</span>
<span>SVG exercices: {status.exercise_svg ?? 0}</span>
<span>Tests adaptatifs: {status.adaptive_tests ?? 0}</span>
<span>Parcours: {status.adaptive_profiles ?? 0}</span>
<span>SVG kit: {status.adaptive_svg ?? 0}</span>
</div>
)}
{selectedNode.url && (
<button type="button" className="program-node-preview" onClick={() => setIsFullscreen(true)}>
<img src={selectedNode.url} alt={selectedNode.label} />
<span>{readOnly ? 'Cliquer pour inspecter en plein écran' : 'Cliquer pour valider en plein écran'}</span>
</button>
)}
{selectedNode.content && !selectedNode.url && (
<div className="program-node-text-preview">
<pre>{selectedNode.content}</pre>
</div>
)}
{review.comment && (
<div className="admin-review-note">
<strong>Dernier commentaire</strong>
<p>{review.comment}</p>
<span>{review.reviewer} · {review.updated_at}</span>
</div>
)}
{readOnly ? (
<div className="admin-review-form">
<strong>Validation centralisée</strong>
<p className="muted">Cet onglet sert à explorer le programme. Les décisions éditoriales se font dans longlet Validation contenus.</p>
<button type="button" onClick={onOpenValidation}>Ouvrir dans Validation contenus</button>
</div>
) : (
<form className="admin-review-form" onSubmit={onSaveReview}>
<label>
<span>Statut de validation</span>
<select
value={reviewForm.status}
onChange={(event) => onReviewChange({ ...reviewForm, status: event.target.value })}
>
<option value="validated">Validé</option>
<option value="needs_changes">À modifier</option>
<option value="blocked">Bloqué</option>
<option value="draft">Brouillon</option>
</select>
</label>
<label>
<span>Commentaire visible par Codex</span>
<textarea
value={reviewForm.comment}
onChange={(event) => onReviewChange({ ...reviewForm, comment: event.target.value })}
placeholder="Note ce qui dépasse, ce qui est incorrect, ou ce qui doit être régénéré."
rows={5}
/>
</label>
<button type="submit">Enregistrer la revue</button>
</form>
)}
{isFullscreen && (
<div className="review-fullscreen" role="dialog" aria-modal="true" aria-label={`Revue plein écran ${selectedNode.label}`}>
<div className="review-fullscreen-stage">
<div className="review-fullscreen-head">
<div>
<span className="stage-label">{selectedNode.type}</span>
<h2>{selectedNode.label}</h2>
<p className="muted">{selectedNode.path || selectedNode.key}</p>
</div>
<button type="button" className="secondary-button" onClick={() => setIsFullscreen(false)}>
Fermer
</button>
</div>
<div className="review-fullscreen-image">
<img src={selectedNode.url} alt={selectedNode.label} />
</div>
</div>
<aside className="review-fullscreen-panel">
<h2>{readOnly ? 'Inspection' : 'Validation'}</h2>
{readOnly ? (
<div className="admin-review-form">
<p className="muted">Pour modifier le statut ou laisser une remarque, ouvre cet élément dans la file de validation.</p>
<button type="button" onClick={() => { setIsFullscreen(false); onOpenValidation?.() }}>
Ouvrir dans Validation contenus
</button>
</div>
) : (
<form className="admin-review-form" onSubmit={onSaveReview}>
<label>
<span>Statut</span>
<select
value={reviewForm.status}
onChange={(event) => onReviewChange({ ...reviewForm, status: event.target.value })}
>
<option value="validated">Validé</option>
<option value="needs_changes">À modifier</option>
<option value="blocked">Bloqué</option>
<option value="draft">Brouillon</option>
</select>
</label>
<label>
<span>Commentaire</span>
<textarea
value={reviewForm.comment}
onChange={(event) => onReviewChange({ ...reviewForm, comment: event.target.value })}
placeholder="Note précisément ce qui doit être corrigé."
rows={8}
/>
</label>
<button type="submit">Enregistrer la revue</button>
</form>
)}
</aside>
</div>
)}
</section>
)
}
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 (
<main className="login-page">
<section className="login-panel card">
<Avatar speaking={false} />
<div className="login-copy">
<h1>Professeur TOP</h1>
<p className="muted">Connexion élève, professeur ou maintenance.</p>
</div>
<form onSubmit={submitLogin} className="stack login-form">
<input
autoComplete="username"
placeholder="Identifiant"
value={credentials.username}
onChange={(event) =>
setCredentials({ ...credentials, username: event.target.value })
}
/>
<input
autoComplete="current-password"
placeholder="Mot de passe"
type="password"
value={credentials.password}
onChange={(event) =>
setCredentials({ ...credentials, password: event.target.value })
}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Connexion...' : 'Se connecter'}
</button>
</form>
{status && <p className="muted voice-status">{status}</p>}
</section>
</main>
)
}
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 nest 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 denregistrer 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 larborescence 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 denregistrer 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 denregistrer 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 daffecter 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 deffacer 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 quon 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 denvoyer 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 lenregistrement.')
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 nest 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, jenverrai 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 denvoyer 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 (
<div className={`student-screen ${sessionActive ? 'lesson-active' : 'lesson-ready'}`}>
{!sessionActive ? (
<main className="student-home">
<section className="student-home-hero card">
<div>
<p className="eyebrow">Bonjour</p>
<h1>{selectedStudent?.first_name || currentUser.username}</h1>
<p className="muted">{selectedStudent?.grade || 'Classe non renseignée'}</p>
</div>
<div className="student-home-actions">
<button onClick={toggleSession} disabled={!selectedStudentId}>
Démarrer la leçon
</button>
<button type="button" className="secondary-button" onClick={onLogout}>
Déconnexion
</button>
</div>
</section>
<section className="student-home-grid">
<article className="student-info-card card">
<span className="stage-label">Dernière séance</span>
<p>{lessonSummary}</p>
</article>
<article className="student-info-card card">
<span className="stage-label">Dernière réponse</span>
<p>{lastStudentWork}</p>
</article>
<article className="student-info-card card">
<div className="today-card-head">
<span className="stage-label">Avancement</span>
<strong>{displayedLessonPercent}%</strong>
</div>
<h2>{activeLessonTitle}</h2>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${displayedLessonPercent}%` }} />
</div>
</article>
<article className="student-info-card card advice-card">
<span className="stage-label">Prochaine leçon conseillée</span>
<p>{nextLessonAdvice}</p>
</article>
</section>
{voiceStatus && <p className="muted voice-status visible-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
) : (
<main className="student-lesson">
<header className="lesson-topbar">
<Avatar speaking={speaking} />
<div>
<p className="eyebrow">Leçon en cours</p>
<h1>{activeLessonTitle}</h1>
</div>
<div className="lesson-actions">
<button
type="button"
className="icon-button"
aria-label="Paramètres"
title="Paramètres"
onClick={() => setSettingsPanelOpen(true)}
>
...
</button>
<button type="button" className="secondary-button" onClick={() => setProgressPanelOpen(true)}>
Progression
</button>
<button type="button" className="secondary-button" onClick={endSession}>
Fin
</button>
</div>
</header>
<section className="lesson-display">
<div className="lesson-display-head">
<span className="stage-label">Professeur TOP</span>
{activeAsset && (
<span className="asset-counter">
{lessonStepPercent}% · Fiche {activeAssetIndex + 1}/{lessonAssets.length}
</span>
)}
</div>
{currentInstruction ? (
<p className="teacher-transcript">{compactInstruction(currentInstruction)}</p>
) : (
<p className="muted teacher-transcript">Professeur TOP prépare la suite de la leçon.</p>
)}
{activeAsset && (
<div className="lesson-asset-view" aria-label="Fiche de leçon active">
<div className="lesson-asset-toolbar">
<div className="lesson-asset-actions">
<button
type="button"
className="secondary-button"
onClick={goToPreviousLessonStep}
disabled={activeLessonStep <= 1}
>
Précédente
</button>
<button
type="button"
className="secondary-button"
onClick={goToNextLessonStep}
disabled={activeLessonStep >= totalLessonSteps}
>
Suivante
</button>
</div>
</div>
<div className="lesson-asset-open">
<img
src={activeSection?.url || activeAsset.url}
alt={activeSection?.title || activeAsset.title}
/>
</div>
</div>
)}
</section>
<form onSubmit={assessment ? submitAssessment : sendMessage} className="lesson-composer">
<input
value={assessment ? assessmentAnswer : input}
onChange={(event) =>
assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value)
}
placeholder={
assessment
? 'Ta réponse au mini-test...'
: 'Dicte ou écris ta réponse...'
}
autoComplete="off"
/>
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
{isAutoListening ? 'Stop micro' : isTranscribing ? 'Transcription...' : 'Dicter'}
</button>
<button type="submit">Envoyer</button>
</form>
{(voiceStatus || errorMessage || isAutoListening) && (
<p className="lesson-status">
{errorMessage || voiceStatus || `Niveau micro: ${Math.round(Math.min(micLevel * 1200, 100))}%`}
</p>
)}
{progressPanelOpen && (
<div className="progress-panel-backdrop" role="presentation" onClick={() => setProgressPanelOpen(false)}>
<aside className="progress-panel card" role="dialog" aria-label="Progression" onClick={(event) => event.stopPropagation()}>
<div className="progress-panel-head">
<h2>Progression</h2>
<button type="button" className="secondary-button" onClick={() => setProgressPanelOpen(false)}>
Fermer
</button>
</div>
<div className="stack small-gap">
{progress.map((item) => <ProgressCard key={item.code} item={item} />)}
</div>
</aside>
</div>
)}
{settingsPanelOpen && (
<div className="progress-panel-backdrop" role="presentation" onClick={() => setSettingsPanelOpen(false)}>
<aside className="progress-panel card" role="dialog" aria-label="Paramètres" onClick={(event) => event.stopPropagation()}>
<div className="progress-panel-head">
<h2>Paramètres</h2>
<button type="button" className="secondary-button" onClick={() => setSettingsPanelOpen(false)}>
Fermer
</button>
</div>
<label className="settings-field">
<span>Voix de Professeur TOP</span>
<select
value={selectedVoiceProfileId}
onChange={(event) => setSelectedVoiceProfileId(event.target.value)}
>
<option value="">Voix de Professeur TOP</option>
{voiceProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{formatVoiceLabel(profile)}
</option>
))}
</select>
</label>
{selectedVoiceProfile && (
<p className="muted voice-note visible-status">
Voix IA non humaine: {selectedVoiceProfile.label} - {selectedVoiceProfile.description}
</p>
)}
</aside>
</div>
)}
</main>
)}
</div>
)
}
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 densemble' },
{ 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 (
<div className="admin-console">
<header className="admin-console-header">
<div>
<p className="eyebrow">Poste de pilotage</p>
<h1>Professeur TOP</h1>
<p className="muted">Suivi des élèves, validation des contenus et pilotage du programme.</p>
</div>
<div className="admin-user-box">
<span>{currentUser.username} · {currentUser.role}</span>
<button type="button" className="secondary-button" onClick={onLogout}>Déconnexion</button>
</div>
</header>
<nav className="admin-tabs" aria-label="Navigation admin">
{adminTabs.map((tab) => (
<button
key={tab.id}
type="button"
className={activeAdminTab === tab.id ? 'active' : ''}
onClick={() => setActiveAdminTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<main className="admin-workspace">
{activeAdminTab === 'overview' && (
<>
<section className="admin-metrics-grid">
<article className="admin-metric">
<span>Élèves actifs</span>
<strong>{students.length}</strong>
<p>{selectedStudent ? `${selectedStudent.first_name} sélectionné` : 'Aucun élève sélectionné'}</p>
</article>
<article className="admin-metric">
<span>Leçon active</span>
<strong>{studentProgram?.title || 'À affecter'}</strong>
<p>{studentProgram ? `${studentProgram.asset_count} fiche(s) · ${totalLessonCards} cards` : 'Passe par longlet Élèves'}</p>
</article>
<article className="admin-metric">
<span>Compréhension</span>
<strong>{averageComprehension}%</strong>
<p>{validatedCards} card(s) validée(s), {remainingCards} restante(s)</p>
</article>
<article className="admin-metric warning">
<span>File de revue</span>
<strong>{reviewQueue.length}</strong>
<p>Contenus à relire, modifier ou intégrer</p>
</article>
</section>
<section className="admin-dashboard-grid">
<div className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Priorités</span>
<h2>À traiter maintenant</h2>
</div>
</div>
<div className="admin-action-list">
<button type="button" onClick={() => setActiveAdminTab('validation')}>
Relire les SVG dexercices fractions
<span>Phase 6B attendue</span>
</button>
<button type="button" onClick={() => setActiveAdminTab('students')}>
Vérifier le programme de lélève
<span>{selectedStudent?.first_name || 'Choisir un élève'}</span>
</button>
<button type="button" onClick={() => setActiveAdminTab('factory')}>
Préparer les prochaines générations
<span>Kits adaptatifs et intégration</span>
</button>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Production</span>
<h2>État du programme</h2>
</div>
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
</div>
<div className="program-status-grid">
<span>Leçons: {programStatus?.lesson_count ?? 0}</span>
<span>CM1: {programStatus?.lessons_by_grade?.CM1 ?? 0}</span>
<span>Dossier: {programStatus?.content_root_exists === false ? 'absent' : 'OK'}</span>
</div>
</div>
</section>
</>
)}
{activeAdminTab === 'students' && (
<section className="admin-students-layout">
<aside className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Élèves</span>
<h2>Sélection et création</h2>
</div>
</div>
<div className="student-roster">
{students.map((student) => (
<button
key={student.id}
type="button"
className={String(student.id) === String(selectedStudentId) ? 'active' : ''}
onClick={() => setSelectedStudentId(String(student.id))}
>
<strong>{student.first_name}</strong>
<span>{student.grade} · {student.age} ans</span>
</button>
))}
</div>
<form onSubmit={createStudent} className="student-create-form">
<h3>Créer un compte élève</h3>
<input placeholder="Prénom" value={form.first_name} onChange={(event) => setForm({ ...form, first_name: event.target.value })} />
<input type="number" min="8" max="12" value={form.age} onChange={(event) => setForm({ ...form, age: event.target.value })} />
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
<option>CE2</option>
<option>CM1</option>
<option>CM2</option>
<option>6e</option>
</select>
<input placeholder="Identifiant élève" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<input placeholder="Mot de passe élève" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
<button type="submit">Créer le compte</button>
</form>
</aside>
<section className="admin-panel student-report-panel">
{selectedStudent ? (
<>
<div className="admin-panel-head">
<div>
<span className="stage-label">Fiche élève</span>
<h2>{selectedStudent.first_name}</h2>
<p className="muted">{selectedStudent.grade} · {selectedStudent.age} ans</p>
</div>
</div>
<div className="student-report-grid">
<article>
<span>Leçon courante</span>
<strong>{studentProgram?.title || 'Aucune leçon'}</strong>
<p>{studentProgram ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)` : 'Affecte une leçon ci-dessous.'}</p>
</article>
<article>
<span>Résultats</span>
<strong>{averageComprehension}%</strong>
<p>{cardProgress.length} card(s) observée(s)</p>
</article>
<article>
<span>Contenu fait</span>
<strong>{validatedCards}</strong>
<p>{remainingCards} card(s) encore à travailler</p>
</article>
<article>
<span>Kit adaptatif</span>
<strong>{adaptiveProfile?.profile_title || (adaptiveKit ? 'Disponible' : 'Absent')}</strong>
<p>
{adaptiveProfile
? 'Profil de reprise enregistré'
: adaptiveKit
? `${adaptiveKit.diagnostics?.length || 0} test(s), ${adaptiveKit.profiles?.length || 0} parcours`
: 'Aucun diagnostic relié à cette leçon.'}
</p>
</article>
</div>
<div className="student-settings-grid">
<label>
<span>Leçon active</span>
<select value={studentProgram?.id || ''} onChange={(event) => assignProgramLesson(event.target.value)}>
<option value="">Choisir une leçon pour {selectedStudent.grade}</option>
{programLessons.map((lesson) => (
<option key={lesson.id} value={lesson.id}>{lesson.subject} · {lesson.title} · {lesson.asset_count} fiche(s)</option>
))}
</select>
</label>
{studentProgram?.assets?.length > 0 && (
<>
<label>
<span>Reprendre à la fiche</span>
<select value={programStart.asset_index} onChange={(event) => updateProgramStart(event.target.value, 0)}>
{studentProgram.assets.map((asset, index) => (
<option key={asset.path} value={index}>{index + 1}. {asset.title}</option>
))}
</select>
</label>
<label>
<span>Card de départ</span>
<select value={programStart.section_index} onChange={(event) => updateProgramStart(programStart.asset_index, event.target.value)}>
{(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
<option key={section.context_key || index} value={index}>{index + 1}. {section.title}</option>
))}
</select>
</label>
</>
)}
<label>
<span>Rythme pédagogique</span>
<select defaultValue="normal">
<option value="slow">Très guidé</option>
<option value="normal">Normal</option>
<option value="fast">Autonome</option>
</select>
</label>
<label className="wide-field">
<span>Instructions propres à lenfant</span>
<textarea placeholder="Exemple: privilégier les exemples concrets, reformuler les consignes longues, valoriser les réponses orales." />
</label>
</div>
{adaptiveKit && (
<section className="adaptive-kit-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Adaptatif</span>
<h2>Diagnostic et parcours disponibles</h2>
</div>
<button type="button" className="secondary-button" onClick={() => setActiveAdminTab('validation')}>
Valider le kit
</button>
</div>
<div className="adaptive-kit-grid">
<article>
<strong>Tests diagnostics</strong>
{(adaptiveKit.diagnostics || []).map((item) => (
<span key={item.id}>{item.title}</span>
))}
</article>
<article>
<strong>Parcours profils</strong>
{(adaptiveKit.profiles || []).map((item) => (
<span key={item.id}>{item.title}</span>
))}
</article>
<article>
<strong>Supports visuels</strong>
{(adaptiveKit.supports || []).map((item) => (
<span key={item.id}>{item.title}</span>
))}
</article>
</div>
<form className="adaptive-profile-form" onSubmit={saveAdaptiveProfile}>
<label>
<span>Test diagnostic de référence</span>
<select
value={adaptiveProfileForm.diagnostic_id}
onChange={(event) => setAdaptiveProfileForm({ ...adaptiveProfileForm, diagnostic_id: event.target.value })}
>
<option value="">Non précisé</option>
{(adaptiveKit.diagnostics || []).map((item) => (
<option key={item.id} value={item.id}>{item.title}</option>
))}
</select>
</label>
<label>
<span>Parcours recommandé</span>
<select
value={adaptiveProfileForm.profile_id}
onChange={(event) => setAdaptiveProfileForm({ ...adaptiveProfileForm, profile_id: event.target.value })}
>
<option value="">Choisir un parcours</option>
{(adaptiveKit.profiles || []).map((item) => (
<option key={item.id} value={item.id}>{item.title}</option>
))}
</select>
</label>
<label className="wide-field">
<span>Note de suivi</span>
<textarea
value={adaptiveProfileForm.notes}
onChange={(event) => setAdaptiveProfileForm({ ...adaptiveProfileForm, notes: event.target.value })}
placeholder="Exemple: inverse encore numérateur/dénominateur, commencer par le parcours verbal."
/>
</label>
<button type="submit" disabled={!adaptiveProfileForm.profile_id}>
Enregistrer le profil adaptatif
</button>
</form>
</section>
)}
<div className="admin-dashboard-grid">
<section className="admin-preview">
<h2>Compréhension des cards</h2>
{cardProgress.length ? (
<div className="admin-log-list">
{cardProgress.map((item) => (
<article key={item.id} className="admin-log-card">
<strong>{item.section_title}</strong>
<span>{Math.round(item.comprehension_score)}% · essais: {item.attempts}</span>
</article>
))}
</div>
) : <p className="muted">Aucune card validée pour le moment.</p>}
</section>
<section className="admin-preview">
<h2>Logs de séance</h2>
{sessionLogs.length ? (
<div className="admin-log-list">
{sessionLogs.map((log) => (
<article key={log.id} className="admin-log-card">
<div>
<strong>{log.lesson_title}</strong>
<span>{new Date(log.ended_at).toLocaleString('fr-FR')}</span>
</div>
<div className="admin-log-actions">
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(log)}>Voir</button>
<button type="button" className="secondary-button" onClick={() => deleteSessionLog(log.id)}>Effacer</button>
</div>
</article>
))}
</div>
) : <p className="muted">Aucun log enregistré.</p>}
</section>
</div>
{selectedSessionLog && (
<section className="admin-preview">
<div className="admin-log-detail-head">
<h2>Détail de séance</h2>
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(null)}>Fermer</button>
</div>
<div className="admin-log-detail">
<h3>Résultats des cards</h3>
{parseLogJson(selectedSessionLog.card_results).map((item) => (
<article key={item.section_key} className="admin-log-card">
<strong>{item.section_title}</strong>
<span>{Math.round(item.comprehension_score)}% · {item.validated ? 'validée' : 'vue/tentée'}</span>
</article>
))}
<h3>Conversation</h3>
{parseLogJson(selectedSessionLog.conversation).map((message, index) => (
<p key={`${message.id || index}`} className="admin-log-message">
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}:</strong> {message.content}
</p>
))}
</div>
</section>
)}
</>
) : (
<section className="admin-empty">
<h2>Aucun élève sélectionné</h2>
<p className="muted">Choisis un élève ou crée un compte pour configurer son programme.</p>
</section>
)}
</section>
</section>
)}
{activeAdminTab === 'program' && (
<section className="admin-program-browser program-tab">
<div className="program-tree-panel">
<div className="admin-log-detail-head">
<h2>Arborescence du programme</h2>
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
</div>
<div className="program-tree-list">
{visibleProgramRows.map(({ node, depth }) => (
<button key={node.key} type="button" className={`program-tree-row ${selectedProgramNodeKey === node.key ? 'active' : ''}`} style={{ paddingLeft: `${0.65 + depth * 1.05}rem` }} onClick={() => toggleProgramNode(node)}>
<span className="tree-label">
<b>{node.children?.length ? (collapsedProgramKeys.has(node.key) ? '+' : '-') : ''}</b>
{node.label}
</span>
<em>{node.type}</em>
{node.review?.status && <strong>{node.review.status}</strong>}
</button>
))}
</div>
</div>
<ProgramReviewPanel
selectedNode={selectedProgramNode}
reviewForm={programReviewForm}
onReviewChange={setProgramReviewForm}
onSaveReview={saveProgramReview}
readOnly
onOpenValidation={() => setActiveAdminTab('validation')}
/>
</section>
)}
{activeAdminTab === 'validation' && (
<section className="validation-layout">
<aside className="admin-panel review-queue">
<div className="admin-panel-head">
<div>
<span className="stage-label">File éditoriale</span>
<h2>Contenus à valider</h2>
</div>
</div>
<div className="review-filters">
<span>À relire</span>
<span>À modifier</span>
<span>Validé</span>
<span>Intégré</span>
</div>
<div className="program-tree-list">
{reviewRows.map(({ node, depth }) => (
<button key={node.key} type="button" className={`program-tree-row ${selectedProgramNodeKey === node.key ? 'active' : ''}`} style={{ paddingLeft: `${0.65 + Math.min(depth, 3) * 0.75}rem` }} onClick={() => setSelectedProgramNodeKey(node.key)}>
<span>{node.label}</span>
<em>{node.type}</em>
<strong>{node.review?.status || 'à relire'}</strong>
</button>
))}
</div>
</aside>
<section className="admin-panel validation-stage">
<div className="admin-panel-head">
<div>
<span className="stage-label">{selectedProgramNode?.type || 'Sélection'}</span>
<h2>{selectedProgramNode?.label || 'Choisis une card ou une fiche'}</h2>
<p className="muted">{selectedProgramNode?.path || selectedProgramNode?.key || 'La zone centrale affiche le contenu à analyser en grand.'}</p>
</div>
{selectedProgramNode?.review?.status && <span className={`review-pill ${selectedProgramNode.review.status}`}>{selectedProgramNode.review.status}</span>}
</div>
<div className="validation-main-preview">
{selectedProgramNode?.url ? (
<img src={selectedProgramNode.url} alt={selectedProgramNode.label} />
) : selectedProgramNode?.content ? (
<pre className="validation-text-preview">{selectedProgramNode.content}</pre>
) : (
<div className="empty-preview">Aucun visuel direct pour cet élément.</div>
)}
</div>
</section>
<aside className="admin-panel validation-inspector">
<h2>Revue</h2>
<div className="program-status-grid compact">
<span>Micro: {selectedNodeStatus.micro_fiches ?? '-'}</span>
<span>SVG: {selectedNodeStatus.lesson_svg ?? '-'}</span>
<span>Cards: {selectedNodeStatus.cards ?? '-'}</span>
<span>Contextes: {selectedNodeStatus.contexts ?? '-'}</span>
<span>Exercices: {selectedNodeStatus.text_exercises ?? '-'}</span>
<span>SVG ex.: {selectedNodeStatus.exercise_svg ?? '-'}</span>
<span>Tests: {selectedNodeStatus.adaptive_tests ?? '-'}</span>
<span>Parcours: {selectedNodeStatus.adaptive_profiles ?? '-'}</span>
<span>SVG kit: {selectedNodeStatus.adaptive_svg ?? '-'}</span>
</div>
<form className="admin-review-form" onSubmit={saveProgramReview}>
<label>
<span>Statut</span>
<select value={programReviewForm.status} onChange={(event) => setProgramReviewForm({ ...programReviewForm, status: event.target.value })}>
<option value="validated">Validé</option>
<option value="needs_changes">À modifier</option>
<option value="blocked">Bloqué</option>
<option value="draft">Brouillon</option>
</select>
</label>
<label>
<span>Remarque pour modification</span>
<textarea value={programReviewForm.comment} onChange={(event) => setProgramReviewForm({ ...programReviewForm, comment: event.target.value })} placeholder="Explique ce qui doit être corrigé avant assimilation au programme." rows={8} />
</label>
<button type="submit" disabled={!selectedProgramNode}>Enregistrer la revue</button>
</form>
</aside>
</section>
)}
{activeAdminTab === 'factory' && (
<section className="admin-dashboard-grid">
<div className="admin-panel">
<span className="stage-label">Atelier</span>
<h2>Planifier une génération</h2>
<div className="student-settings-grid single">
<label><span>Niveau</span><select defaultValue="CM1"><option>CM1</option><option>CM2</option><option>6e</option></select></label>
<label><span>Production</span><select defaultValue="exercise_svg"><option value="micro">Micro-fiches</option><option value="lesson_svg">SVG leçon</option><option value="contexts">Contextes de cards</option><option value="exercises">Exercices textuels</option><option value="exercise_svg">SVG exercices</option><option value="adaptive">Kit adaptatif</option></select></label>
<label><span>Créneau</span><select defaultValue="night"><option value="now">Lancer maintenant</option><option value="night">Cette nuit</option></select></label>
<button type="button" disabled>Planifier après branchement backend</button>
</div>
</div>
<div className="admin-panel">
<span className="stage-label">Pipeline</span>
<h2>Assimilation des contenus</h2>
<div className="pipeline-list">
<span>1. Génération nocturne</span>
<span>2. File de validation</span>
<span>3. Remarque ou validation</span>
<span>4. Assimilation au programme</span>
<span>5. Disponibilité dans le mode élève</span>
</div>
</div>
</section>
)}
{activeAdminTab === 'settings' && (
<section className="admin-dashboard-grid">
<div className="admin-panel">
<span className="stage-label">Application</span>
<h2>Paramètres globaux</h2>
<div className="student-settings-grid single">
<label><span>Voix par défaut</span><select value={selectedVoiceProfileId} onChange={(event) => setSelectedVoiceProfileId(event.target.value)}>{voiceProfiles.map((profile) => <option key={profile.id} value={profile.id}>{formatVoiceLabel(profile)}</option>)}</select></label>
<label><span>Longueur des tours</span><select defaultValue="short"><option value="short">Courte</option><option value="normal">Normale</option></select></label>
<label className="wide-field"><span>Règles pédagogiques globales</span><textarea placeholder="Règles communes appliquées à Professeur TOP pour tous les élèves." /></label>
</div>
</div>
<div className="admin-panel">
<span className="stage-label">Système</span>
<h2>Diagnostic</h2>
<div className="program-status-grid">
<span>API: active</span>
<span>Contenus: {programStatus?.content_root_exists === false ? 'à vérifier' : 'OK'}</span>
<span>Leçons: {programStatus?.lesson_count ?? 0}</span>
</div>
</div>
</section>
)}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
</div>
)
}
return (
<div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}>
<aside className="sidebar card">
<h2>{isStudentUser ? 'Ma séance' : 'Élèves'}</h2>
{!isStudentUser && (
<>
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
<option value="">Choisir un élève</option>
{students.map((student) => (
<option key={student.id} value={student.id}>
{student.first_name} · {student.grade}
</option>
))}
</select>
<form onSubmit={createStudent} className="stack">
<input
placeholder="Prénom"
value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/>
<input
type="number"
min="8"
max="12"
value={form.age}
onChange={(event) => setForm({ ...form, age: event.target.value })}
/>
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
<option>CE2</option>
<option>CM1</option>
<option>CM2</option>
<option>6e</option>
</select>
<input
placeholder="Identifiant élève"
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<input
placeholder="Mot de passe élève"
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
/>
<button type="submit">Créer le compte élève</button>
</form>
{selectedStudent && (
<section className="admin-program-panel">
<h3>Programme de lélève</h3>
<p className="muted">
{studentProgram
? `Leçon active: ${studentProgram.title}`
: 'Aucune leçon active pour cet élève.'}
</p>
<select
value={studentProgram?.id || ''}
onChange={(event) => assignProgramLesson(event.target.value)}
>
<option value="">Choisir une leçon pour {selectedStudent.grade}</option>
{programLessons.map((lesson) => (
<option key={lesson.id} value={lesson.id}>
{lesson.subject} · {lesson.title} · {lesson.asset_count} fiche(s)
</option>
))}
</select>
{programLessons.length === 0 && (
<p className="program-diagnostic">
Aucune leçon trouvée pour {selectedStudent.grade}. Dossier lu: {programStatus?.content_root || 'inconnu'}.
{programStatus?.content_root_exists === false
? ' Le dossier de programme nest pas accessible par le backend.'
: ' Vérifie le niveau ou le montage du dossier Programme.'}
</p>
)}
</section>
)}
</>
)}
<button type="button" className="secondary-button" onClick={onLogout}>
Déconnexion
</button>
</aside>
<main className="admin-main card">
<header className="admin-header">
<div>
<h1>Gestion du programme</h1>
<p className="muted">
Configure les élèves, leur niveau et la leçon active visible dans le mode élève.
</p>
</div>
<span>{currentUser.username} · {currentUser.role}</span>
</header>
<section className="admin-program-browser">
<div className="program-tree-panel">
<div className="admin-log-detail-head">
<h2>Arborescence du programme</h2>
<button type="button" className="secondary-button" onClick={loadProgramTree}>
Rafraîchir
</button>
</div>
<div className="program-tree-list">
{programRows.map(({ node, depth }) => (
<button
key={node.key}
type="button"
className={`program-tree-row ${selectedProgramNodeKey === node.key ? 'active' : ''}`}
style={{ paddingLeft: `${0.65 + depth * 1.05}rem` }}
onClick={() => setSelectedProgramNodeKey(node.key)}
>
<span>{node.label}</span>
<em>{node.type}</em>
{node.review?.status && <strong>{node.review.status}</strong>}
</button>
))}
</div>
</div>
<ProgramReviewPanel
selectedNode={selectedProgramNode}
reviewForm={programReviewForm}
onReviewChange={setProgramReviewForm}
onSaveReview={saveProgramReview}
/>
</section>
{selectedStudent ? (
<section className="admin-detail">
<div className="admin-student-card">
<span className="stage-label">Élève sélectionné</span>
<h2>{selectedStudent.first_name}</h2>
<p>{selectedStudent.grade} · {selectedStudent.age} ans</p>
</div>
<div className="admin-student-card">
<span className="stage-label">Leçon active</span>
<h2>{studentProgram?.title || 'Aucune leçon'}</h2>
<p>
{studentProgram
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
: 'Choisis une leçon dans la colonne de gauche.'}
</p>
{studentProgram?.assets?.length > 0 && (
<div className="admin-start-controls">
<label>
<span>Reprendre à la fiche</span>
<select
value={programStart.asset_index}
onChange={(event) => updateProgramStart(event.target.value, 0)}
>
{studentProgram.assets.map((asset, index) => (
<option key={asset.path} value={index}>
{index + 1}. {asset.title}
</option>
))}
</select>
</label>
<label>
<span>Card de départ</span>
<select
value={programStart.section_index}
onChange={(event) => updateProgramStart(programStart.asset_index, event.target.value)}
>
{(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
<option key={section.context_key || index} value={index}>
{index + 1}. {section.title}
</option>
))}
</select>
</label>
</div>
)}
</div>
</section>
) : (
<section className="admin-empty">
<h2>Aucun élève sélectionné</h2>
<p className="muted">Choisis un élève ou crée un compte pour configurer son programme.</p>
</section>
)}
{studentProgram?.assets?.length > 0 && (
<section className="admin-preview">
<h2>Fiches de la leçon</h2>
<div className="admin-asset-grid">
{studentProgram.assets.map((asset) => (
<article key={asset.path} className="admin-asset-card">
<img src={asset.url} alt={asset.title} />
<strong>{asset.title}</strong>
</article>
))}
</div>
</section>
)}
{selectedStudent && (
<section className="admin-preview">
<h2>Compréhension des cards</h2>
{cardProgress.length ? (
<div className="admin-log-list">
{cardProgress.map((item) => (
<article key={item.id} className="admin-log-card">
<strong>{item.section_title}</strong>
<span>{Math.round(item.comprehension_score)}% · essais: {item.attempts}</span>
</article>
))}
</div>
) : (
<p className="muted">Aucune card validée pour le moment.</p>
)}
</section>
)}
{selectedStudent && (
<section className="admin-preview">
<h2>Logs de séance</h2>
{sessionLogs.length ? (
<div className="admin-log-list">
{sessionLogs.map((log) => (
<article key={log.id} className="admin-log-card">
<div>
<strong>{log.lesson_title}</strong>
<span>{new Date(log.ended_at).toLocaleString('fr-FR')}</span>
</div>
<div className="admin-log-actions">
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(log)}>
Voir
</button>
<button type="button" className="secondary-button" onClick={() => deleteSessionLog(log.id)}>
Effacer
</button>
</div>
</article>
))}
</div>
) : (
<p className="muted">Aucun log enregistré.</p>
)}
</section>
)}
{selectedSessionLog && (
<section className="admin-preview">
<div className="admin-log-detail-head">
<h2>Détail de séance</h2>
<button type="button" className="secondary-button" onClick={() => setSelectedSessionLog(null)}>
Fermer
</button>
</div>
<div className="admin-log-detail">
<h3>Résultats des cards</h3>
{parseLogJson(selectedSessionLog.card_results).map((item) => (
<article key={item.section_key} className="admin-log-card">
<strong>{item.section_title}</strong>
<span>{Math.round(item.comprehension_score)}% · {item.validated ? 'validée' : 'vue/tentée'}</span>
</article>
))}
<h3>Conversation</h3>
{parseLogJson(selectedSessionLog.conversation).map((message, index) => (
<p key={`${message.id || index}`} className="admin-log-message">
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}:</strong> {message.content}
</p>
))}
</div>
</section>
)}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
</div>
)
}
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 (
<main className="login-page">
<section className="login-panel card">
<Avatar speaking={false} />
<p className="muted">Verification de la session...</p>
</section>
</main>
)
}
if (!currentUser) {
return <LoginPage onLogin={setCurrentUser} />
}
return <TutorApp currentUser={currentUser} onLogout={logout} />
}