diff --git a/apps/web/src/app/practice/PracticeClient.tsx b/apps/web/src/app/practice/PracticeClient.tsx index ba9d996e..ef3ef76d 100644 --- a/apps/web/src/app/practice/PracticeClient.tsx +++ b/apps/web/src/app/practice/PracticeClient.tsx @@ -8,6 +8,7 @@ import { PendingApprovalsSection, SessionObserverModal, } from '@/components/classroom' +import { useClassroomSocket } from '@/hooks/useClassroomSocket' import { PageWithNav } from '@/components/PageWithNav' import { getAvailableViews, @@ -70,6 +71,10 @@ export function PracticeClient({ initialPlayers, viewerId, userId }: PracticeCli classroomId, } = useUnifiedStudents(initialPlayers) + // Real-time WebSocket updates for classroom events + // This invalidates React Query caches when students enter/leave, sessions start/end, etc. + useClassroomSocket(classroomId) + // View and filter state const availableViews = useMemo(() => getAvailableViews(isTeacher), [isTeacher]) const defaultView = useMemo(() => getDefaultView(isTeacher), [isTeacher]) diff --git a/apps/web/src/components/classroom/index.ts b/apps/web/src/components/classroom/index.ts index c88b468f..4d1a97c8 100644 --- a/apps/web/src/components/classroom/index.ts +++ b/apps/web/src/components/classroom/index.ts @@ -1,11 +1,13 @@ export { AddStudentByFamilyCodeModal } from './AddStudentByFamilyCodeModal' export { ClassroomCodeShare } from './ClassroomCodeShare' -export { ClassroomDashboard } from './ClassroomDashboard' -export { ClassroomTab } from './ClassroomTab' export { CreateClassroomForm } from './CreateClassroomForm' export { EnrollChildFlow } from './EnrollChildFlow' export { EnrollChildModal } from './EnrollChildModal' export { EnterClassroomButton } from './EnterClassroomButton' export { PendingApprovalsSection } from './PendingApprovalsSection' export { SessionObserverModal } from './SessionObserverModal' -export { StudentManagerTab } from './StudentManagerTab' + +// Deprecated - kept for reference but no longer exported: +// - ClassroomDashboard (replaced by unified view in PracticeClient) +// - ClassroomTab (functionality moved to unified student list) +// - StudentManagerTab (functionality moved to unified student list) diff --git a/apps/web/src/components/practice/NotesModal.tsx b/apps/web/src/components/practice/NotesModal.tsx index b54f2707..82b5a00e 100644 --- a/apps/web/src/components/practice/NotesModal.tsx +++ b/apps/web/src/components/practice/NotesModal.tsx @@ -2,6 +2,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { animated, useSpring } from '@react-spring/web' +import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { EnrollChildModal } from '@/components/classroom' import { FamilyCodeDisplay } from '@/components/family' @@ -113,12 +114,14 @@ function buildStudentActionData(student: StudentProp): StudentActionData { export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModalProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' + const router = useRouter() // ========== Internal state (notes editing only) ========== const [activeTab, setActiveTab] = useState('overview') const [isEditing, setIsEditing] = useState(false) const [editedNotes, setEditedNotes] = useState(student.notes ?? '') const [isSaving, setIsSaving] = useState(false) + const [isExpandingToFullscreen, setIsExpandingToFullscreen] = useState(false) const modalRef = useRef(null) const textareaRef = useRef(null) @@ -131,6 +134,12 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa const { data: curriculumData } = usePlayerCurriculumQuery(student.id) const updatePlayer = useUpdatePlayer() // For notes only + // ========== Navigation handler ========== + const handleOpenDashboard = useCallback(() => { + // Trigger expand-to-fullscreen animation, navigation happens onRest + setIsExpandingToFullscreen(true) + }, []) + // ========== Derived data ========== const relationship: StudentRelationship | null = student.relationship ?? null const activity: StudentActivity | null = student.activity ?? null @@ -162,6 +171,7 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa setEditedNotes(student.notes ?? '') setIsEditing(false) setActiveTab(defaultTab) + setIsExpandingToFullscreen(false) } }, [isOpen, student.id, student.notes, defaultTab]) @@ -201,8 +211,9 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 800 const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 600 - const modalWidth = Math.min(420, windowWidth - 40) - const modalHeight = 400 + // Responsive modal dimensions + const modalWidth = Math.min(420, windowWidth - 32) + const modalHeight = Math.min(400, windowHeight - 100) // Leave room for virtual keyboard on mobile const targetX = (windowWidth - modalWidth) / 2 const targetY = (windowHeight - modalHeight) / 2 @@ -211,6 +222,44 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa const sourceWidth = sourceBounds?.width ?? modalWidth const sourceHeight = sourceBounds?.height ?? modalHeight + // Determine animation target based on state + const getAnimationTarget = () => { + if (isExpandingToFullscreen) { + // Expand to fullscreen + return { + x: 0, + y: 0, + width: windowWidth, + height: windowHeight, + opacity: 1, + scale: 1, + borderRadius: 0, + } + } + if (isOpen) { + // Normal open state + return { + x: targetX, + y: targetY, + width: modalWidth, + height: modalHeight, + opacity: 1, + scale: 1, + borderRadius: 16, + } + } + // Closing - return to source + return { + x: sourceX, + y: sourceY, + width: sourceWidth, + height: sourceHeight, + opacity: 0, + scale: 0.95, + borderRadius: 16, + } + } + const springProps = useSpring({ from: { x: sourceX, @@ -219,17 +268,19 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa height: sourceHeight, opacity: 0, scale: 0.95, + borderRadius: 16, }, - to: { - x: isOpen ? targetX : sourceX, - y: isOpen ? targetY : sourceY, - width: isOpen ? modalWidth : sourceWidth, - height: isOpen ? modalHeight : sourceHeight, - opacity: isOpen ? 1 : 0, - scale: isOpen ? 1 : 0.95, - }, + to: getAnimationTarget(), reset: isOpening, - config: { tension: 300, friction: 30 }, + config: isExpandingToFullscreen + ? { tension: 400, friction: 30 } // Faster for fullscreen expand + : { tension: 300, friction: 30 }, + onRest: () => { + if (isExpandingToFullscreen) { + // Navigate after animation completes + router.push(`/practice/${student.id}/dashboard`) + } + }, }) const backdropSpring = useSpring({ @@ -292,17 +343,22 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa transform: springProps.scale.to((s) => `scale(${s})`), transformOrigin: 'center center', zIndex: Z_INDEX.MODAL, - pointerEvents: isOpen ? 'auto' : 'none', + pointerEvents: isOpen || isExpandingToFullscreen ? 'auto' : 'none', + borderRadius: springProps.borderRadius.to((r) => `${r}px`), }} className={css({ display: 'flex', flexDirection: 'column', - borderRadius: '16px', overflow: 'hidden', - boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', + boxShadow: isExpandingToFullscreen ? 'none' : '0 25px 50px -12px rgba(0, 0, 0, 0.5)', backgroundColor: isDark ? 'gray.800' : 'white', })} > + {/* Show dashboard shell during expand transition */} + {isExpandingToFullscreen ? ( + + ) : ( + <> {/* Header */}
+ {/* Open full dashboard button */} + + {/* Overflow menu - uses shared actions hook */} @@ -567,6 +648,8 @@ export function NotesModal({ isOpen, student, sourceBounds, onClose }: NotesModa /> )} + + )} {/* Sub-modals - managed by shared hook */} @@ -1053,3 +1136,156 @@ function separatorStyle(isDark: boolean) { margin: '4px 0', }) } + +// ============================================================================ +// Dashboard Shell Component (shown during expand transition) +// ============================================================================ + +interface DashboardShellProps { + student: { name: string; emoji: string; color: string } + isDark: boolean +} + +/** + * A skeleton shell that matches the dashboard layout. + * Shown during the expand-to-fullscreen transition for a seamless morph effect. + */ +function DashboardShell({ student, isDark }: DashboardShellProps) { + return ( +
+ {/* Nav bar shell */} +
+ {/* Back button placeholder */} +
+ {/* Avatar */} +
+ {student.emoji} +
+ {/* Name */} + + {student.name} + +
+ + {/* Sub-nav / tabs shell */} +
+ {['Overview', 'Skills', 'History'].map((tab, i) => ( +
+ {tab} +
+ ))} +
+ + {/* Content area with skeleton */} +
+ {/* Stats cards skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ + {/* Main content skeleton */} +
+
+
+ ) +} diff --git a/apps/web/src/components/practice/StudentFilterBar.tsx b/apps/web/src/components/practice/StudentFilterBar.tsx index 67cf39a3..52fa94bd 100644 --- a/apps/web/src/components/practice/StudentFilterBar.tsx +++ b/apps/web/src/components/practice/StudentFilterBar.tsx @@ -1,5 +1,6 @@ 'use client' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { useCallback, useEffect, useRef, useState } from 'react' import { useTheme } from '@/contexts/ThemeContext' import { Z_INDEX } from '@/constants/zIndex' @@ -497,51 +498,107 @@ export function StudentFilterBar({ )} - {/* Edit mode toggle button - always visible */} - + {/* More menu with edit mode and other options */} + {editMode ? ( + /* Done button when in edit mode */ + + ) : ( + /* More dropdown menu */ + + + + + + + onEditModeChange(true)} + className={css({ + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 12px', + borderRadius: '4px', + fontSize: '14px', + color: isDark ? 'gray.200' : 'gray.700', + cursor: 'pointer', + outline: 'none', + _hover: { + bg: isDark ? 'gray.700' : 'gray.100', + }, + _focus: { + bg: isDark ? 'gray.700' : 'gray.100', + }, + })} + > + ☑️ + Select Multiple + + + + + )} {/* Add Student FAB - only in normal mode */} {!editMode && onAddStudent && ( diff --git a/apps/web/src/components/practice/StudentSelector.tsx b/apps/web/src/components/practice/StudentSelector.tsx index 68da9152..30c40d32 100644 --- a/apps/web/src/components/practice/StudentSelector.tsx +++ b/apps/web/src/components/practice/StudentSelector.tsx @@ -1,7 +1,7 @@ 'use client' import * as Checkbox from '@radix-ui/react-checkbox' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTheme } from '@/contexts/ThemeContext' import type { Player } from '@/types/player' import { css } from '../../../styled-system/css' @@ -27,6 +27,78 @@ import { wrap, } from './styles' +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Find the next student in a given direction based on bounding rects. + * Used for arrow key navigation in the QuickLook modal. + */ +function findNextStudent( + currentId: string, + direction: 'up' | 'down' | 'left' | 'right', + cardRefs: Map +): { studentId: string; bounds: DOMRect } | null { + const currentRef = cardRefs.get(currentId) + if (!currentRef) return null + + const currentRect = currentRef.getBoundingClientRect() + const currentCenterX = currentRect.left + currentRect.width / 2 + const currentCenterY = currentRect.top + currentRect.height / 2 + + let bestCandidate: { studentId: string; bounds: DOMRect; distance: number } | null = null + + for (const [studentId, ref] of cardRefs) { + if (studentId === currentId) continue + + const rect = ref.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + + // Check if this card is in the correct direction + let isValidDirection = false + switch (direction) { + case 'left': + isValidDirection = centerX < currentCenterX - 10 + break + case 'right': + isValidDirection = centerX > currentCenterX + 10 + break + case 'up': + isValidDirection = centerY < currentCenterY - 10 + break + case 'down': + isValidDirection = centerY > currentCenterY + 10 + break + } + + if (!isValidDirection) continue + + // Calculate distance with preference for the primary direction + let distance: number + if (direction === 'left' || direction === 'right') { + // Horizontal: prioritize same row (small Y difference) + const yPenalty = Math.abs(centerY - currentCenterY) * 2 + distance = Math.abs(centerX - currentCenterX) + yPenalty + } else { + // Vertical: prioritize same column (small X difference) + const xPenalty = Math.abs(centerX - currentCenterX) * 2 + distance = Math.abs(centerY - currentCenterY) + xPenalty + } + + if (!bestCandidate || distance < bestCandidate.distance) { + bestCandidate = { studentId, bounds: rect, distance } + } + } + + return bestCandidate ? { studentId: bestCandidate.studentId, bounds: bestCandidate.bounds } : null +} + +// ============================================================================ +// Types +// ============================================================================ + /** * Intervention data for students needing attention */ @@ -59,6 +131,8 @@ interface StudentCardProps { isSelected?: boolean /** Callback when observe session is clicked */ onObserveSession?: (sessionId: string) => void + /** Callback to register card ref for keyboard navigation */ + onRegisterRef?: (studentId: string, ref: HTMLDivElement | null) => void } /** @@ -72,6 +146,7 @@ function StudentCard({ editMode, isSelected, onObserveSession, + onRegisterRef, }: StudentCardProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -79,6 +154,18 @@ function StudentCard({ const cardRef = useRef(null) const isArchived = student.isArchived ?? false + // Register ref for keyboard navigation + useEffect(() => { + if (onRegisterRef && cardRef.current) { + onRegisterRef(student.id, cardRef.current) + } + return () => { + if (onRegisterRef) { + onRegisterRef(student.id, null) + } + } + }, [student.id, onRegisterRef]) + // Check for unified student fields (relationship/activity) at runtime // These are present when using the unified student list const relationship = ( @@ -623,6 +710,27 @@ export function StudentSelector({ const [selectedStudent, setSelectedStudent] = useState(null) const [sourceBounds, setSourceBounds] = useState(null) + // Ref map for keyboard navigation + const cardRefsRef = useRef>(new Map()) + + // Student lookup for quick access by ID + const studentMap = useRef>(new Map()) + useEffect(() => { + studentMap.current.clear() + for (const student of students) { + studentMap.current.set(student.id, student) + } + }, [students]) + + // Register card ref callback + const handleRegisterRef = useCallback((studentId: string, ref: HTMLDivElement | null) => { + if (ref) { + cardRefsRef.current.set(studentId, ref) + } else { + cardRefsRef.current.delete(studentId) + } + }, []) + const handleOpenQuickLook = useCallback((student: StudentWithProgress, bounds: DOMRect) => { setSelectedStudent(student) setSourceBounds(bounds) @@ -633,6 +741,54 @@ export function StudentSelector({ setModalOpen(false) }, []) + // Keyboard navigation when modal is open + useEffect(() => { + if (!modalOpen || !selectedStudent) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle arrow keys + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return + + // Don't interfere with text input (notes editing) + if ( + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLInputElement + ) { + return + } + + // Don't interfere with dropdown menu navigation (Radix UI) + if ( + e.target instanceof HTMLElement && + (e.target.closest('[data-radix-menu-content]') || + e.target.getAttribute('role') === 'menuitem' || + e.target.getAttribute('role') === 'menu') + ) { + return + } + + e.preventDefault() + + const direction = e.key.replace('Arrow', '').toLowerCase() as + | 'up' + | 'down' + | 'left' + | 'right' + + const next = findNextStudent(selectedStudent.id, direction, cardRefsRef.current) + if (next) { + const nextStudent = studentMap.current.get(next.studentId) + if (nextStudent) { + setSelectedStudent(nextStudent) + setSourceBounds(next.bounds) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [modalOpen, selectedStudent]) + return ( <>
))} diff --git a/apps/web/src/components/practice/ViewSelector.tsx b/apps/web/src/components/practice/ViewSelector.tsx index 888fa9f7..66cb7259 100644 --- a/apps/web/src/components/practice/ViewSelector.tsx +++ b/apps/web/src/components/practice/ViewSelector.tsx @@ -73,8 +73,19 @@ export function ViewSelector({ className={css({ display: 'flex', gap: '8px', - flexWrap: 'wrap', alignItems: 'center', + // Mobile: horizontal scroll, Desktop: wrap + overflowX: 'auto', + overflowY: 'hidden', + flexWrap: { base: 'nowrap', md: 'wrap' }, + // Hide scrollbar but allow scroll + scrollbarWidth: 'none', + '&::-webkit-scrollbar': { display: 'none' }, + // Prevent text selection while swiping + WebkitUserSelect: 'none', + userSelect: 'none', + // Add padding for mobile so chips don't touch edge when scrolling + paddingRight: { base: '8px', md: 0 }, })} > {availableViews.map((viewId) => { @@ -102,6 +113,9 @@ export function ViewSelector({ fontSize: '13px', fontWeight: 'medium', transition: 'all 0.15s ease', + // Don't shrink on mobile scroll + flexShrink: 0, + whiteSpace: 'nowrap', // Active state bg: isActive ? (isDark ? 'blue.900' : 'blue.100') : isDark ? 'gray.800' : 'white', borderColor: isActive diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index 0e412bbf..edca8c8a 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -17,6 +17,8 @@ export { useIsTouchDevice, } from './hooks/useDeviceDetection' export { NotesModal } from './NotesModal' +// StudentQuickLook is an alias for NotesModal (which was enhanced to serve as the QuickLook modal) +export { NotesModal as StudentQuickLook } from './NotesModal' export { NumericKeypad } from './NumericKeypad' export { PracticeErrorBoundary } from './PracticeErrorBoundary' export { PracticeFeedback } from './PracticeFeedback'