Merge pull request #13 from antialias/codex/add-full-screen-toggle-for-observation-modal
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
65
apps/web/src/app/practice/[studentId]/observe/page.tsx
Normal file
65
apps/web/src/app/practice/[studentId]/observe/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user