All files / web/src/components PageTransitionOverlay.tsx

0% Statements 0/248
0% Branches 0/1
0% Functions 0/1
0% Lines 0/248

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 
'use client'

import { animated, useSpring } from '@react-spring/web'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '@/constants/zIndex'
import { usePageTransition, type TransitionState } from '@/contexts/PageTransitionContext'
import { css } from '../../styled-system/css'

// ============================================================================
// Component
// ============================================================================

/**
 * Full-screen overlay for smooth page transitions.
 *
 * Animates from the QuickLook modal position to cover the full viewport,
 * then fades out to reveal the destination page.
 */
export function PageTransitionOverlay() {
  const { transitionState, isTransitioning, isRevealing } = usePageTransition()
  const [phase, setPhase] = useState<'idle' | 'expanding' | 'covering' | 'revealing'>('idle')

  // Track the animation state
  useEffect(() => {
    if (isTransitioning && transitionState) {
      // Determine which phase we're in based on where we are in the flow
      // If we just started (on source page), we're expanding
      // If we arrived on destination, we're revealing
      const age = Date.now() - transitionState.timestamp

      if (age < 300) {
        setPhase('expanding')
        // After expansion, switch to covering
        const remainingExpand = Math.max(0, 300 - age)
        setTimeout(() => setPhase('covering'), remainingExpand)
      } else if (age < 1500) {
        // We're on the destination page, covering until ready
        setPhase('covering')
      } else {
        // Stale - should reveal
        setPhase('revealing')
      }
    } else {
      setPhase('idle')
    }
  }, [isTransitioning, transitionState])

  // When context signals revealing, sync our phase
  useEffect(() => {
    if (isRevealing && phase === 'covering') {
      console.log('[PageTransitionOverlay] Context signaled revealing, switching to reveal phase')
      setPhase('revealing')
    }
  }, [isRevealing, phase])

  // Debug logging
  useEffect(() => {
    console.log(
      '[PageTransitionOverlay] phase:',
      phase,
      'isRevealing:',
      isRevealing,
      'isTransitioning:',
      isTransitioning
    )
  }, [phase, isRevealing, isTransitioning])

  // When phase changes to revealing, start the fade out
  useEffect(() => {
    if (phase === 'revealing') {
      const timer = setTimeout(() => setPhase('idle'), 350)
      return () => clearTimeout(timer)
    }
  }, [phase])

  // Don't render if not transitioning
  if (!isTransitioning || !transitionState || phase === 'idle') {
    return null
  }

  return <OverlayAnimation transitionState={transitionState} phase={phase} />
}

// ============================================================================
// Animation Component
// ============================================================================

interface OverlayAnimationProps {
  transitionState: TransitionState
  phase: 'expanding' | 'covering' | 'revealing'
}

function OverlayAnimation({ transitionState, phase }: OverlayAnimationProps) {
  const { originBounds, studentEmoji, studentName, studentColor } = transitionState
  const isFirstRender = useRef(true)

  // Calculate target (full screen)
  const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 800
  const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 600

  // Animation spring - position is immediate, opacity animates for cross-fade
  const spring = useSpring({
    // Position and size - start at origin, expand to full screen
    left: phase === 'expanding' ? originBounds.left : 0,
    top: phase === 'expanding' ? originBounds.top : 0,
    width: phase === 'expanding' ? originBounds.width : windowWidth,
    height: phase === 'expanding' ? originBounds.height : windowHeight,
    // Border radius (from modal rounded to full screen square)
    borderRadius: phase === 'expanding' ? 16 : 0,
    // Opacity - fade in on first render, fade out when revealing
    opacity: phase === 'revealing' ? 0 : 1,
    // Scale for subtle effect
    scale: phase === 'expanding' ? 1 : 1,
    // Start from opacity 0 for fade-in, position starts at origin
    from: {
      left: originBounds.left,
      top: originBounds.top,
      width: originBounds.width,
      height: originBounds.height,
      borderRadius: 16,
      opacity: 0,
      scale: 1,
    },
    // Position/size are immediate on first render, but opacity animates for cross-fade with modal
    immediate: (key) => isFirstRender.current && key !== 'opacity',
    config:
      phase === 'revealing'
        ? { tension: 200, friction: 26 } // Slower fade-out for smooth cross-fade
        : { tension: 300, friction: 20 }, // Quick fade-in for modal cross-fade
  })

  // Mark first render as complete
  useEffect(() => {
    isFirstRender.current = false
  }, [])

  // Avatar animation (grows and centers)
  const avatarSpring = useSpring({
    // Size grows as overlay expands
    size: phase === 'expanding' ? 48 : 80,
    // Opacity
    opacity: phase === 'revealing' ? 0 : 1,
    immediate: isFirstRender.current,
    config: { tension: 200, friction: 26 },
  })

  return (
    <animated.div
      data-component="page-transition-overlay"
      style={{
        position: 'fixed',
        left: spring.left,
        top: spring.top,
        width: spring.width,
        height: spring.height,
        borderRadius: spring.borderRadius,
        opacity: spring.opacity,
        transform: spring.scale.to((s) => `scale(${s})`),
        zIndex: Z_INDEX.MODAL + 100,
        backgroundColor: studentColor,
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        gap: 16,
        overflow: 'hidden',
        pointerEvents: 'none',
      }}
    >
      {/* Student emoji avatar */}
      <animated.div
        style={{
          width: avatarSpring.size,
          height: avatarSpring.size,
          opacity: avatarSpring.opacity,
          borderRadius: '50%',
          backgroundColor: 'rgba(255, 255, 255, 0.3)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          fontSize: avatarSpring.size.to((s) => `${s * 0.5}px`),
        }}
      >
        {studentEmoji}
      </animated.div>

      {/* Student name */}
      <animated.div
        style={{
          opacity: avatarSpring.opacity,
          color: 'white',
          fontSize: 24,
          fontWeight: 600,
          textShadow: '0 2px 4px rgba(0,0,0,0.2)',
        }}
      >
        {studentName}
      </animated.div>

      {/* Loading indicator (shows after a delay) */}
      <LoadingIndicator phase={phase} />
    </animated.div>
  )
}

// ============================================================================
// Loading Indicator
// ============================================================================

function LoadingIndicator({ phase }: { phase: string }) {
  const [showLoading, setShowLoading] = useState(false)

  // Only show loading indicator if covering phase lasts > 800ms
  useEffect(() => {
    if (phase === 'covering') {
      const timer = setTimeout(() => setShowLoading(true), 800)
      return () => clearTimeout(timer)
    } else {
      setShowLoading(false)
    }
  }, [phase])

  if (!showLoading) return null

  return (
    <div
      className={css({
        display: 'flex',
        gap: '8px',
        opacity: 0.7,
      })}
    >
      {[0, 1, 2].map((i) => (
        <div
          key={i}
          className={css({
            width: '8px',
            height: '8px',
            borderRadius: '50%',
            backgroundColor: 'white',
            animation: 'pulse 1s ease-in-out infinite',
            animationDelay: `${i * 0.15}s`,
          })}
        />
      ))}
    </div>
  )
}