admin mode

This commit is contained in:
2026-05-01 20:37:15 +02:00
parent 72a9f9d9e2
commit bfd367f3d8
8 changed files with 563 additions and 186 deletions

View File

@@ -146,7 +146,7 @@ function LoginPage({ onLogin }) {
<Avatar speaking={false} />
<div className="login-copy">
<h1>Professeur TOP</h1>
<p className="muted">Connexion eleve, professeur ou maintenance.</p>
<p className="muted">Connexion élève, professeur ou maintenance.</p>
</div>
<form onSubmit={submitLogin} className="stack login-form">
@@ -208,6 +208,8 @@ function TutorApp({ currentUser, onLogout }) {
const [audioDebug, setAudioDebug] = useState([])
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const [programLessons, setProgramLessons] = useState([])
const [studentProgram, setStudentProgram] = useState(null)
const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null)
@@ -294,13 +296,20 @@ function TutorApp({ currentUser, onLogout }) {
setLastStudentTransmission('')
setCurrentFocusSkill(null)
setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
if (selectedStudentId) {
loadProgress(selectedStudentId)
loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
if (!isStudentUser) {
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
} else {
setMessages([])
}
}, [selectedStudentId])
}, [selectedStudentId, students])
useEffect(() => {
if (selectedVoiceProfileId) {
@@ -324,7 +333,7 @@ function TutorApp({ currentUser, onLogout }) {
} else {
setStudents([])
setSelectedStudentId('')
setErrorMessage('Ce compte eleve n est pas encore rattache a une fiche eleve.')
setErrorMessage('Ce compte élève nest pas encore rattaché à une fiche élève.')
}
return
}
@@ -371,7 +380,7 @@ function TutorApp({ currentUser, onLogout }) {
return
}
if (!form.username.trim() || !form.password) {
setErrorMessage('Identifiant et mot de passe eleve obligatoires.')
setErrorMessage('Identifiant et mot de passe élève obligatoires.')
return
}
@@ -391,7 +400,7 @@ function TutorApp({ currentUser, onLogout }) {
setSelectedStudentId(String(data.student.id))
setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' })
} catch (error) {
setErrorMessage(error.message || 'Impossible de creer le compte eleve.')
setErrorMessage(error.message || 'Impossible de créer le compte élève.')
}
}
@@ -527,6 +536,40 @@ function TutorApp({ currentUser, onLogout }) {
}
}
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 loadStudentProgram(studentId) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
setStudentProgram(data.lesson || null)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la leçon de lélève.')
}
}
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)
} catch (error) {
setErrorMessage(error.message || 'Impossible daffecter cette leçon.')
}
}
async function finishSpeechPlayback(debugMessage) {
setSpeaking(false)
if (audioPlaybackUrlRef.current) {
@@ -877,11 +920,14 @@ function TutorApp({ currentUser, onLogout }) {
const lastAssistantMessage = [...messages].reverse().find((message) => message.role === 'assistant')
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user')
const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne seance trouvee pour le moment. Professeur TOP commencera par une reprise courte.'
const lastStudentWork = lastUserMessage?.content || 'Aucune reponse recente enregistree.'
const nextLessonAdvice = lessonFocus
? `Conseil: reprendre ${lessonFocus.label}, puis lancer un exercice court pour equilibrer la progression.`
: 'Conseil: demarrer une seance pour laisser Professeur TOP choisir la prochaine lecon.'
const activeLessonTitle = studentProgram?.title || lessonFocus?.label || 'Cours du jour'
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 (
@@ -892,26 +938,26 @@ function TutorApp({ currentUser, onLogout }) {
<div>
<p className="eyebrow">Bonjour</p>
<h1>{selectedStudent?.first_name || currentUser.username}</h1>
<p className="muted">{selectedStudent?.grade || 'Classe non renseignee'}</p>
<p className="muted">{selectedStudent?.grade || 'Classe non renseignée'}</p>
</div>
<div className="student-home-actions">
<button onClick={toggleSession} disabled={!selectedStudentId}>
Demarrer la lecon
Démarrer la leçon
</button>
<button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion
Déconnexion
</button>
</div>
</section>
<section className="student-home-grid">
<article className="student-info-card card">
<span className="stage-label">Derniere seance</span>
<span className="stage-label">Dernière séance</span>
<p>{lessonSummary}</p>
</article>
<article className="student-info-card card">
<span className="stage-label">Derniere reponse</span>
<span className="stage-label">Dernière réponse</span>
<p>{lastStudentWork}</p>
</article>
@@ -920,14 +966,14 @@ function TutorApp({ currentUser, onLogout }) {
<span className="stage-label">Avancement</span>
<strong>{lessonPercent}%</strong>
</div>
<h2>{lessonFocus?.label || 'Programme a definir'}</h2>
<h2>{activeLessonTitle}</h2>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${lessonPercent}%` }} />
</div>
</article>
<article className="student-info-card card advice-card">
<span className="stage-label">Prochaine lecon conseillee</span>
<span className="stage-label">Prochaine leçon conseillée</span>
<p>{nextLessonAdvice}</p>
</article>
</section>
@@ -940,15 +986,15 @@ function TutorApp({ currentUser, onLogout }) {
<header className="lesson-topbar">
<Avatar speaking={speaking} />
<div>
<p className="eyebrow">Lecon en cours</p>
<h1>{lessonFocus?.label || 'Cours du jour'}</h1>
<p className="eyebrow">Leçon en cours</p>
<h1>{activeLessonTitle}</h1>
</div>
<div className="lesson-actions">
<button
type="button"
className="icon-button"
aria-label="Parametres"
title="Parametres"
aria-label="Paramètres"
title="Paramètres"
onClick={() => setSettingsPanelOpen(true)}
>
...
@@ -967,13 +1013,20 @@ function TutorApp({ currentUser, onLogout }) {
{currentInstruction ? (
<p>{currentInstruction}</p>
) : (
<p className="muted">Professeur TOP prepare la suite de la lecon.</p>
<p className="muted">Professeur TOP prépare la suite de la leçon.</p>
)}
{studentProgram?.assets?.length > 0 && (
<div className="lesson-assets" aria-label="Fiches de leçon">
{studentProgram.assets.map((asset) => (
<img key={asset.path} src={asset.url} alt={asset.title} />
))}
</div>
)}
</section>
<section className="student-transmission lesson-bubble">
<span>Ta reponse</span>
<p>{lastStudentTransmission || 'Dicte ou ecris ta reponse quand tu es pret.'}</p>
<span>Ta réponse</span>
<p>{lastStudentTransmission || 'Dicte ou écris ta réponse quand tu es prêt.'}</p>
</section>
<form onSubmit={assessment ? submitAssessment : sendMessage} className="lesson-composer">
@@ -982,7 +1035,7 @@ function TutorApp({ currentUser, onLogout }) {
onChange={(event) =>
assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value)
}
placeholder={assessment ? 'Ta reponse au mini-test...' : 'Ecris ta reponse...'}
placeholder={assessment ? 'Ta réponse au mini-test...' : 'Écris ta réponse...'}
/>
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
{isAutoListening ? 'Stop micro' : isTranscribing ? 'Transcription...' : 'Dicter'}
@@ -1014,9 +1067,9 @@ function TutorApp({ currentUser, onLogout }) {
{settingsPanelOpen && (
<div className="progress-panel-backdrop" role="presentation" onClick={() => setSettingsPanelOpen(false)}>
<aside className="progress-panel card" role="dialog" aria-label="Parametres" onClick={(event) => event.stopPropagation()}>
<aside className="progress-panel card" role="dialog" aria-label="Paramètres" onClick={(event) => event.stopPropagation()}>
<div className="progress-panel-head">
<h2>Parametres</h2>
<h2>Paramètres</h2>
<button type="button" className="secondary-button" onClick={() => setSettingsPanelOpen(false)}>
Fermer
</button>
@@ -1052,11 +1105,11 @@ function TutorApp({ currentUser, onLogout }) {
return (
<div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}>
<aside className="sidebar card">
<h2>{isStudentUser ? 'Ma seance' : 'Eleves'}</h2>
<h2>{isStudentUser ? 'Ma séance' : 'Élèves'}</h2>
{!isStudentUser && (
<>
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
<option value="">Choisir un eleve</option>
<option value="">Choisir un élève</option>
{students.map((student) => (
<option key={student.id} value={student.id}>
{student.first_name} · {student.grade}
@@ -1066,7 +1119,7 @@ function TutorApp({ currentUser, onLogout }) {
<form onSubmit={createStudent} className="stack">
<input
placeholder="Prenom"
placeholder="Prénom"
value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/>
@@ -1084,171 +1137,99 @@ function TutorApp({ currentUser, onLogout }) {
<option>6e</option>
</select>
<input
placeholder="Identifiant eleve"
placeholder="Identifiant élève"
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<input
placeholder="Mot de passe eleve"
placeholder="Mot de passe élève"
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
/>
<button type="submit">Creer le compte eleve</button>
<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>
</section>
)}
</>
)}
{isStudentUser && selectedStudent && (
<div className="session-summary">
<strong>{selectedStudent.first_name}</strong>
<span>{selectedStudent.grade}</span>
<span>{sessionActive ? 'Seance en cours' : 'Pret pour la seance'}</span>
</div>
)}
{isStudentUser && (
<div className="today-panel">
<button onClick={toggleSession} disabled={!selectedStudentId}>
{sessionActive ? 'Fin de la seance' : 'Demarrer la seance'}
</button>
<h3>Themes du jour</h3>
<div className="today-card lesson-card">
<div className="today-card-head">
<strong>Lecon</strong>
<span>{lessonPercent}% acquis</span>
</div>
<p>{lessonFocus?.label || 'Le sujet sera choisi au demarrage.'}</p>
</div>
<div className="today-card exercise-card">
<div className="today-card-head">
<strong>Exercice</strong>
<span>{exerciseLabel}</span>
</div>
<p>{assessment?.skill_label || lessonFocus?.label || 'Aucun exercice lance.'}</p>
</div>
</div>
)}
<button onClick={startSession} disabled={!selectedStudentId}>Démarrer la séance</button>
<button onClick={fetchAssessment} disabled={!selectedStudentId}>Lancer un mini-test</button>
<h3>Progression</h3>
<div className="stack small-gap">
{progress.map((item) => <ProgressCard key={item.code} item={item} />)}
</div>
<button type="button" className="secondary-button" onClick={onLogout}>
Déconnexion
</button>
</aside>
<main className="main card">
<div className="hero">
<Avatar speaking={speaking} />
<main className="admin-main card">
<header className="admin-header">
<div>
<h1>Professeur virtuel</h1>
<p>
{selectedStudent
? `Séance active pour ${selectedStudent.first_name}, ${selectedStudent.grade}`
: 'Choisis ou crée un élève pour commencer.'}
</p>
</div>
<div className="session-user">
<span>{currentUser.username} · {currentUser.role}</span>
<button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion
</button>
</div>
</div>
<section className="instruction-stage">
<span className="stage-label">Cours en cours</span>
{currentInstruction ? (
<p>{currentInstruction}</p>
) : (
<h1>Gestion du programme</h1>
<p className="muted">
Demarre la seance pour que Professeur TOP reprenne ou tu t'etais arrete et lance le cours du jour.
Configure les élèves, leur niveau et la leçon active visible dans le mode élève.
</p>
)}
</section>
</div>
<span>{currentUser.username} · {currentUser.role}</span>
</header>
<section className="messages" aria-label="Journal compact de la seance">
{messages.length === 0 && <p className="muted">Le dialogue apparaîtra ici.</p>}
{messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}</strong>
<p>{message.content}</p>
{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>
))}
</section>
{assessment && (
<form className="assessment" onSubmit={submitAssessment}>
<h3>Mini-test · {assessment.skill_label}</h3>
<p>{assessment.question}</p>
<input
value={assessmentAnswer}
onChange={(event) => setAssessmentAnswer(event.target.value)}
placeholder="Ta réponse"
/>
<button type="submit">Valider</button>
</form>
<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>
</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 className="student-transmission">
<span>Transmis au prof</span>
<p>{lastStudentTransmission || 'Le texte reconnu par le micro ou envoye au clavier apparaitra ici.'}</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>
)}
<form onSubmit={assessment ? submitAssessment : sendMessage} className="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...' : 'Pose une question ou demande une explication...'}
/>
<select
value={selectedVoiceProfileId}
onChange={(event) => setSelectedVoiceProfileId(event.target.value)}
title="Choisir la personnalité vocale de Professeur TOP"
>
<option value="">Voix de Professeur TOP</option>
{voiceProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{formatVoiceLabel(profile)}
</option>
))}
</select>
{selectedVoiceProfile && (
<span
className="voice-tooltip"
title={`Voix IA non humaine: ${formatVoiceLabel(selectedVoiceProfile)} - ${selectedVoiceProfile.description}`}
aria-label={`Voix IA non humaine: ${formatVoiceLabel(selectedVoiceProfile)} - ${selectedVoiceProfile.description}`}
>
i
</span>
)}
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
{isAutoListening ? 'Couper le micro' : isTranscribing ? 'Transcription...' : 'Micro auto'}
</button>
<button type="submit">Envoyer</button>
</form>
{isAutoListening && (
<div className="voice-status">
Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}%
</div>
)}
{selectedVoiceProfile && (
<p className="muted voice-note">
Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description}
</p>
)}
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>}
{DEBUG_AUDIO && (
<div className="voice-status">
{audioDebug.length === 0 ? 'Debug audio: aucune trace' : `Debug audio: ${audioDebug.join(' | ')}`}
</div>
)}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
</div>
)

View File

@@ -96,6 +96,85 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.today-card p {
margin: 0 0 0.75rem;
}
.admin-program-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid #dbeafe;
border-radius: 16px;
background: #f8fbff;
padding: 0.85rem;
}
.admin-program-panel h3,
.admin-program-panel p {
margin: 0;
}
.admin-main {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
overflow-y: auto;
}
.admin-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
.admin-header h1,
.admin-header p,
.admin-detail h2,
.admin-detail p,
.admin-empty h2,
.admin-empty p,
.admin-preview h2 {
margin: 0;
}
.admin-header span {
color: #64748b;
white-space: nowrap;
}
.admin-detail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.admin-student-card,
.admin-empty {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #f8fafc;
padding: 1rem;
}
.admin-preview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.admin-asset-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #f8fafc;
padding: 0.75rem;
}
.admin-asset-card img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
border-radius: 12px;
background: white;
}
.session-user {
margin-left: auto;
display: flex;
@@ -382,6 +461,24 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: clamp(1rem, 2.2vw, 1.35rem);
line-height: 1.5;
}
.lesson-assets {
min-height: 0;
flex: 1 1 auto;
display: flex;
gap: 0.75rem;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.25rem;
}
.lesson-assets img {
width: min(100%, 720px);
max-height: 100%;
flex: 0 0 min(100%, 720px);
object-fit: contain;
border: 1px solid #dbeafe;
border-radius: 14px;
background: white;
}
.lesson-bubble {
min-height: 68px;
}
@@ -471,6 +568,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: space-between;
}
.admin-header,
.admin-detail {
grid-template-columns: 1fr;
}
.admin-header {
flex-direction: column;
}
.instruction-stage {
flex: 0 0 auto;
justify-content: flex-start;