feat(classroom): integrate Enter Classroom into StudentActionMenu

- Add classroom data (enrolled, current presence) to useStudentActions hook
- Add enterSpecificClassroom handler for entering a specific classroom
- Replace simple enter/leave actions with smart classroom section:
  - In classroom: shows presence indicator with leave option
  - 1 enrollment: direct enter action
  - Multiple enrollments: Radix submenu with classroom list
  - Always shows enroll option
- Remove EnterClassroomButton from dashboard (now handled by StudentActionMenu)
- Fix EnrollChildModal z-index to use same value as overlay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-27 12:14:30 -06:00
parent bf262e7d53
commit 2f1b9df9d9
4 changed files with 171 additions and 40 deletions

View File

@@ -3,7 +3,6 @@
import { useQuery } from '@tanstack/react-query'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { EnterClassroomButton } from '@/components/classroom'
import { useEnrolledClassrooms, useMyClassroom } from '@/hooks/useClassroom'
import { PageWithNav } from '@/components/PageWithNav'
import { useIncomingTransition } from '@/contexts/PageTransitionContext'
@@ -2787,7 +2786,6 @@ export function DashboardClient({
})}
>
<StudentActionMenu student={studentActionData} variant="inline" />
<EnterClassroomButton playerId={studentId} playerName={player.name} />
</div>
{/* Session mode banner - renders in-flow, projects to nav on scroll */}

View File

@@ -145,7 +145,7 @@ export function EnrollChildModal({ isOpen, onClose, playerId, playerName }: Enro
width: 'calc(100% - 2rem)',
maxWidth: '420px',
boxShadow: '0 20px 50px -12px rgba(0, 0, 0, 0.4)',
zIndex: Z_INDEX.TOOLTIP + 1, // Above the overlay
zIndex: Z_INDEX.TOOLTIP, // Same as overlay - siblings in same stacking context
outline: 'none',
transition: 'opacity 0.2s ease-out, transform 0.2s ease-out',
opacity: isClosing ? 0 : 1,

View File

@@ -40,7 +40,7 @@ export function StudentActionMenu({
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { actions, handlers, modals } = useStudentActions(student, { onObserveSession })
const { actions, handlers, modals, classrooms } = useStudentActions(student, { onObserveSession })
// If no actions are available, don't render the menu
const hasAnyAction =
@@ -136,37 +136,104 @@ export function StudentActionMenu({
</DropdownMenu.Item>
)}
{/* Classroom presence actions */}
{actions.enterClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.enterClassroom}
>
<span>{ACTION_DEFINITIONS.enterClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enterClassroom.label}</span>
</DropdownMenu.Item>
{/* Classroom section */}
{(classrooms.enrolled.length > 0 || classrooms.current) && (
<>
<DropdownMenu.Separator className={separatorStyles(isDark)} />
{/* If in a classroom, show presence + leave */}
{classrooms.current && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.leaveClassroom}
data-action="leave-classroom"
>
<span
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'green.500',
})}
/>
<span>In {classrooms.current.classroom.name} Leave</span>
</DropdownMenu.Item>
)}
{/* If not in classroom and has exactly 1 enrollment: direct action */}
{!classrooms.current && classrooms.enrolled.length === 1 && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.enterClassroom}
data-action="enter-classroom"
>
<span>🏫</span>
<span>Enter {classrooms.enrolled[0].name}</span>
</DropdownMenu.Item>
)}
{/* If not in classroom and has multiple enrollments: use submenu */}
{!classrooms.current && classrooms.enrolled.length > 1 && (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className={subTriggerStyles(isDark)}>
<span>🏫</span>
<span>Enter Classroom</span>
<span className={css({ marginLeft: 'auto' })}></span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className={css({
minWidth: '160px',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '4px',
boxShadow: 'lg',
zIndex: Z_INDEX.DROPDOWN + 1,
})}
sideOffset={4}
>
{classrooms.enrolled.map((c) => (
<DropdownMenu.Item
key={c.id}
className={menuItemStyles(isDark)}
onSelect={() => handlers.enterSpecificClassroom(c.id)}
data-action="enter-specific-classroom"
>
{c.name}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)}
{/* Always show enroll option */}
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span></span>
<span>Enroll in Classroom</span>
</DropdownMenu.Item>
</>
)}
{actions.leaveClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.leaveClassroom}
>
<span>{ACTION_DEFINITIONS.leaveClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.leaveClassroom.label}</span>
</DropdownMenu.Item>
)}
{/* Enrollment actions */}
{actions.enrollInClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
>
<span>{ACTION_DEFINITIONS.enrollInClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enrollInClassroom.label}</span>
</DropdownMenu.Item>
)}
{/* Show enroll option even if no enrollments yet */}
{classrooms.enrolled.length === 0 &&
!classrooms.current &&
actions.enrollInClassroom && (
<DropdownMenu.Item
className={menuItemStyles(isDark)}
onSelect={handlers.openEnrollModal}
data-action="enroll-in-classroom"
>
<span>{ACTION_DEFINITIONS.enrollInClassroom.icon}</span>
<span>{ACTION_DEFINITIONS.enrollInClassroom.label}</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator className={separatorStyles(isDark)} />
@@ -264,3 +331,27 @@ function separatorStyles(isDark: boolean) {
margin: '4px 0',
})
}
function subTriggerStyles(isDark: boolean) {
return css({
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '13px',
cursor: 'pointer',
outline: 'none',
color: isDark ? 'gray.200' : 'gray.700',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_focus: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
// SubTrigger specific: highlight when open
'&[data-state="open"]': {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
})
}

View File

@@ -7,7 +7,9 @@ import {
useEnterClassroom,
useLeaveClassroom,
useMyClassroom,
useStudentPresence,
} from '@/hooks/useClassroom'
import type { Classroom } from '@/db/schema'
import { useUpdatePlayer } from '@/hooks/useUserPlayers'
import {
getAvailableActions,
@@ -21,6 +23,7 @@ export interface StudentActionHandlers {
startPractice: () => void
watchSession: () => void
enterClassroom: () => Promise<void>
enterSpecificClassroom: (classroomId: string) => Promise<void>
leaveClassroom: () => Promise<void>
toggleArchive: () => Promise<void>
openShareAccess: () => void
@@ -40,6 +43,15 @@ export interface StudentActionModals {
}
}
export interface ClassroomData {
/** All classrooms this student is enrolled in */
enrolled: Classroom[]
/** Current classroom presence (if any) */
current: { classroomId: string; classroom: Classroom } | null
/** Whether classroom data is loading */
isLoading: boolean
}
export interface UseStudentActionsResult {
/** Which actions are available based on student state and user context */
actions: AvailableActions
@@ -51,6 +63,8 @@ export interface UseStudentActionsResult {
isLoading: boolean
/** The student data being operated on */
student: StudentActionData
/** Classroom enrollment and presence data */
classrooms: ClassroomData
}
/**
@@ -79,7 +93,10 @@ export function useStudentActions(
const isTeacher = !!classroom
// ========== Action hooks ==========
const { data: enrolledClassrooms = [] } = useEnrolledClassrooms(student.id)
const { data: enrolledClassrooms = [], isLoading: loadingEnrollments } = useEnrolledClassrooms(
student.id
)
const { data: currentPresence, isLoading: loadingPresence } = useStudentPresence(student.id)
const updatePlayer = useUpdatePlayer()
const enterClassroom = useEnterClassroom()
const leaveClassroom = useLeaveClassroom()
@@ -119,12 +136,22 @@ export function useStudentActions(
}
}, [enrolledClassrooms, enterClassroom, student.id])
const handleEnterSpecificClassroom = useCallback(
async (classroomId: string) => {
await enterClassroom.mutateAsync({ classroomId, playerId: student.id })
},
[enterClassroom, student.id]
)
const handleLeaveClassroom = useCallback(async () => {
if (enrolledClassrooms.length > 0 && student.relationship?.isPresent) {
const classroomId = enrolledClassrooms[0].id
await leaveClassroom.mutateAsync({ classroomId, playerId: student.id })
// Use currentPresence to get the actual classroom they're in
if (currentPresence) {
await leaveClassroom.mutateAsync({
classroomId: currentPresence.classroomId,
playerId: student.id,
})
}
}, [enrolledClassrooms, leaveClassroom, student.relationship?.isPresent, student.id])
}, [currentPresence, leaveClassroom, student.id])
const handleToggleArchive = useCallback(async () => {
await updatePlayer.mutateAsync({
@@ -139,6 +166,7 @@ export function useStudentActions(
startPractice: handleStartPractice,
watchSession: handleWatchSession,
enterClassroom: handleEnterClassroom,
enterSpecificClassroom: handleEnterSpecificClassroom,
leaveClassroom: handleLeaveClassroom,
toggleArchive: handleToggleArchive,
openShareAccess: () => setShowShareAccess(true),
@@ -148,6 +176,7 @@ export function useStudentActions(
handleStartPractice,
handleWatchSession,
handleEnterClassroom,
handleEnterSpecificClassroom,
handleLeaveClassroom,
handleToggleArchive,
]
@@ -169,8 +198,20 @@ export function useStudentActions(
[showShareAccess, showEnrollModal]
)
const isLoading =
updatePlayer.isPending || enterClassroom.isPending || leaveClassroom.isPending
const isLoading = updatePlayer.isPending || enterClassroom.isPending || leaveClassroom.isPending
// ========== Classroom data ==========
const classrooms: ClassroomData = useMemo(
() => ({
enrolled: enrolledClassrooms,
// Only set current if both presence and classroom are defined
current: currentPresence?.classroom
? { classroomId: currentPresence.classroomId, classroom: currentPresence.classroom }
: null,
isLoading: loadingEnrollments || loadingPresence,
}),
[enrolledClassrooms, currentPresence, loadingEnrollments, loadingPresence]
)
return {
actions,
@@ -178,5 +219,6 @@ export function useStudentActions(
modals,
isLoading,
student,
classrooms,
}
}