diff --git a/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx index ad73348a..3ed7606e 100644 --- a/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx +++ b/apps/web/src/app/practice/[studentId]/summary/SummaryClient.tsx @@ -1,26 +1,27 @@ 'use client' +import * as Dialog from '@radix-ui/react-dialog' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { PageWithNav } from '@/components/PageWithNav' import { ContentBannerSlot, PracticeSubNav, ProjectingBanner, - SessionPhotoGallery, SessionSummary, StartPracticeModal, } from '@/components/practice' -import { api } from '@/lib/queryClient' -import { useTheme } from '@/contexts/ThemeContext' +import { Z_INDEX } from '@/constants/zIndex' import { SessionModeBannerProvider, useSessionModeBanner, } from '@/contexts/SessionModeBannerContext' +import { useTheme } from '@/contexts/ThemeContext' import type { Player } from '@/db/schema/players' import type { SessionPlan } from '@/db/schema/session-plans' import { useSessionMode } from '@/hooks/useSessionMode' import type { ProblemResultWithContext } from '@/lib/curriculum/session-planner' +import { api } from '@/lib/queryClient' import { css } from '../../../../../styled-system/css' // Combined height of sticky elements above content area @@ -75,12 +76,17 @@ export function SummaryClient({ const isDark = resolvedTheme === 'dark' const [showStartPracticeModal, setShowStartPracticeModal] = useState(false) - const [showPhotoGallery, setShowPhotoGallery] = useState(false) - const [galleryUploadMode, setGalleryUploadMode] = useState(false) + const [showCamera, setShowCamera] = useState(false) + const [dragOver, setDragOver] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(null) // Session mode - single source of truth for session planning decisions const { data: sessionMode, isLoading: isLoadingSessionMode } = useSessionMode(studentId) + const queryClient = useQueryClient() + // Fetch attachments for this session const { data: attachmentsData } = useQuery({ queryKey: ['session-attachments', studentId, session?.id], @@ -98,26 +104,88 @@ export function SummaryClient({ const isInProgress = session?.startedAt && !session?.completedAt - const queryClient = useQueryClient() + // Upload photos immediately + const uploadPhotos = useCallback( + async (files: File[]) => { + if (!session?.id || files.length === 0) return - // Handle opening gallery for viewing photos - const handleViewPhotos = useCallback(() => { - setGalleryUploadMode(false) - setShowPhotoGallery(true) + // Filter for images only + const imageFiles = files.filter((f) => f.type.startsWith('image/')) + if (imageFiles.length === 0) { + setUploadError('No valid image files selected') + return + } + + setIsUploading(true) + setUploadError(null) + + try { + const formData = new FormData() + for (const file of imageFiles) { + formData.append('photos', file) + } + + const response = await fetch( + `/api/curriculum/${studentId}/sessions/${session.id}/attachments`, + { method: 'POST', body: formData } + ) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'Failed to upload photos') + } + + // Refresh attachments + queryClient.invalidateQueries({ + queryKey: ['session-attachments', studentId, session.id], + }) + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Failed to upload photos') + } finally { + setIsUploading(false) + } + }, + [studentId, session?.id, queryClient] + ) + + // Handle file selection + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : [] + uploadPhotos(files) + e.target.value = '' + }, + [uploadPhotos] + ) + + // Handle drag and drop + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + const files = Array.from(e.dataTransfer.files) + uploadPhotos(files) + }, + [uploadPhotos] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setDragOver(true) }, []) - // Handle opening gallery for adding photos - const handleAddPhotos = useCallback(() => { - setGalleryUploadMode(true) - setShowPhotoGallery(true) + const handleDragLeave = useCallback(() => { + setDragOver(false) }, []) - // Refresh attachments query after upload - const handlePhotosUploaded = useCallback(() => { - queryClient.invalidateQueries({ - queryKey: ['session-attachments', studentId, session?.id], - }) - }, [queryClient, studentId, session?.id]) + // Handle camera capture + const handleCameraCapture = useCallback( + (file: File) => { + setShowCamera(false) + uploadPhotos([file]) + }, + [uploadPhotos] + ) // Handle practice again - show the start practice modal const handlePracticeAgain = useCallback(() => { @@ -207,24 +275,42 @@ export function SummaryClient({ problemHistory={problemHistory} /> - {/* Photos Section */} + {/* Photos Section - Drop Target */}
+ {/* Hidden file input */} + + + {/* Header with action buttons */}

)}

- + + {/* Action buttons */} +
+ + +
+ {/* Upload error */} + {uploadError && ( +
+ {uploadError} +
+ )} + + {/* Photo grid or empty state */} {hasPhotos ? (
- {attachments.slice(0, 6).map((att) => ( - +
))} - {attachments.length > 6 && ( - - )}
) : (

- No photos attached. Add photos of student work to keep a visual record. + {dragOver + ? 'Drop photos here to upload' + : 'Drag photos here or use the buttons above'}

)} @@ -403,18 +503,296 @@ export function SummaryClient({ /> )} - {/* Photo Gallery Modal */} - {showPhotoGallery && session && ( - setShowPhotoGallery(false)} - initialShowUpload={galleryUploadMode} - onPhotosUploaded={handlePhotosUploaded} - /> - )} + {/* Fullscreen Camera Modal */} + + + + + Take Photo + + Camera viewfinder. Tap capture to take a photo. + + setShowCamera(false)} + /> + + + ) } + +// ============================================================================= +// Fullscreen Camera Component +// ============================================================================= + +interface FullscreenCameraProps { + onCapture: (file: File) => void + onClose: () => void +} + +function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) { + const videoRef = useRef(null) + const canvasRef = useRef(null) + const streamRef = useRef(null) + + const [isReady, setIsReady] = useState(false) + const [error, setError] = useState(null) + const [isCapturing, setIsCapturing] = useState(false) + + useEffect(() => { + let cancelled = false + + const startCamera = async () => { + try { + const constraints: MediaStreamConstraints = { + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + } + + const stream = await navigator.mediaDevices.getUserMedia(constraints) + + if (cancelled) { + stream.getTracks().forEach((track) => track.stop()) + return + } + + streamRef.current = stream + + if (videoRef.current) { + videoRef.current.srcObject = stream + await videoRef.current.play() + if (!cancelled) { + setIsReady(true) + } + } + } catch (err) { + if (cancelled) return + console.error('Camera access error:', err) + setError('Camera access denied. Please allow camera access and try again.') + } + } + + startCamera() + + return () => { + cancelled = true + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()) + streamRef.current = null + } + } + }, []) + + const capturePhoto = async () => { + if (!videoRef.current || !canvasRef.current) return + + setIsCapturing(true) + try { + const video = videoRef.current + const canvas = canvasRef.current + + canvas.width = video.videoWidth + canvas.height = video.videoHeight + + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get canvas context') + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height) + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => { + if (b) resolve(b) + else reject(new Error('Failed to create blob')) + }, + 'image/jpeg', + 0.9 + ) + }) + + const file = new File([blob], `photo-${Date.now()}.jpg`, { + type: 'image/jpeg', + }) + + onCapture(file) + } catch (err) { + console.error('Capture error:', err) + setError('Failed to capture photo. Please try again.') + } finally { + setIsCapturing(false) + } + } + + return ( +
+
+ ) +}