import React, { useEffect, useState, useRef, useMemo } from 'react'; import { getWsUrl, type ExamRoomStudent } from '../api'; import { openStaffChat } from '../chatEvents'; interface ExamGridProctorProps { students: ExamRoomStudent[]; onlineIds: number[]; onSelectStudent: (student: ExamRoomStudent) => void; } export const ExamGridProctor: React.FC = ({ students, onlineIds, onSelectStudent, }) => { const [screenFrames, setScreenFrames] = useState>({}); const [webcamFrames, setWebcamFrames] = useState>({}); const [gridCols, setGridCols] = useState(3); const [showWebcamOverlay, setShowWebcamOverlay] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [onlyOnline, setOnlyOnline] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [zoomedStudent, setZoomedStudent] = useState(null); const [pageSize, setPageSize] = useState(12); const [currentPage, setCurrentPage] = useState(1); const wsRef = useRef(null); const subscribedRef = useRef>(new Set()); const gridContainerRef = useRef(null); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setZoomedStudent(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); const studentIdsString = useMemo( () => students.map((s) => s.studentRkId).join(','), [students] ); const filteredStudents = useMemo(() => { return students.filter((s) => { const matchesSearch = s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || s.studentCode.toLowerCase().includes(searchQuery.toLowerCase()); const isOnline = onlineIds.includes(s.studentRkId); const matchesOnlineFilter = !onlyOnline || isOnline; return matchesSearch && matchesOnlineFilter; }); }, [students, onlineIds, searchQuery, onlyOnline]); const pagedStudents = useMemo(() => { if (pageSize === 'all') return filteredStudents; const start = (currentPage - 1) * pageSize; return filteredStudents.slice(start, start + pageSize); }, [filteredStudents, currentPage, pageSize]); const totalPages = useMemo(() => { if (pageSize === 'all') return 1; return Math.ceil(filteredStudents.length / pageSize) || 1; }, [filteredStudents, pageSize]); useEffect(() => { if (currentPage > totalPages) { setCurrentPage(totalPages); } }, [totalPages, currentPage]); const visibleOnlineIds = useMemo(() => { return pagedStudents .map((s) => s.studentRkId) .filter((id) => onlineIds.includes(id)); }, [pagedStudents, onlineIds]); const visibleOnlineIdsRef = useRef(visibleOnlineIds); visibleOnlineIdsRef.current = visibleOnlineIds; const onlineKey = useMemo(() => visibleOnlineIds.join(','), [visibleOnlineIds]); const syncSubscriptions = (ws: WebSocket) => { if (ws.readyState !== WebSocket.OPEN) return; const currentVisibleIds = visibleOnlineIdsRef.current; const target = new Set(currentVisibleIds); const prev = subscribedRef.current; prev.forEach((id) => { if (!target.has(id)) { ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } })); prev.delete(id); } }); currentVisibleIds.forEach((id) => { if (!prev.has(id)) { ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } })); prev.add(id); } }); }; useEffect(() => { const ws = wsRef.current; if (ws) syncSubscriptions(ws); }, [onlineKey]); useEffect(() => { if (students.length === 0) return; const wsUrl = getWsUrl('/ws?role=teacher'); let closed = false; let retryTimer: ReturnType | null = null; let pingTimer: ReturnType | null = null; let attempt = 0; const clearPing = () => { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } }; const connect = () => { if (closed) return; const ws = new WebSocket(wsUrl); wsRef.current = ws; subscribedRef.current = new Set(); ws.onopen = () => { attempt = 0; syncSubscriptions(ws); clearPing(); pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ event: 'client:ping', data: {} })); } }, 15000); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.event === 'client:pong') return; if (msg.event === 'teacher:screenshot-stream-frame') { const { studentId, imageBuffer } = msg.data; setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); } else if (msg.event === 'teacher:webcam-stream-frame') { const { studentId, imageBuffer } = msg.data; setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); } else if (msg.event === 'teacher:stream-stopped') { const { studentId } = msg.data; setScreenFrames((prev) => { const next = { ...prev }; delete next[studentId]; return next; }); setWebcamFrames((prev) => { const next = { ...prev }; delete next[studentId]; return next; }); } } catch (err) { console.error('Error parsing WS message in grid view:', err); } }; ws.onclose = () => { clearPing(); subscribedRef.current = new Set(); if (!closed) { const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000); retryTimer = setTimeout(connect, delay); } }; }; connect(); return () => { closed = true; clearPing(); if (retryTimer) clearTimeout(retryTimer); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { subscribedRef.current.forEach((id) => { ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } })); }); } ws?.close(); wsRef.current = null; subscribedRef.current = new Set(); }; }, [studentIdsString]); // Clean up frames when students are removed or go offline useEffect(() => { setScreenFrames((prev) => { const next = { ...prev }; let changed = false; Object.keys(next).forEach((idStr) => { const id = Number(idStr); if (!onlineIds.includes(id)) { delete next[id]; changed = true; } }); return changed ? next : prev; }); setWebcamFrames((prev) => { const next = { ...prev }; let changed = false; Object.keys(next).forEach((idStr) => { const id = Number(idStr); if (!onlineIds.includes(id)) { delete next[id]; changed = true; } }); return changed ? next : prev; }); }, [onlineIds]); const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => { e.stopPropagation(); openStaffChat({ studentRkId: s.studentRkId, fullName: s.fullName, studentCode: s.studentCode, email: s.email, }); }; useEffect(() => { const handleFsChange = () => { setIsFullscreen(document.fullscreenElement === gridContainerRef.current); }; document.addEventListener('fullscreenchange', handleFsChange); return () => document.removeEventListener('fullscreenchange', handleFsChange); }, []); const toggleFullscreen = () => { const el = gridContainerRef.current; if (!el) return; if (document.fullscreenElement === el) { document.exitFullscreen().catch(console.error); } else { el.requestFullscreen().catch(console.error); } }; return (
setSearchQuery(e.target.value)} /> 🔍
Xem tối đa:
Cột:
{pagedStudents.map((s) => { const isOnline = onlineIds.includes(s.studentRkId); const screenFrame = screenFrames[s.studentRkId]; const webcamFrame = webcamFrames[s.studentRkId]; return (
onSelectStudent(s)} >
{s.fullName}
{s.studentCode}
{isOnline ? (
{ e.stopPropagation(); setZoomedStudent(s); }} style={{ cursor: 'zoom-in' }} > {screenFrame ? ( {`Màn ) : (
Đang kết nối màn hình...
)} {showWebcamOverlay && webcamFrame && (
{`Webcam
)}
) : (
📴 Ngoại tuyến (Offline)
)}
e.stopPropagation()}> {s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
); })} {filteredStudents.length === 0 && (
🔍

Không tìm thấy sinh viên nào.

)}
{/* Pagination Controls */} {totalPages > 1 && (
Trang {currentPage} / {totalPages} (Tổng {filteredStudents.length} bạn)
)} {/* Zoom Modal Overlay (Rendered inside the container so it works in fullscreen mode) */} {zoomedStudent && (
setZoomedStudent(null)} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(10, 15, 30, 0.9)', backdropFilter: 'blur(8px)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', animation: 'fadeIn 0.2s ease-out', }} >
e.stopPropagation()} style={{ width: '100%', maxWidth: '1000px', backgroundColor: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: '16px', boxShadow: '0 20px 40px rgba(0, 0, 0, 0.4)', display: 'flex', flexDirection: 'column', maxHeight: '90%', overflow: 'hidden', }} > {/* Modal Header */}

{zoomedStudent.fullName}

{zoomedStudent.studentCode}
{/* Modal Body */}
{screenFrames[zoomedStudent.studentRkId] ? (
Zoomed Screen {showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
Zoomed Webcam
)}
) : (
Đang tải màn hình...
)}
)}
); };