feat(practice): add StudentActionMenu to dashboard + fix z-index layering

- Add StudentActionMenu to student dashboard with inline variant
- Build proper StudentActionData with relationship/activity info
- Use useEnrolledClassrooms and useMyClassroom hooks for context
- Fix z-index layering for sub-modals (FamilyCodeDisplay, EnrollChildModal, SessionObserverModal)
- Use Z_INDEX.TOOLTIP (15000) for nested modals to appear above parent modals
- Remove unnecessary portal from FamilyCodeDisplay
- Add variant prop to StudentActionMenu: 'card' (absolute) vs 'inline' (normal flow)

🤖 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 10:02:29 -06:00
parent fd1df93a8f
commit bf262e7d53
5 changed files with 169 additions and 197 deletions

View File

@@ -4,6 +4,7 @@ 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'
import {
@@ -16,6 +17,7 @@ import {
type SkillHealthSummary,
SkillProgressChart,
StartPracticeModal,
StudentActionMenu,
type StudentWithProgress,
VirtualizedSessionList,
} from '@/components/practice'
@@ -2529,6 +2531,35 @@ export function DashboardClient({
// This ensures the UI updates when teacher removes student from classroom
usePlayerPresenceSocket(studentId)
// Classroom context for student actions
const { data: classroom } = useMyClassroom()
const { data: enrolledClassrooms = [] } = useEnrolledClassrooms(studentId)
const isEnrolled = enrolledClassrooms.length > 0
// Build StudentActionData with same structure as student tiles/quick look
const studentActionData = useMemo(
() => ({
id: player.id,
name: player.name,
isArchived: player.isArchived,
relationship: {
isMyChild: true, // On this student's dashboard, they're either your child or enrolled student you manage
isEnrolled,
isPresent: false, // TODO: Could check classroom presence if needed
enrollmentStatus: null,
},
activity: activeSession
? {
status: 'practicing' as const,
sessionId: activeSession.id,
}
: {
status: 'idle' as const,
},
}),
[player.id, player.name, player.isArchived, isEnrolled, activeSession]
)
// Handle incoming page transition (from QuickLook modal)
const { hasTransition, isRevealing, signalReady } = useIncomingTransition()
@@ -2552,7 +2583,14 @@ export function DashboardClient({
// Debug logging
useEffect(() => {
console.log('[DashboardClient] hasTransition:', hasTransition, 'isRevealing:', isRevealing, 'contentOpacity:', contentOpacity)
console.log(
'[DashboardClient] hasTransition:',
hasTransition,
'isRevealing:',
isRevealing,
'contentOpacity:',
contentOpacity
)
}, [hasTransition, isRevealing, contentOpacity])
// Tab state - sync with URL
@@ -2738,14 +2776,17 @@ export function DashboardClient({
})}
>
<div className={css({ maxWidth: '900px', margin: '0 auto' })}>
{/* Classroom presence - allows entering enrolled classrooms for live practice */}
{/* Student actions & classroom presence */}
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: '8px',
marginBottom: '0.75rem',
})}
>
<StudentActionMenu student={studentActionData} variant="inline" />
<EnterClassroomButton playerId={studentId} playerName={player.name} />
</div>

View File

@@ -125,7 +125,7 @@ export function EnrollChildModal({ isOpen, onClose, playerId, playerName }: Enro
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
zIndex: Z_INDEX.MODAL + 100, // Above parent modals when nested
zIndex: Z_INDEX.TOOLTIP, // 15000 - above modals (10001) but below toasts (20000)
transition: 'opacity 0.2s ease-out',
opacity: isClosing ? 0 : 1,
})}
@@ -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.MODAL + 101, // Above parent modals when nested
zIndex: Z_INDEX.TOOLTIP + 1, // Above the overlay
outline: 'none',
transition: 'opacity 0.2s ease-out, transform 0.2s ease-out',
opacity: isClosing ? 0 : 1,

View File

@@ -140,7 +140,7 @@ export function SessionObserverModal({
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
zIndex: Z_INDEX.MODAL_BACKDROP,
zIndex: Z_INDEX.TOOLTIP, // 15000 - above parent modals when nested
})}
onClick={onClose}
/>
@@ -159,7 +159,7 @@ export function SessionObserverModal({
backgroundColor: isDark ? 'gray.900' : 'white',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
zIndex: Z_INDEX.MODAL,
zIndex: Z_INDEX.TOOLTIP + 1, // Above the overlay
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',

View File

@@ -1,8 +1,10 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ShareCodePanel } from '@/components/common'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import { useShareCode } from '@/hooks/useShareCode'
import { css } from '../../../styled-system/css'
interface FamilyCodeDisplayProps {
@@ -16,8 +18,8 @@ interface FamilyCodeDisplayProps {
* Modal to display and manage a child's family code
*
* Parents can:
* - View the family code
* - Copy it to clipboard
* - View the family code with QR
* - Copy code or link to clipboard
* - Regenerate it (invalidates old code)
*/
export function FamilyCodeDisplay({
@@ -32,8 +34,6 @@ export function FamilyCodeDisplay({
const [familyCode, setFamilyCode] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [isRegenerating, setIsRegenerating] = useState(false)
// Fetch family code when modal opens
const fetchFamilyCode = useCallback(async () => {
@@ -57,7 +57,6 @@ export function FamilyCodeDisplay({
useEffect(() => {
setFamilyCode(null)
setError(null)
setCopied(false)
}, [playerId])
// Fetch on open
@@ -67,30 +66,8 @@ export function FamilyCodeDisplay({
}
}, [isOpen, familyCode, isLoading, fetchFamilyCode])
// Copy to clipboard
const handleCopy = useCallback(async () => {
if (!familyCode) return
try {
await navigator.clipboard.writeText(familyCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = familyCode
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [familyCode])
// Regenerate family code
const handleRegenerate = useCallback(async () => {
setIsRegenerating(true)
setError(null)
try {
const response = await fetch(`/api/family/children/${playerId}/code`, {
method: 'POST',
@@ -100,22 +77,25 @@ export function FamilyCodeDisplay({
throw new Error(data.error || 'Failed to regenerate code')
}
setFamilyCode(data.familyCode)
return data.familyCode
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to regenerate code')
} finally {
setIsRegenerating(false)
throw err
}
}, [playerId])
// Don't render if not open
if (!isOpen) return null
// Render directly as sibling to parent modal's animated.div
// Uses position: fixed with z-index above parent modal
return (
<div
data-component="family-code-modal"
className={css({
position: 'fixed',
inset: 0,
zIndex: Z_INDEX.MODAL + 100, // Above parent modals when nested
zIndex: Z_INDEX.TOOLTIP, // 15000 - above modals (10001) but below toasts (20000)
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -131,165 +111,10 @@ export function FamilyCodeDisplay({
maxWidth: '400px',
width: '90%',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
position: 'relative',
})}
onClick={(e) => e.stopPropagation()}
>
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Share Access to {playerName}
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
Share this code with another parent to give them equal access to {playerName}&apos;s
practice data.
</p>
{isLoading ? (
<div
className={css({
textAlign: 'center',
padding: '20px',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Loading...
</div>
) : error ? (
<div
className={css({
textAlign: 'center',
padding: '20px',
color: 'red.500',
})}
>
{error}
</div>
) : (
<>
{/* Family Code Display */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
})}
>
<div
data-element="family-code"
className={css({
flex: 1,
padding: '16px',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
borderRadius: '8px',
fontFamily: 'monospace',
fontSize: '1.5rem',
fontWeight: 'bold',
textAlign: 'center',
letterSpacing: '0.1em',
color: isDark ? 'green.400' : 'green.600',
})}
>
{familyCode}
</div>
<button
type="button"
onClick={handleCopy}
data-action="copy-family-code"
className={css({
padding: '12px 16px',
backgroundColor: copied
? isDark
? 'green.700'
: 'green.500'
: isDark
? 'blue.700'
: 'blue.500',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'medium',
cursor: 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: copied
? isDark
? 'green.600'
: 'green.600'
: isDark
? 'blue.600'
: 'blue.600',
},
})}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* Instructions */}
<p
className={css({
fontSize: '0.8125rem',
color: isDark ? 'gray.500' : 'gray.500',
marginBottom: '20px',
})}
>
The other parent will enter this code on their device to link to {playerName}.
</p>
{/* Regenerate button */}
<button
type="button"
onClick={handleRegenerate}
disabled={isRegenerating}
data-action="regenerate-family-code"
className={css({
width: '100%',
padding: '10px',
backgroundColor: 'transparent',
color: isDark ? 'gray.400' : 'gray.500',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '8px',
fontSize: '13px',
cursor: isRegenerating ? 'wait' : 'pointer',
transition: 'all 0.15s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'gray.100',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
{isRegenerating ? 'Regenerating...' : 'Generate New Code'}
</button>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.600' : 'gray.400',
marginTop: '8px',
textAlign: 'center',
})}
>
Generating a new code will invalidate the old one
</p>
</>
)}
{/* Close button */}
<button
type="button"
@@ -313,7 +138,100 @@ export function FamilyCodeDisplay({
>
×
</button>
{isLoading ? (
<div
className={css({
textAlign: 'center',
padding: '40px 20px',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Loading...
</div>
) : error ? (
<div
className={css({
textAlign: 'center',
padding: '40px 20px',
color: 'red.500',
})}
>
{error}
</div>
) : familyCode ? (
<FamilyCodeContent
code={familyCode}
playerName={playerName}
onRegenerate={handleRegenerate}
/>
) : null}
</div>
</div>
)
}
/**
* Inner content when family code is loaded
*/
function FamilyCodeContent({
code,
playerName,
onRegenerate,
}: {
code: string
playerName: string
onRegenerate: () => Promise<string>
}) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const shareCode = useShareCode({
type: 'family',
code,
onRegenerate,
})
return (
<div data-section="family-code-content">
<h2
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
marginBottom: '8px',
})}
>
Share Access to {playerName}
</h2>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '20px',
})}
>
Share this code or QR with another parent to give them equal access to {playerName}&apos;s
practice data.
</p>
<ShareCodePanel
shareCode={shareCode}
showRegenerate
className={css({ padding: '0', border: 'none' })}
/>
{/* Regeneration note */}
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.600' : 'gray.400',
marginTop: '12px',
textAlign: 'center',
})}
>
Generating a new code will invalidate the old one
</p>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { EnrollChildModal } from '@/components/classroom'
import { FamilyCodeDisplay } from '@/components/family'
import { Z_INDEX } from '@/constants/zIndex'
import { useTheme } from '@/contexts/ThemeContext'
import { useStudentActions, type StudentActionData } from '@/hooks/useStudentActions'
import { type StudentActionData, useStudentActions } from '@/hooks/useStudentActions'
import { css } from '../../../styled-system/css'
import { ACTION_DEFINITIONS } from './studentActions'
@@ -17,6 +17,8 @@ interface StudentActionMenuProps {
student: StudentActionData
/** Optional callback when observe session is clicked (for external handling) */
onObserveSession?: (sessionId: string) => void
/** Positioning variant: 'card' for absolute positioning on cards, 'inline' for normal flow */
variant?: 'card' | 'inline'
}
/**
@@ -30,7 +32,11 @@ interface StudentActionMenuProps {
* - Student status (practicing, present, enrolled, etc.)
* - Relationship (is my child, etc.)
*/
export function StudentActionMenu({ student, onObserveSession }: StudentActionMenuProps) {
export function StudentActionMenu({
student,
onObserveSession,
variant = 'card',
}: StudentActionMenuProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -60,9 +66,16 @@ export function StudentActionMenu({ student, onObserveSession }: StudentActionMe
data-action="open-menu"
onClick={(e) => e.stopPropagation()}
className={css({
position: 'absolute',
top: '6px',
right: '38px',
// Card variant: absolute positioned overlay on cards
...(variant === 'card' && {
position: 'absolute',
top: '6px',
right: '38px',
}),
// Inline variant: normal flow for toolbars
...(variant === 'inline' && {
position: 'relative',
}),
width: '28px',
height: '28px',
borderRadius: '6px',