Merge pull request #13 from antialias/codex/add-full-screen-toggle-for-observation-modal

This commit is contained in:
Thomas Hallock
2025-12-28 14:59:06 -06:00
committed by GitHub
4 changed files with 532 additions and 318 deletions

View File

@@ -0,0 +1,63 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { SessionObserverView } from '@/components/classroom'
import { PageWithNav } from '@/components/PageWithNav'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { css } from '../../../../styled-system/css'
interface ObservationClientProps {
session: ActiveSessionInfo
observerId: string
student: {
name: string
emoji: string
color: string
}
studentId: string
}
export function ObservationClient({ session, observerId, student, studentId }: ObservationClientProps) {
const router = useRouter()
const handleExit = useCallback(() => {
router.push(`/practice/${studentId}/dashboard`, { scroll: false })
}, [router, studentId])
return (
<PageWithNav navTitle={`Observing ${student.name}`} navEmoji={student.emoji}>
<main
data-component="practice-observation-page"
className={css({
minHeight: '100vh',
backgroundColor: 'gray.50',
_dark: { backgroundColor: 'gray.900' },
display: 'flex',
justifyContent: 'center',
padding: { base: '16px', md: '24px' },
})}
>
<div
className={css({
width: '100%',
maxWidth: '960px',
borderRadius: '16px',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.25)',
border: '1px solid',
borderColor: { base: 'rgba(0,0,0,0.05)', _dark: 'rgba(255,255,255,0.08)' },
})}
>
<SessionObserverView
session={session}
student={student}
observerId={observerId}
onClose={handleExit}
variant="page"
/>
</div>
</main>
</PageWithNav>
)
}

View File

@@ -0,0 +1,65 @@
import { notFound, redirect } from 'next/navigation'
import { canPerformAction } from '@/lib/classroom'
import { getActiveSessionPlan, getPlayer } from '@/lib/curriculum/server'
import type { ActiveSessionInfo } from '@/hooks/useClassroom'
import { getDbUserId } from '@/lib/viewer'
import { ObservationClient } from './ObservationClient'
export const dynamic = 'force-dynamic'
interface ObservationPageProps {
params: Promise<{ studentId: string }>
}
export default async function PracticeObservationPage({ params }: ObservationPageProps) {
const { studentId } = await params
const [observerId, player, activeSession] = await Promise.all([
getDbUserId(),
getPlayer(studentId),
getActiveSessionPlan(studentId),
])
if (!player) {
notFound()
}
const canObserve = await canPerformAction(observerId, studentId, 'observe')
if (!canObserve) {
notFound()
}
if (!activeSession || !activeSession.startedAt || activeSession.completedAt) {
redirect(`/practice/${studentId}/dashboard`)
}
const totalProblems = activeSession.parts.reduce((sum, part) => sum + part.slots.length, 0)
let completedProblems = 0
for (let i = 0; i < activeSession.currentPartIndex; i++) {
completedProblems += activeSession.parts[i]?.slots.length ?? 0
}
completedProblems += activeSession.currentSlotIndex
const session: ActiveSessionInfo = {
sessionId: activeSession.id,
playerId: activeSession.playerId,
startedAt: activeSession.startedAt as string,
currentPartIndex: activeSession.currentPartIndex,
currentSlotIndex: activeSession.currentSlotIndex,
totalParts: activeSession.parts.length,
totalProblems,
completedProblems,
}
return (
<ObservationClient
session={session}
observerId={observerId}
student={{
name: player.name,
emoji: player.emoji,
color: player.color,
}}
studentId={studentId}
/>
)
}

View File

@@ -1,7 +1,8 @@
'use client'
import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
@@ -30,6 +31,16 @@ interface SessionObserverModalProps {
observerId: string
}
interface SessionObserverViewProps {
session: ActiveSessionInfo
student: SessionObserverModalProps['student']
observerId: string
onClose?: () => void
onRequestFullscreen?: () => void
renderCloseButton?: (button: ReactElement) => ReactElement
variant?: 'modal' | 'page'
}
/**
* Modal for teachers to observe a student's practice session in real-time
*
@@ -47,18 +58,79 @@ export function SessionObserverModal({
student,
observerId,
}: SessionObserverModalProps) {
const router = useRouter()
const handleFullscreen = useCallback(() => {
router.push(`/practice/${session.playerId}/observe`, { scroll: false })
}, [router, session.playerId])
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
data-element="observer-modal-overlay"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.NESTED_MODAL_BACKDROP,
})}
/>
<Dialog.Content
data-component="session-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '800px',
maxHeight: '85vh',
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.NESTED_MODAL,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
outline: 'none',
})}
>
<SessionObserverView
session={session}
student={student}
observerId={observerId}
onClose={onClose}
onRequestFullscreen={handleFullscreen}
renderCloseButton={(button) => <Dialog.Close asChild>{button}</Dialog.Close>}
variant="modal"
/>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
export function SessionObserverView({
session,
student,
observerId,
onClose,
onRequestFullscreen,
renderCloseButton,
variant = 'modal',
}: SessionObserverViewProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus()
// Subscribe to the session's socket channel
const { state, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
useSessionObserver(
isOpen ? session.sessionId : undefined,
isOpen ? observerId : undefined,
isOpen ? session.playerId : undefined,
isOpen
)
useSessionObserver(session.sessionId, observerId, session.playerId, true)
// Track if we've paused the session (teacher controls resume)
const [hasPausedSession, setHasPausedSession] = useState(false)
@@ -131,337 +203,351 @@ export function SessionObserverModal({
? String(Math.abs(state.currentProblem.answer)).length
: 3
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay
data-element="observer-modal-overlay"
className={css({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.NESTED_MODAL_BACKDROP,
})}
/>
const defaultCloseButton = (
<button
type="button"
data-action="close-observer"
onClick={onClose}
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
})}
>
Close
</button>
)
<Dialog.Content
data-component="session-observer-modal"
className={css({
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '800px',
maxHeight: '85vh',
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.NESTED_MODAL,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
outline: 'none',
})}
>
{/* Header */}
<div
const closeButton = renderCloseButton ? renderCloseButton(defaultCloseButton) : defaultCloseButton
return (
<div
data-component="session-observer-view"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
backgroundColor: variant === 'page' ? (isDark ? 'gray.900' : 'white') : undefined,
})}
>
{/* Header */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
gap: '12px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0 })}>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
justifyContent: 'center',
fontSize: '1.25rem',
flexShrink: 0,
})}
style={{ backgroundColor: student.color }}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '12px' })}>
<span
className={css({
width: '40px',
height: '40px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.25rem',
})}
style={{ backgroundColor: student.color }}
>
{student.emoji}
</span>
<div>
<Dialog.Title
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '1rem',
margin: 0,
})}
>
Observing {student.name}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
margin: 0,
})}
>
Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '}
{state?.totalProblems ?? session.totalProblems}
</Dialog.Description>
</div>
</div>
<Dialog.Close asChild>
<button
type="button"
data-action="close-observer"
className={css({
padding: '8px 16px',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.700',
border: 'none',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'gray.600' : 'gray.300' },
})}
>
Close
</button>
</Dialog.Close>
{student.emoji}
</span>
<div className={css({ minWidth: 0 })}>
<Dialog.Title
className={css({
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
fontSize: '1rem',
margin: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
Observing {student.name}
</Dialog.Title>
<Dialog.Description
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.400' : 'gray.500',
margin: 0,
})}
>
Problem {state?.currentProblemNumber ?? session.completedProblems + 1} of{' '}
{state?.totalProblems ?? session.totalProblems}
</Dialog.Description>
</div>
</div>
{/* Content */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 })}>
{onRequestFullscreen && (
<button
type="button"
data-action="fullscreen-observer"
onClick={onRequestFullscreen}
className={css({
width: '32px',
height: '32px',
borderRadius: '8px',
border: 'none',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
_hover: { backgroundColor: 'rgba(255, 255, 255, 0.3)' },
})}
title="Open full-screen observation"
>
</button>
)}
{closeButton}
</div>
</div>
{/* Content */}
<div
className={css({
flex: 1,
padding: variant === 'page' ? '28px' : '24px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
backgroundColor: variant === 'page' ? (isDark ? 'gray.900' : 'white') : undefined,
})}
>
{/* Connection status */}
{!isConnected && !error && (
<div
className={css({
flex: 1,
padding: '24px',
overflowY: 'auto',
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>Connecting...</p>
</div>
)}
{error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'red.400' : 'red.600',
padding: '16px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '8px',
})}
>
<p>{error}</p>
</div>
)}
{isObserving && !state && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>
Waiting for student activity...
</p>
<p className={css({ fontSize: '0.875rem' })}>
You&apos;ll see their problem when they start working
</p>
</div>
)}
{/* Problem display with abacus dock - matches ActiveSession layout */}
{state && (
<div
data-element="observer-content"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
gap: '16px',
})}
>
{/* Connection status */}
{!isConnected && !error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>Connecting...</p>
{/* Purpose badge with tooltip - matches student's view */}
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
{/* Problem container with absolutely positioned AbacusDock */}
<div
data-element="problem-with-dock"
className={css({
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
})}
>
{/* Problem - ref for height measurement */}
<div ref={problemRef}>
<VerticalProblem
terms={state.currentProblem.terms}
userAnswer={state.studentAnswer}
isFocused={state.phase === 'problem'}
isCompleted={state.phase === 'feedback'}
correctAnswer={state.currentProblem.answer}
size="large"
/>
</div>
)}
{error && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'red.400' : 'red.600',
padding: '16px',
backgroundColor: isDark ? 'red.900/30' : 'red.50',
borderRadius: '8px',
})}
>
<p>{error}</p>
</div>
)}
{isObserving && !state && (
<div
className={css({
textAlign: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<p className={css({ fontSize: '1rem', marginBottom: '8px' })}>
Waiting for student activity...
</p>
<p className={css({ fontSize: '0.875rem' })}>
You&apos;ll see their problem when they start working
</p>
</div>
)}
{/* Problem display with abacus dock - matches ActiveSession layout */}
{state && (
<div
data-element="observer-content"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
})}
>
{/* Purpose badge with tooltip - matches student's view */}
<PurposeBadge purpose={state.purpose} complexity={state.complexity} />
{/* Problem container with absolutely positioned AbacusDock */}
<div
data-element="problem-with-dock"
{/* AbacusDock - positioned exactly like ActiveSession */}
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="teacher-observer-dock"
columns={abacusColumns}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleTeacherAbacusChange}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'flex-start',
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
>
{/* Problem - ref for height measurement */}
<div ref={problemRef}>
<VerticalProblem
terms={state.currentProblem.terms}
userAnswer={state.studentAnswer}
isFocused={state.phase === 'problem'}
isCompleted={state.phase === 'feedback'}
correctAnswer={state.currentProblem.answer}
size="large"
/>
</div>
style={{ height: problemHeight ?? undefined }}
/>
)}
</div>
{/* AbacusDock - positioned exactly like ActiveSession */}
{state.phase === 'problem' && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="teacher-observer-dock"
columns={abacusColumns}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleTeacherAbacusChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight ?? undefined }}
/>
)}
</div>
{/* Feedback message */}
{state.studentAnswer && state.phase === 'feedback' && (
<PracticeFeedback
isCorrect={state.isCorrect ?? false}
correctAnswer={state.currentProblem.answer}
/>
)}
</div>
)}
</div>
{/* Footer with connection status and controls */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
{/* Connection status */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '6px' })}>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
})}
style={{
backgroundColor: isObserving ? '#10b981' : isConnected ? '#eab308' : '#6b7280',
}}
{/* Feedback message */}
{state.studentAnswer && state.phase === 'feedback' && (
<PracticeFeedback
isCorrect={state.isCorrect ?? false}
correctAnswer={state.currentProblem.answer}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isObserving ? 'Live' : isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Teacher controls: pause/resume and dock abaci */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Pause/Resume button */}
{isObserving && (
<button
type="button"
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
className={css({
padding: '8px 12px',
backgroundColor: hasPausedSession
? isDark
? 'green.700'
: 'green.100'
: isDark
? 'amber.700'
: 'amber.100',
color: hasPausedSession
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'amber.200'
: 'amber.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: hasPausedSession
? isDark
? 'green.600'
: 'green.200'
: isDark
? 'amber.600'
: 'amber.200',
},
})}
>
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
</button>
)}
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
</div>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
</div>
{/* Footer with connection status and controls */}
<div
className={css({
padding: '12px 20px',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
})}
>
{/* Connection status */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '6px' })}>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
})}
style={{
backgroundColor: isObserving ? '#10b981' : isConnected ? '#eab308' : '#6b7280',
}}
/>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{isObserving ? 'Live' : isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Teacher controls: pause/resume and dock abaci */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Pause/Resume button */}
{isObserving && (
<button
type="button"
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
className={css({
padding: '8px 12px',
backgroundColor: hasPausedSession
? isDark
? 'green.700'
: 'green.100'
: isDark
? 'amber.700'
: 'amber.100',
color: hasPausedSession
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'amber.200'
: 'amber.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: hasPausedSession
? isDark
? 'green.600'
: 'green.200'
: isDark
? 'amber.600'
: 'amber.200',
},
})}
>
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
</button>
)}
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -5,5 +5,5 @@ export { EnrollChildFlow } from './EnrollChildFlow'
export { EnrollChildModal } from './EnrollChildModal'
export { EnterClassroomButton } from './EnterClassroomButton'
export { PendingApprovalsSection } from './PendingApprovalsSection'
export { SessionObserverModal } from './SessionObserverModal'
export { SessionObserverModal, SessionObserverView } from './SessionObserverModal'
export { TeacherEnrollmentSection } from './TeacherEnrollmentSection'