diff --git a/MEMORY.md b/MEMORY.md index 67f4ebd..757ab0d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -183,6 +183,26 @@ Approche progressive recommandee: - En cas de `502` sur `/api/*`, verifier d'abord `docker logs tutor-backend`. - Pour tester l'auth locale actuelle: `.env` contient `ADMIN_USERNAME=admin` et `ADMIN_PASSWORD=admin`; a changer avant tout usage partage. +## Note Programme Pedagogique par Eleve + +- Le mode admin doit devenir une gestion du programme par eleve. +- Pour chaque eleve configure, le professeur/admin doit pouvoir choisir une lecon dans le contenu disponible localement. +- La source prioritaire du contenu est: + - `C:\DataS\OpenSquared\OpenSchool\Programme\contenus_pedagogiques` +- Le contenu est classe par cycle, niveau et matiere: + - cycle 3: CM1, CM2, 6e + - cycle 4: 5e, 4e, 3e +- Pour un eleve de CM1, l'admin doit pouvoir choisir par exemple: + - mathematiques > nombres entiers +- Les fiches `.svg` associees a la lecon choisie doivent s'afficher dans la zone centrale de Professeur TOP cote eleve. +- Premier essai implemente: une affectation active par eleve, selectionnee en admin, puis visible dans l'ecran eleve. +- A prevoir ensuite: + - plusieurs cours possibles par eleve + - progression equilibree entre lecons et matieres + - instructions personnalisees par eleve + - suivi fin des fiches vues / exercices faits +- Vigilance langue française: les libellés visibles doivent utiliser les accents et formulations françaises correctes, par exemple `Démarrer la leçon`. + ## Plan de Dev Acces Eleve / Admin 1. Socle comptes et roles: table `users`, hash password, seed admin depuis `.env`, login, session courante. diff --git a/backend/app/main.py b/backend/app/main.py index 8ac5d0d..9843506 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ from contextlib import asynccontextmanager import os from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from .database import Base, engine, get_db from . import models, schemas @@ -15,6 +16,7 @@ from .auth import ( verify_password, ) from .curriculum import QUESTIONS +from .program_content import decode_asset_token, get_lesson, list_lessons_for_grade, normalize_grade from .services import ( build_llm_reply, ensure_student_mastery, @@ -59,7 +61,12 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None: return if current_user.role == "student" and current_user.student_id == student_id: return - raise HTTPException(status_code=403, detail="Droits insuffisants pour cet eleve") + raise HTTPException(status_code=403, detail="Droits insuffisants pour cet élève") + + +def build_student_program_response(student: models.Student, assignment: models.StudentProgramAssignment | None): + lesson = get_lesson(assignment.lesson_id) if assignment else None + return schemas.StudentProgramResponse(student=student, lesson=lesson) @app.get("/health") def health(): @@ -127,7 +134,7 @@ def create_student_account( username = payload.username.strip() first_name = payload.first_name.strip() if not username or not first_name: - raise HTTPException(status_code=400, detail="Prenom et identifiant obligatoires") + raise HTTPException(status_code=400, detail="Prénom et identifiant obligatoires") existing_user = db.query(models.User).filter_by(username=username).first() if existing_user: @@ -165,7 +172,7 @@ def list_student_messages( ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: - raise HTTPException(status_code=404, detail="Eleve introuvable") + raise HTTPException(status_code=404, detail="Élève introuvable") safe_limit = min(max(limit, 1), 200) rows = ( @@ -178,6 +185,72 @@ def list_student_messages( return list(reversed(rows)) +@app.get("/program/lessons", response_model=schemas.ProgramLessonListResponse) +def list_program_lessons( + grade: str, + current_user: models.User = Depends(require_roles("teacher", "maintenance")), +): + return schemas.ProgramLessonListResponse(lessons=list_lessons_for_grade(grade)) + + +@app.get("/program/assets/{asset_token}") +def get_program_asset( + asset_token: str, + current_user: models.User = Depends(get_current_user), +): + try: + path = decode_asset_token(asset_token) + except Exception as exc: + raise HTTPException(status_code=400, detail="Ressource invalide") from exc + if not path.exists() or path.suffix.lower() != ".svg": + raise HTTPException(status_code=404, detail="Fiche introuvable") + return FileResponse(path, media_type="image/svg+xml") + + +@app.get("/students/{student_id}/program", response_model=schemas.StudentProgramResponse) +def get_student_program( + student_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + ensure_student_access(current_user, student_id) + student = db.query(models.Student).filter_by(id=student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first() + return build_student_program_response(student, assignment) + + +@app.put("/admin/students/{student_id}/program", response_model=schemas.StudentProgramResponse) +def assign_student_program( + student_id: int, + payload: schemas.StudentProgramAssignRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(require_roles("teacher", "maintenance")), +): + student = db.query(models.Student).filter_by(id=student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + + lesson = get_lesson(payload.lesson_id) + if not lesson: + raise HTTPException(status_code=404, detail="Leçon introuvable") + if lesson["grade"] != normalize_grade(student.grade): + raise HTTPException(status_code=400, detail="Cette leçon ne correspond pas au niveau de l’élève") + + assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first() + if not assignment: + assignment = models.StudentProgramAssignment(student_id=student_id) + assignment.lesson_id = lesson["id"] + assignment.title = lesson["title"] + assignment.subject = lesson["subject"] + assignment.grade = lesson["grade"] + db.add(assignment) + db.commit() + db.refresh(assignment) + return build_student_program_response(student, assignment) + + @app.post("/session/start", response_model=schemas.ChatResponse) def start_session( student_id: int, @@ -194,18 +267,24 @@ def start_session( f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. " "Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà." ) + assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student.id).first() + assigned_lesson = get_lesson(assignment.lesson_id) if assignment else None + if assigned_lesson: + message = ( + f"Bonjour {student.first_name} ! Aujourd’hui, on travaille la leçon " + f"{assigned_lesson['title']}. Regarde la première fiche, puis dis-moi ce que tu remarques." + ) previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count() if previous_messages_count: - message = build_llm_reply( - db, - student.id, - ( - "L'eleve revient pour une nouvelle seance. Fais une courte reprise: " - "explique ou on s'etait arrete la derniere fois a partir de l'historique, " - "puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une " - "premiere consigne simple." - ), + prompt = ( + "L’élève revient pour une nouvelle séance. Fais une courte reprise: " + "explique où on s’était arrêté la dernière fois à partir de l’historique, " + "puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une " + "première consigne simple." ) + if assigned_lesson: + prompt += f" La leçon active choisie par le professeur est: {assigned_lesson['title']}." + message = build_llm_reply(db, student.id, prompt) db.add(models.Message(student_id=student.id, role="assistant", content=message)) db.commit() return schemas.ChatResponse(reply=message) diff --git a/backend/app/models.py b/backend/app/models.py index 4f3f906..b2443bd 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -68,6 +68,21 @@ class StudentSkillMastery(Base): skill = relationship("Skill") +class StudentProgramAssignment(Base): + __tablename__ = "student_program_assignments" + __table_args__ = (UniqueConstraint("student_id", name="uq_student_program_assignment"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True) + lesson_id: Mapped[str] = mapped_column(String(500), index=True) + title: Mapped[str] = mapped_column(String(255)) + subject: Mapped[str] = mapped_column(String(100)) + grade: Mapped[str] = mapped_column(String(50), index=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + student = relationship("Student") + + class AssessmentAttempt(Base): __tablename__ = "assessment_attempts" diff --git a/backend/app/program_content.py b/backend/app/program_content.py new file mode 100644 index 0000000..a054e41 --- /dev/null +++ b/backend/app/program_content.py @@ -0,0 +1,138 @@ +import base64 +import os +from functools import lru_cache +from pathlib import Path +from urllib.parse import quote + + +def default_content_root() -> Path: + current = Path(__file__).resolve() + candidates = [ + parent / "Programme" / "contenus_pedagogiques" + for parent in current.parents + ] + candidates.extend( + [ + Path("/programme/contenus_pedagogiques"), + Path("/app/Programme/contenus_pedagogiques"), + ] + ) + return next((candidate for candidate in candidates if candidate.exists()), candidates[0]) + + +CONTENT_ROOT = Path(os.getenv("PROGRAM_CONTENT_ROOT", default_content_root())).resolve() + +GRADE_ALIASES = { + "CM1": "cm1", + "CM2": "cm2", + "6e": "6e", + "6eme": "6e", + "6ieme": "6e", + "5e": "5e", + "5eme": "5e", + "5ieme": "5e", + "4e": "4e", + "4eme": "4e", + "4ieme": "4e", + "3e": "3e", + "3eme": "3e", + "3ieme": "3e", +} + + +def normalize_grade(grade: str) -> str: + compact = (grade or "").strip().replace("è", "e").replace("é", "e") + return GRADE_ALIASES.get(compact, compact.lower()) + + +def title_from_slug(slug: str) -> str: + parts = slug.split("_") + if parts and parts[0][:2].isdigit(): + parts = parts[1:] + return " ".join(part.capitalize() for part in parts if part) + + +def encode_asset_path(relative_path: str) -> str: + payload = relative_path.encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def decode_asset_token(token: str) -> Path: + padding = "=" * (-len(token) % 4) + relative = base64.urlsafe_b64decode(f"{token}{padding}").decode("utf-8") + path = (CONTENT_ROOT / relative).resolve() + if CONTENT_ROOT not in path.parents: + raise ValueError("Chemin de ressource invalide") + return path + + +def build_asset_url(relative_path: str) -> str: + return f"/api/program/assets/{quote(encode_asset_path(relative_path))}" + + +@lru_cache(maxsize=1) +def list_lessons() -> list[dict]: + lessons: list[dict] = [] + if not CONTENT_ROOT.exists(): + return lessons + + for svg_dir in sorted(CONTENT_ROOT.glob("cycle_*/*/*/**/svg")): + if not svg_dir.is_dir(): + continue + + svg_files = sorted(svg_dir.glob("*.svg")) + if not svg_files: + continue + + lesson_dir = svg_dir.parent + relative_lesson = lesson_dir.relative_to(CONTENT_ROOT).as_posix() + parts = relative_lesson.split("/") + if len(parts) < 4: + continue + + cycle, subject, grade = parts[0], parts[1], parts[2] + title = title_from_slug(lesson_dir.name) + readme = lesson_dir / "README.md" + if readme.exists(): + first_heading = next( + ( + line.lstrip("#").strip() + for line in readme.read_text(encoding="utf-8", errors="ignore").splitlines() + if line.startswith("#") + ), + "", + ) + title = first_heading or title + + assets = [ + { + "name": svg_path.stem, + "title": title_from_slug(svg_path.stem), + "path": svg_path.relative_to(CONTENT_ROOT).as_posix(), + "url": build_asset_url(svg_path.relative_to(CONTENT_ROOT).as_posix()), + } + for svg_path in svg_files + ] + + lessons.append( + { + "id": relative_lesson, + "title": title, + "subject": title_from_slug(subject), + "grade": grade, + "cycle": cycle, + "asset_count": len(assets), + "assets": assets, + } + ) + + return lessons + + +def list_lessons_for_grade(grade: str) -> list[dict]: + normalized = normalize_grade(grade) + return [lesson for lesson in list_lessons() if lesson["grade"] == normalized] + + +def get_lesson(lesson_id: str) -> dict | None: + return next((lesson for lesson in list_lessons() if lesson["id"] == lesson_id), None) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5b9b2af..bfa5dfb 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -101,6 +101,36 @@ class ProgressResponse(BaseModel): progress: List[SkillProgress] +class ProgramAsset(BaseModel): + name: str + title: str + path: str + url: str + + +class ProgramLesson(BaseModel): + id: str + title: str + subject: str + grade: str + cycle: str + asset_count: int + assets: List[ProgramAsset] = [] + + +class ProgramLessonListResponse(BaseModel): + lessons: List[ProgramLesson] + + +class StudentProgramAssignRequest(BaseModel): + lesson_id: str = Field(..., min_length=1) + + +class StudentProgramResponse(BaseModel): + student: StudentRead + lesson: ProgramLesson | None = None + + class AssessmentQuestionResponse(BaseModel): skill_code: str skill_label: str diff --git a/backend/app/services.py b/backend/app/services.py index e765995..b632129 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -4,6 +4,7 @@ from openai import OpenAI from sqlalchemy.orm import Session from . import models from .curriculum import QUESTIONS, SKILLS +from .program_content import get_lesson client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) @@ -25,7 +26,7 @@ Consignes de rythme oral: - Fais des tours de parole courts: 1 a 3 phrases maximum, sauf demande explicite. - Termine souvent par une question ou une consigne courte pour laisser l'enfant parler. - Ne fais pas de longs monologues: une idee a la fois. -- Si l'eleve t'interrompt, arrete-toi, ecoute son message, puis reprends seulement si c'est utile. +- Si l’élève t’interrompt, arrête-toi, écoute son message, puis reprends seulement si c’est utile. - Si l'interruption semble accidentelle ou hors sujet, reponds tres brievement puis continue. """.strip() @@ -119,10 +120,17 @@ def get_student_context(db: Session, student_id: int) -> str: .all() ) + assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first() + lesson = get_lesson(assignment.lesson_id) if assignment else None + lines: List[str] = [ f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.", "Progression par compétence:", ] + if lesson: + lines.append( + f"Leçon programme sélectionnée par le professeur: {lesson['title']} ({lesson['subject']}, {lesson['grade']})." + ) for mastery_row, skill in mastery: lines.append( f"- {skill.label}: score={mastery_row.mastery_score:.1f}, confiance={mastery_row.confidence:.2f}, preuves={mastery_row.evidence_count}" @@ -188,7 +196,7 @@ def transcribe_audio(filename: str, audio_bytes: bytes, content_type: str | None file=file_payload, language="fr", prompt=( - "Transcris uniquement en francais. " + "Transcris uniquement en français. " "Le locuteur est un enfant qui repond a un exercice scolaire. " "Les phrases peuvent etre tres courtes, par exemple: nous chantons, je lis, 8 plus 5." ), diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5e3fab6..90ff71d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -146,7 +146,7 @@ function LoginPage({ onLogin }) {

Professeur TOP

-

Connexion eleve, professeur ou maintenance.

+

Connexion élève, professeur ou maintenance.

@@ -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 n’est 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 d’affecter 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 }) {

Bonjour

{selectedStudent?.first_name || currentUser.username}

-

{selectedStudent?.grade || 'Classe non renseignee'}

+

{selectedStudent?.grade || 'Classe non renseignée'}

- Derniere seance + Dernière séance

{lessonSummary}

- Derniere reponse + Dernière réponse

{lastStudentWork}

@@ -920,14 +966,14 @@ function TutorApp({ currentUser, onLogout }) { Avancement {lessonPercent}% -

{lessonFocus?.label || 'Programme a definir'}

+

{activeLessonTitle}

- Prochaine lecon conseillee + Prochaine leçon conseillée

{nextLessonAdvice}

@@ -940,15 +986,15 @@ function TutorApp({ currentUser, onLogout }) {
-

Lecon en cours

-

{lessonFocus?.label || 'Cours du jour'}

+

Leçon en cours

+

{activeLessonTitle}

@@ -1052,11 +1105,11 @@ function TutorApp({ currentUser, onLogout }) { return (
-
-
- +
+
-

Professeur virtuel

-

- {selectedStudent - ? `Séance active pour ${selectedStudent.first_name}, ${selectedStudent.grade}` - : 'Choisis ou crée un élève pour commencer.'} -

-
-
- {currentUser.username} · {currentUser.role} - -
-
- -
- Cours en cours - {currentInstruction ? ( -

{currentInstruction}

- ) : ( +

Gestion du programme

- 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.

- )} -
+
+ {currentUser.username} · {currentUser.role} +
-
- {messages.length === 0 &&

Le dialogue apparaîtra ici.

} - {messages.map((message) => ( -
- {message.role === 'assistant' ? 'Professeur TOP' : 'Élève'} -

{message.content}

+ {selectedStudent ? ( +
+
+ Élève sélectionné +

{selectedStudent.first_name}

+

{selectedStudent.grade} · {selectedStudent.age} ans

- ))} -
- {assessment && ( -
-

Mini-test · {assessment.skill_label}

-

{assessment.question}

- setAssessmentAnswer(event.target.value)} - placeholder="Ta réponse" - /> - -
+
+ Leçon active +

{studentProgram?.title || 'Aucune leçon'}

+

+ {studentProgram + ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)` + : 'Choisis une leçon dans la colonne de gauche.'} +

+
+
+ ) : ( +
+

Aucun élève sélectionné

+

Choisis un élève ou crée un compte pour configurer son programme.

+
)} -
- Transmis au prof -

{lastStudentTransmission || 'Le texte reconnu par le micro ou envoye au clavier apparaitra ici.'}

-
+ {studentProgram?.assets?.length > 0 && ( +
+

Fiches de la leçon

+
+ {studentProgram.assets.map((asset) => ( +
+ {asset.title} + {asset.title} +
+ ))} +
+
+ )} -
- - assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value) - } - placeholder={assessment ? 'Ta réponse au mini-test...' : 'Pose une question ou demande une explication...'} - /> - - {selectedVoiceProfile && ( - - i - - )} - - -
- - {isAutoListening && ( -
- Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}% -
- )} - {selectedVoiceProfile && ( -

- Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description} -

- )} - {voiceStatus &&

{voiceStatus}

} - {errorMessage &&

{errorMessage}

} - {DEBUG_AUDIO && ( -
- {audioDebug.length === 0 ? 'Debug audio: aucune trace' : `Debug audio: ${audioDebug.join(' | ')}`} -
- )} + {errorMessage &&

{errorMessage}

} ) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1b68ee0..8e5fd3c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;