672 lines
23 KiB
TypeScript
672 lines
23 KiB
TypeScript
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<ExamGridProctorProps> = ({
|
|
students,
|
|
onlineIds,
|
|
onSelectStudent,
|
|
}) => {
|
|
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
|
|
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
|
|
const [gridCols, setGridCols] = useState<number>(3);
|
|
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
|
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
|
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
|
const [zoomedStudent, setZoomedStudent] = useState<ExamRoomStudent | null>(null);
|
|
const [pageSize, setPageSize] = useState<number | 'all'>(12);
|
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const subscribedRef = useRef<Set<number>>(new Set());
|
|
const gridContainerRef = useRef<HTMLDivElement>(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<number[]>(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<typeof setTimeout> | null = null;
|
|
let pingTimer: ReturnType<typeof setInterval> | 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 (
|
|
<div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
|
|
<div className="grid-proctor-toolbar">
|
|
<div className="grid-proctor-toolbar-left">
|
|
<div className="search-input-wrapper">
|
|
<input
|
|
type="text"
|
|
className="search-input"
|
|
placeholder="Lọc sinh viên..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<span className="search-icon">🔍</span>
|
|
</div>
|
|
|
|
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={onlyOnline}
|
|
onChange={(e) => setOnlyOnline(e.target.checked)}
|
|
/>
|
|
<span>Chỉ hiện Online</span>
|
|
</label>
|
|
|
|
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-primary)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={showWebcamOverlay}
|
|
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
|
|
/>
|
|
<span>Đè webcam góc màn hình</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid-proctor-toolbar-right">
|
|
<div className="grid-cols-selector" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Xem tối đa:</span>
|
|
<select
|
|
value={pageSize}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setPageSize(val === 'all' ? 'all' : Number(val));
|
|
setCurrentPage(1);
|
|
}}
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
padding: '0.2rem 0.4rem',
|
|
borderRadius: '4px',
|
|
border: '1px solid var(--border-color)',
|
|
backgroundColor: 'var(--bg-card)',
|
|
color: 'var(--text-primary)',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<option value={12}>12 bạn</option>
|
|
<option value={24}>24 bạn</option>
|
|
<option value={48}>48 bạn</option>
|
|
<option value="all">Tất cả</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid-cols-selector">
|
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
|
|
onClick={() => setGridCols(2)}
|
|
>
|
|
2
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
|
|
onClick={() => setGridCols(3)}
|
|
>
|
|
3
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
|
|
onClick={() => setGridCols(4)}
|
|
>
|
|
4
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
|
|
onClick={() => setGridCols(6)}
|
|
>
|
|
6
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={toggleFullscreen}
|
|
>
|
|
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`grid-proctor-layout`}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
|
gap: '1rem',
|
|
padding: '1rem 0',
|
|
}}
|
|
>
|
|
{pagedStudents.map((s) => {
|
|
const isOnline = onlineIds.includes(s.studentRkId);
|
|
const screenFrame = screenFrames[s.studentRkId];
|
|
const webcamFrame = webcamFrames[s.studentRkId];
|
|
|
|
return (
|
|
<div
|
|
key={s.id}
|
|
className={`grid-proctor-card ${isOnline ? 'online' : 'offline'} ${s.submitted ? 'submitted' : ''}`}
|
|
onClick={() => onSelectStudent(s)}
|
|
>
|
|
<div className="grid-proctor-card-header">
|
|
<div className="student-info-left">
|
|
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
|
|
<span className="student-name" title={s.fullName}>
|
|
{s.fullName}
|
|
</span>
|
|
</div>
|
|
<span className="student-code">{s.studentCode}</span>
|
|
</div>
|
|
|
|
<div className="grid-proctor-card-body">
|
|
{isOnline ? (
|
|
<div
|
|
className="proctor-frame-container cursor-zoom-in"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setZoomedStudent(s);
|
|
}}
|
|
style={{ cursor: 'zoom-in' }}
|
|
>
|
|
{screenFrame ? (
|
|
<img
|
|
src={screenFrame}
|
|
alt={`Màn hình ${s.fullName}`}
|
|
className="proctor-screen-image"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="proctor-placeholder streaming">
|
|
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} />
|
|
<span>Đang kết nối màn hình...</span>
|
|
</div>
|
|
)}
|
|
|
|
{showWebcamOverlay && webcamFrame && (
|
|
<div className="proctor-webcam-overlay">
|
|
<img
|
|
src={webcamFrame}
|
|
alt={`Webcam ${s.fullName}`}
|
|
className="proctor-webcam-image"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="proctor-placeholder offline">
|
|
<span className="icon">📴</span>
|
|
<span>Ngoại tuyến (Offline)</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
|
|
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
|
|
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
|
|
</span>
|
|
<div className="footer-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-xs text-primary"
|
|
onClick={(e) => handleOpenChat(s, e)}
|
|
title="Nhắn tin cho sinh viên"
|
|
>
|
|
💬 Nhắn tin
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-xs"
|
|
onClick={() => onSelectStudent(s)}
|
|
title="Xem chi tiết giám sát"
|
|
>
|
|
🔍 Giám sát
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{filteredStudents.length === 0 && (
|
|
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
|
|
<span>🔍</span>
|
|
<p>Không tìm thấy sinh viên nào.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination Controls */}
|
|
{totalPages > 1 && (
|
|
<div
|
|
className="grid-proctor-pagination"
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: '1rem',
|
|
padding: '1rem 0',
|
|
borderTop: '1px solid var(--border-light)',
|
|
marginTop: '1.25rem',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
disabled={currentPage === 1}
|
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
|
style={{ fontWeight: 600 }}
|
|
>
|
|
← Trang trước
|
|
</button>
|
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', fontWeight: 600 }}>
|
|
Trang {currentPage} / {totalPages} (Tổng {filteredStudents.length} bạn)
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
disabled={currentPage === totalPages}
|
|
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
|
style={{ fontWeight: 600 }}
|
|
>
|
|
Trang sau →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Zoom Modal Overlay (Rendered inside the container so it works in fullscreen mode) */}
|
|
{zoomedStudent && (
|
|
<div
|
|
className="zoomed-proctor-overlay"
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<div
|
|
className="zoomed-proctor-modal"
|
|
onClick={(e) => 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 */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '1rem 1.5rem',
|
|
borderBottom: '1px solid var(--border-color)',
|
|
backgroundColor: 'var(--bg-subtle)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<span
|
|
style={{
|
|
width: '10px',
|
|
height: '10px',
|
|
borderRadius: '50%',
|
|
backgroundColor: onlineIds.includes(zoomedStudent.studentRkId) ? 'var(--success)' : 'var(--danger)',
|
|
boxShadow: onlineIds.includes(zoomedStudent.studentRkId) ? '0 0 8px var(--success)' : 'none',
|
|
}}
|
|
/>
|
|
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: 600 }}>
|
|
{zoomedStudent.fullName}
|
|
</h3>
|
|
<span
|
|
style={{
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.85rem',
|
|
color: 'var(--text-secondary)',
|
|
backgroundColor: 'var(--bg-subtle)',
|
|
padding: '2px 8px',
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
{zoomedStudent.studentCode}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setZoomedStudent(null)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: 'var(--text-secondary)',
|
|
fontSize: '1.5rem',
|
|
cursor: 'pointer',
|
|
padding: '4px 8px',
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
padding: '1.5rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#000',
|
|
position: 'relative',
|
|
minHeight: '400px',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{screenFrames[zoomedStudent.studentRkId] ? (
|
|
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
|
<img
|
|
src={screenFrames[zoomedStudent.studentRkId]}
|
|
alt="Zoomed Screen"
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '65vh',
|
|
objectFit: 'contain',
|
|
borderRadius: '8px',
|
|
}}
|
|
/>
|
|
{showWebcamOverlay && webcamFrames[zoomedStudent.studentRkId] && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '20px',
|
|
right: '20px',
|
|
width: '240px',
|
|
aspectRatio: '4/3',
|
|
borderRadius: '8px',
|
|
border: '2px solid #ffffff',
|
|
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.4)',
|
|
overflow: 'hidden',
|
|
backgroundColor: '#000',
|
|
}}
|
|
>
|
|
<img
|
|
src={webcamFrames[zoomedStudent.studentRkId]}
|
|
alt="Zoomed Webcam"
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ color: '#8e8e9e', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
|
<div className="sync-spinner" style={{ width: '32px', height: '32px', borderWidth: '3px', marginBottom: '0.5rem' }} />
|
|
<span>Đang tải màn hình...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.cursor-zoom-in {
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
.cursor-zoom-in:hover {
|
|
transform: scale(1.015);
|
|
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|