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:
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user