2570 lines
100 KiB
JavaScript
2570 lines
100 KiB
JavaScript
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 l’arborescence 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>
|
||
</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>
|
||
)}
|
||
|
||
{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 l’onglet 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 [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([])
|
||
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)
|
||
}
|
||
}, [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 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_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 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
|
||
? `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 }) => node.type === 'lesson' || node.type === 'fiche' || node.type === 'card')
|
||
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 (
|
||
<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 l’onglet É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 d’exercices 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>
|
||
</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 à l’enfant</span>
|
||
<textarea placeholder="Exemple: privilégier les exemples concrets, reformuler les consignes longues, valoriser les réponses orales." />
|
||
</label>
|
||
</div>
|
||
|
||
<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} />
|
||
) : (
|
||
<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>
|
||
</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 n’est 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} />
|
||
}
|