diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 75ec53f..9aa695b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -101,7 +101,25 @@ function flattenProgramTree(node, depth = 0, rows = []) {
return rows
}
-function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview }) {
+function flattenVisibleProgramTree(node, collapsedKeys, depth = 0, rows = []) {
+ if (!node) return rows
+ rows.push({ node, depth })
+ if (!collapsedKeys.has(node.key)) {
+ ;(node.children || []).forEach((child) => flattenVisibleProgramTree(child, collapsedKeys, depth + 1, rows))
+ }
+ return rows
+}
+
+function collectCollapsibleProgramKeys(node, depth = 0, keys = []) {
+ if (!node) return keys
+ if (depth > 0 && node.children?.length) keys.push(node.key)
+ ;(node.children || []).forEach((child) => collectCollapsibleProgramKeys(child, depth + 1, keys))
+ return keys
+}
+
+function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveReview, readOnly = false, onOpenValidation }) {
+ const [isFullscreen, setIsFullscreen] = useState(false)
+
if (!selectedNode) {
return (
@@ -136,9 +154,10 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
)}
{selectedNode.url && (
-
+
+ {readOnly ? 'Cliquer pour inspecter en plein écran' : 'Cliquer pour valider en plein écran'}
+
)}
{review.comment && (
@@ -149,30 +168,95 @@ function ProgramReviewPanel({ selectedNode, reviewForm, onReviewChange, onSaveRe
)}
-
+ {readOnly ? (
+
+
Validation centralisée
+
Cet onglet sert à explorer le programme. Les décisions éditoriales se font dans l’onglet Validation contenus.
+
+
+ ) : (
+
+ )}
+
+ {isFullscreen && (
+
+
+
+
+
{selectedNode.type}
+
{selectedNode.label}
+
{selectedNode.path || selectedNode.key}
+
+
+
+
+

+
+
+
+
+
+ )}
)
}
@@ -297,6 +381,7 @@ function TutorApp({ currentUser, onLogout }) {
const [selectedProgramNodeKey, setSelectedProgramNodeKey] = useState('')
const [programReviewForm, setProgramReviewForm] = useState({ status: 'needs_changes', comment: '' })
const [activeAdminTab, setActiveAdminTab] = useState('overview')
+ const [collapsedProgramKeys, setCollapsedProgramKeys] = useState(() => new Set())
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
@@ -353,6 +438,10 @@ function TutorApp({ currentUser, onLogout }) {
const lessonPercent = lessonFocus ? Math.round(lessonFocus.mastery_score) : averageScore
const programRows = useMemo(() => flattenProgramTree(programTree).filter((row) => row.node.type !== 'root'), [programTree])
+ const visibleProgramRows = useMemo(
+ () => flattenVisibleProgramTree(programTree, collapsedProgramKeys).filter((row) => row.node.type !== 'root'),
+ [programTree, collapsedProgramKeys]
+ )
const selectedProgramNode = useMemo(
() => programRows.find((row) => row.node.key === selectedProgramNodeKey)?.node || null,
[programRows, selectedProgramNodeKey]
@@ -395,6 +484,20 @@ function TutorApp({ currentUser, onLogout }) {
setActiveSectionIndex(sectionIndex)
}
+ function toggleProgramNode(node) {
+ if (!node?.children?.length) {
+ setSelectedProgramNodeKey(node?.key || '')
+ return
+ }
+ setSelectedProgramNodeKey(node.key)
+ setCollapsedProgramKeys((current) => {
+ const next = new Set(current)
+ if (next.has(node.key)) next.delete(node.key)
+ else next.add(node.key)
+ return next
+ })
+ }
+
useEffect(() => {
isRecordingRef.current = isRecording
}, [isRecording])
@@ -759,6 +862,7 @@ function TutorApp({ currentUser, onLogout }) {
const data = await apiFetch('/admin/program/tree')
setProgramTree(data)
setSelectedProgramNodeKey((current) => current || data.children?.[0]?.key || '')
+ setCollapsedProgramKeys(new Set(collectCollapsibleProgramKeys(data)))
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger l’arborescence du programme.')
}
@@ -1987,16 +2091,26 @@ function TutorApp({ currentUser, onLogout }) {
- {programRows.map(({ node, depth }) => (
-
-
+ setActiveAdminTab('validation')}
+ />
)}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 6b0e829..5e012ad 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -350,6 +350,67 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
align-items: flex-start;
}
+.review-fullscreen {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 360px;
+ gap: 1rem;
+ padding: 1rem;
+ background: rgba(248, 250, 252, 0.98);
+}
+
+.review-fullscreen-stage,
+.review-fullscreen-panel {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ border: 1px solid #dbe3ef;
+ border-radius: 18px;
+ background: white;
+ box-shadow: 0 18px 50px rgba(15, 23, 42, 0.16);
+ padding: 1rem;
+}
+
+.review-fullscreen-head {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.review-fullscreen-head h2,
+.review-fullscreen-head p,
+.review-fullscreen-panel h2 {
+ margin: 0;
+}
+
+.review-fullscreen-image {
+ min-height: 0;
+ flex: 1;
+ display: grid;
+ place-items: center;
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ background: #f8fafc;
+ overflow: hidden;
+ padding: 0.5rem;
+}
+
+.review-fullscreen-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+}
+
+.review-fullscreen-panel .admin-review-form {
+ flex: 1;
+}
+
.layout {
display: grid;
grid-template-columns: 360px 1fr;
@@ -540,6 +601,23 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
text-overflow: ellipsis;
white-space: nowrap;
}
+.program-tree-row .tree-label {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+.program-tree-row .tree-label b {
+ display: grid;
+ place-items: center;
+ width: 1.15rem;
+ min-width: 1.15rem;
+ height: 1.15rem;
+ border-radius: 6px;
+ background: #e2e8f0;
+ color: #14213d;
+ font-size: 0.85rem;
+ line-height: 1;
+}
.program-tree-row em,
.program-tree-row strong,
.review-pill {
@@ -578,13 +656,16 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
padding: 0.65rem;
}
.program-node-preview {
- height: min(42vh, 380px);
- min-height: 280px;
+ position: relative;
+ width: 100%;
+ height: 340px;
+ min-height: 0;
display: grid;
place-items: center;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: white;
+ color: #14213d;
overflow: hidden;
padding: 0.75rem;
}
@@ -595,6 +676,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
object-fit: contain;
display: block;
}
+.program-node-preview span {
+ position: absolute;
+ right: 0.75rem;
+ bottom: 0.75rem;
+ border-radius: 999px;
+ background: rgba(20, 33, 61, 0.82);
+ color: white;
+ padding: 0.45rem 0.7rem;
+ font-size: 0.82rem;
+ font-weight: 800;
+}
.admin-review-note p {
margin: 0.35rem 0;
line-height: 1.45;