admin mode
This commit is contained in:
20
MEMORY.md
20
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
138
backend/app/program_content.py
Normal file
138
backend/app/program_content.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
@@ -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 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 }) {
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user