Admin cockpit

This commit is contained in:
2026-05-06 18:45:23 +02:00
parent b1368980f9
commit 7c3e04cf06
2 changed files with 807 additions and 0 deletions

View File

@@ -296,6 +296,7 @@ function TutorApp({ currentUser, onLogout }) {
const [programTree, setProgramTree] = useState(null)
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [activeAdminTab, setActiveAdminTab] = useState('overview')
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
@@ -1682,6 +1683,452 @@ function TutorApp({ currentUser, onLogout }) {
)
}
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 densemble' },
{ id: 'students', label: 'Élèves' },
{ id: 'program', label: 'Programme global' },
{ id: 'validation', label: 'Validation contenus' },
{ id: 'factory', label: 'Atelier génération' },
{ id: 'settings', label: 'Paramètres globaux' },
]
return (
<div className="admin-console">
<header className="admin-console-header">
<div>
<p className="eyebrow">Poste de pilotage</p>
<h1>Professeur TOP</h1>
<p className="muted">Suivi des élèves, validation des contenus et pilotage du programme.</p>
</div>
<div className="admin-user-box">
<span>{currentUser.username} · {currentUser.role}</span>
<button type="button" className="secondary-button" onClick={onLogout}>Déconnexion</button>
</div>
</header>
<nav className="admin-tabs" aria-label="Navigation admin">
{adminTabs.map((tab) => (
<button
key={tab.id}
type="button"
className={activeAdminTab === tab.id ? 'active' : ''}
onClick={() => setActiveAdminTab(tab.id)}
>
{tab.label}
</button>
))}
</nav>
<main className="admin-workspace">
{activeAdminTab === 'overview' && (
<>
<section className="admin-metrics-grid">
<article className="admin-metric">
<span>Élèves actifs</span>
<strong>{students.length}</strong>
<p>{selectedStudent ? `${selectedStudent.first_name} sélectionné` : 'Aucun élève sélectionné'}</p>
</article>
<article className="admin-metric">
<span>Leçon active</span>
<strong>{studentProgram?.title || 'À affecter'}</strong>
<p>{studentProgram ? `${studentProgram.asset_count} fiche(s) · ${totalLessonCards} cards` : 'Passe par longlet Élèves'}</p>
</article>
<article className="admin-metric">
<span>Compréhension</span>
<strong>{averageComprehension}%</strong>
<p>{validatedCards} card(s) validée(s), {remainingCards} restante(s)</p>
</article>
<article className="admin-metric warning">
<span>File de revue</span>
<strong>{reviewQueue.length}</strong>
<p>Contenus à relire, modifier ou intégrer</p>
</article>
</section>
<section className="admin-dashboard-grid">
<div className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Priorités</span>
<h2>À traiter maintenant</h2>
</div>
</div>
<div className="admin-action-list">
<button type="button" onClick={() => setActiveAdminTab('validation')}>
Relire les SVG dexercices fractions
<span>Phase 6B attendue</span>
</button>
<button type="button" onClick={() => setActiveAdminTab('students')}>
Vérifier le programme de lélève
<span>{selectedStudent?.first_name || 'Choisir un élève'}</span>
</button>
<button type="button" onClick={() => setActiveAdminTab('factory')}>
Préparer les prochaines générations
<span>Kits adaptatifs et intégration</span>
</button>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Production</span>
<h2>État du programme</h2>
</div>
<button type="button" className="secondary-button" onClick={loadProgramTree}>Rafraîchir</button>
</div>
<div className="program-status-grid">
<span>Leçons: {programStatus?.lesson_count ?? 0}</span>
<span>CM1: {programStatus?.lessons_by_grade?.CM1 ?? 0}</span>
<span>Dossier: {programStatus?.content_root_exists === false ? 'absent' : 'OK'}</span>
</div>
</div>
</section>
</>
)}
{activeAdminTab === 'students' && (
<section className="admin-students-layout">
<aside className="admin-panel">
<div className="admin-panel-head">
<div>
<span className="stage-label">Élèves</span>
<h2>Sélection et création</h2>
</div>
</div>
<div className="student-roster">
{students.map((student) => (
<button
key={student.id}
type="button"
className={String(student.id) === String(selectedStudentId) ? 'active' : ''}
onClick={() => setSelectedStudentId(String(student.id))}
>
<strong>{student.first_name}</strong>
<span>{student.grade} · {student.age} ans</span>
</button>
))}
</div>
<form onSubmit={createStudent} className="student-create-form">
<h3>Créer un compte élève</h3>
<input placeholder="Prénom" value={form.first_name} onChange={(event) => setForm({ ...form, first_name: event.target.value })} />
<input type="number" min="8" max="12" value={form.age} onChange={(event) => setForm({ ...form, age: event.target.value })} />
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
<option>CE2</option>
<option>CM1</option>
<option>CM2</option>
<option>6e</option>
</select>
<input placeholder="Identifiant élève" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<input placeholder="Mot de passe élève" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
<button type="submit">Créer le compte</button>
</form>
</aside>
<section className="admin-panel student-report-panel">
{selectedStudent ? (
<>
<div className="admin-panel-head">
<div>
<span className="stage-label">Fiche élève</span>
<h2>{selectedStudent.first_name}</h2>
<p className="muted">{selectedStudent.grade} · {selectedStudent.age} ans</p>
</div>
</div>
<div className="student-report-grid">
<article>
<span>Leçon courante</span>
<strong>{studentProgram?.title || 'Aucune leçon'}</strong>
<p>{studentProgram ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)` : 'Affecte une leçon ci-dessous.'}</p>
</article>
<article>
<span>Résultats</span>
<strong>{averageComprehension}%</strong>
<p>{cardProgress.length} card(s) observée(s)</p>
</article>
<article>
<span>Contenu fait</span>
<strong>{validatedCards}</strong>
<p>{remainingCards} card(s) encore à travailler</p>
</article>
</div>
<div className="student-settings-grid">
<label>
<span>Leçon active</span>
<select value={studentProgram?.id || ''} onChange={(event) => assignProgramLesson(event.target.value)}>
<option value="">Choisir une leçon pour {selectedStudent.grade}</option>
{programLessons.map((lesson) => (
<option key={lesson.id} value={lesson.id}>{lesson.subject} · {lesson.title} · {lesson.asset_count} fiche(s)</option>
))}
</select>
</label>
{studentProgram?.assets?.length > 0 && (
<>
<label>
<span>Reprendre à la fiche</span>
<select value={programStart.asset_index} onChange={(event) => updateProgramStart(event.target.value, 0)}>
{studentProgram.assets.map((asset, index) => (
<option key={asset.path} value={index}>{index + 1}. {asset.title}</option>
))}
</select>
</label>
<label>
<span>Card de départ</span>
<select value={programStart.section_index} onChange={(event) => updateProgramStart(programStart.asset_index, event.target.value)}>
{(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
<option key={section.context_key || index} value={index}>{index + 1}. {section.title}</option>
))}
</select>
</label>
</>
)}
<label>
<span>Rythme pédagogique</span>
<select defaultValue="normal">
<option value="slow">Très guidé</option>
<option value="normal">Normal</option>
<option value="fast">Autonome</option>
</select>
</label>
<label className="wide-field">
<span>Instructions propres à lenfant</span>
<textarea placeholder="Exemple: privilégier les exemples concrets, reformuler les consignes longues, valoriser les réponses orales." />
</label>
</div>
<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">
{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>
)}
{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">

View File

@@ -28,6 +28,328 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
color: #14213d;
}
.admin-console {
height: 100vh;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 0.85rem;
padding: 1rem;
overflow: hidden;
background: #f5f7fb;
}
.admin-console-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.15rem;
border: 1px solid #e2e8f0;
border-radius: 18px;
background: white;
box-shadow: 0 10px 30px rgba(20,33,61,0.07);
}
.admin-console-header h1,
.admin-console-header p,
.admin-panel h2,
.admin-panel h3,
.admin-metric p,
.admin-metric span,
.admin-action-list button,
.student-report-grid p {
margin: 0;
}
.admin-user-box {
display: flex;
align-items: center;
gap: 0.75rem;
color: #64748b;
white-space: nowrap;
}
.admin-tabs {
display: flex;
gap: 0.45rem;
overflow-x: auto;
padding: 0.25rem;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: white;
}
.admin-tabs button {
flex: 0 0 auto;
background: transparent;
color: #475569;
border: 1px solid transparent;
border-radius: 12px;
padding: 0.7rem 0.9rem;
font-weight: 800;
}
.admin-tabs button.active {
color: #14213d;
background: #eff6ff;
border-color: #bfdbfe;
}
.admin-workspace {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-metrics-grid,
.admin-dashboard-grid,
.student-report-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.admin-dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-metric,
.admin-panel {
border: 1px solid #e2e8f0;
border-radius: 18px;
background: white;
box-shadow: 0 10px 30px rgba(20,33,61,0.06);
padding: 1rem;
}
.admin-metric {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 132px;
}
.admin-metric span,
.student-report-grid span {
color: #2563eb;
font-size: 0.78rem;
font-weight: 900;
text-transform: uppercase;
}
.admin-metric strong {
min-width: 0;
overflow: hidden;
color: #14213d;
font-size: 1.8rem;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-metric.warning {
background: #fff7ed;
border-color: #fed7aa;
}
.admin-panel,
.student-report-panel {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
}
.admin-panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.admin-action-list,
.student-roster,
.student-create-form,
.pipeline-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.admin-action-list button,
.student-roster button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #14213d;
text-align: left;
}
.admin-action-list button span,
.student-roster button span {
color: #64748b;
font-size: 0.9rem;
font-weight: 700;
}
.student-roster button.active {
border-color: #2563eb;
background: #eff6ff;
}
.admin-students-layout {
min-height: 0;
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 1rem;
}
.student-create-form {
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.student-report-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.student-report-grid article {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 120px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
padding: 0.85rem;
}
.student-report-grid strong {
min-width: 0;
overflow: hidden;
font-size: 1.35rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.student-settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.student-settings-grid.single {
grid-template-columns: 1fr;
}
.student-settings-grid label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-weight: 800;
}
.student-settings-grid .wide-field {
grid-column: 1 / -1;
}
.student-settings-grid textarea {
min-height: 120px;
resize: vertical;
border: 1px solid #cfd7e6;
border-radius: 12px;
padding: 0.8rem 1rem;
font: inherit;
}
.program-tab {
min-height: 72vh;
}
.validation-layout {
min-height: 0;
display: grid;
grid-template-columns: 300px minmax(0, 1fr) 320px;
gap: 1rem;
}
.review-queue,
.validation-inspector {
max-height: calc(100vh - 170px);
overflow: hidden;
}
.review-queue .program-tree-list {
overflow-y: auto;
}
.review-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.review-filters span,
.pipeline-list span {
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
padding: 0.45rem 0.65rem;
color: #475569;
font-size: 0.85rem;
font-weight: 800;
}
.validation-stage {
min-height: calc(100vh - 170px);
}
.validation-main-preview {
min-height: 0;
flex: 1;
display: grid;
place-items: center;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
overflow: hidden;
padding: 1rem;
}
.validation-main-preview img {
width: 100%;
height: 100%;
max-height: calc(100vh - 310px);
object-fit: contain;
display: block;
}
.empty-preview {
display: grid;
place-items: center;
min-height: 320px;
color: #64748b;
font-weight: 800;
}
.validation-inspector .admin-review-form {
flex: 1;
}
.program-status-grid.compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pipeline-list {
align-items: flex-start;
}
.layout {
display: grid;
grid-template-columns: 360px 1fr;
@@ -771,6 +1093,44 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
overflow: auto;
}
.admin-console {
height: auto;
min-height: 100dvh;
overflow: visible;
padding: 0.75rem;
}
.admin-console-header,
.admin-user-box {
align-items: stretch;
flex-direction: column;
}
.admin-workspace {
overflow: visible;
}
.admin-metrics-grid,
.admin-dashboard-grid,
.admin-students-layout,
.student-report-grid,
.student-settings-grid,
.validation-layout,
.admin-program-browser {
grid-template-columns: 1fr;
}
.review-queue,
.validation-inspector,
.validation-stage {
min-height: auto;
max-height: none;
}
.validation-main-preview img {
max-height: 70dvh;
}
.layout {
grid-template-columns: 1fr;
height: auto;