sozsoft-platform/ui/src/views/admin/videoroom/RoomDetail.tsx
Sedat ÖZTÜRK 9cd49f3f66 bottom-end
2026-07-08 15:34:13 +03:00

1386 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, type FormEvent } from 'react'
import { motion } from 'framer-motion'
import {
FaComments,
FaExpand,
FaHandPaper,
FaVolumeMute,
FaVolumeUp,
FaFile,
FaDesktop,
FaMicrophone,
FaMicrophoneSlash,
FaVideo,
FaVideoSlash,
FaPhone,
FaTimes,
FaCompress,
FaUserFriends,
FaLayerGroup,
FaFilePdf,
FaFileWord,
FaFileImage,
FaFileAlt,
FaBars,
} from 'react-icons/fa'
import { SignalRService } from '@/services/videoroom/signalr'
import { WebRTCService } from '@/services/videoroom/webrtc'
import { useStoreState } from '@/store/store'
import { KickParticipantModal } from '@/views/admin/videoroom/KickParticipantModal'
import {
getVideoroomAttandances,
getVideoroomById,
getVideoroomChats,
endVideoroom,
} from '@/services/videoroom.service'
import { showDbDateAsIs } from '@/utils/dateUtils'
import { useNavigate, useParams } from 'react-router-dom'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { Helmet } from 'react-helmet'
import { useLocalization } from '@/utils/hooks/useLocalization'
import ChatPanel from '@/views/admin/videoroom/ChatPanel'
import ParticipantsPanel from '@/views/admin/videoroom/ParticipantsPanel'
import DocumentsPanel from '@/views/admin/videoroom/DocumentsPanel'
import LayoutPanel from '@/views/admin/videoroom/LayoutPanel'
import { ScreenSharePanel } from '@/views/admin/videoroom/ScreenSharePanel'
import { RoomParticipant } from '@/views/admin/videoroom/RoomParticipant'
import toast from '@/components/ui/toast/toast'
import Notification from '@/components/ui/Notification'
import { Button } from '@/components/ui'
import {
VideoroomDocumentDto,
VideoroomAttendanceDto,
VideoroomChatDto,
VideoroomDto,
VideoroomParticipantDto,
VideoroomSettingsDto,
MessageType,
VideoroomLayoutDto,
} from '@/proxy/videoroom/models'
type SidePanelType =
| 'chat'
| 'participants'
| 'documents'
| 'handraises'
| 'layout'
| 'settings'
| null
const newClassSession: VideoroomDto = {
id: '',
name: '',
teacherId: '',
teacherName: '',
scheduledStartTime: '',
scheduledEndTime: '',
actualStartTime: '',
actualEndTime: '',
participantCount: 0,
settingsDto: undefined,
}
const RoomDetail = () => {
const handleDismissHandRaise = async (participantId: string) => {
if (signalRServiceRef.current && user.role === 'teacher') {
await signalRServiceRef.current.dismissHandRaise(classSession.id, participantId)
}
}
const params = useParams()
const navigate = useNavigate()
const { user } = useStoreState((state) => state.auth)
const { translate } = useLocalization()
const [classSession, setClassSession] = useState<VideoroomDto>(newClassSession)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [participants, setParticipants] = useState<VideoroomParticipantDto[]>([])
const [localStream, setLocalStream] = useState<MediaStream | null>(null)
const [isAudioEnabled, setIsAudioEnabled] = useState(true)
const [isVideoEnabled, setIsVideoEnabled] = useState(true)
const [attendanceRecords, setAttendanceRecords] = useState<VideoroomAttendanceDto[]>([])
const [chatMessages, setChatMessages] = useState<VideoroomChatDto[]>([])
const [currentLayout, setCurrentLayout] = useState<VideoroomLayoutDto | null>(null)
const [focusedParticipant, setFocusedParticipant] = useState<string>()
const [hasRaisedHand, setHasRaisedHand] = useState(false)
const [isAllMuted, setIsAllMuted] = useState(false)
const [kickingParticipant, setKickingParticipant] = useState<{ id: string; name: string } | null>(
null,
)
const [documents, setDocuments] = useState<VideoroomDocumentDto[]>([])
const [isScreenSharing, setIsScreenSharing] = useState(false)
const [screenStream, setScreenStream] = useState<MediaStream>()
const [screenSharer, setScreenSharer] = useState<string>()
const [isFullscreen, setIsFullscreen] = useState(false)
const [activeSidePanel, setActiveSidePanel] = useState<SidePanelType>(null)
const [newMessage, setNewMessage] = useState('')
const [messageMode, setMessageMode] = useState<MessageType>('public')
const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>(
null,
)
const raisedHandsCount = participants.filter((p) => p.isHandRaised).length
const messagesEndRef = useRef<HTMLDivElement>(null)
const [classSettings, setClassSettings] = useState<VideoroomSettingsDto>({
allowHandRaise: true,
defaultMicrophoneState: 'muted',
defaultCameraState: 'on',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
})
const signalRServiceRef = useRef<SignalRService>()
const webRTCServiceRef = useRef<WebRTCService>()
const [teacherDisconnected, setTeacherDisconnected] = useState(false)
const layouts: VideoroomLayoutDto[] = [
{
id: 'grid',
name: 'Izgara Görünümü',
type: 'grid',
description: 'Tüm katılımcılar eşit boyutta görünür',
},
{
id: 'sidebar',
name: 'Sunum Modu',
type: 'sidebar',
description: 'Ana konuşmacı büyük, diğerleri yan panelde',
},
{
id: 'teacher-focus',
name: 'Öğretmen Odaklı',
type: 'teacher-focus',
description: 'Öğretmen tam ekranda görünür, öğrenciler küçük panelde',
},
]
const fetchClassAttendances = async () => {
if (!params?.id) return
const attResult = await getVideoroomAttandances(params.id)
if (attResult && attResult.data) {
setAttendanceRecords(attResult.data)
}
}
const fetchClassChats = async () => {
if (!params?.id) return
const chatResult = await getVideoroomChats(params.id)
if (chatResult && chatResult.data) {
setChatMessages(chatResult.data || [])
}
}
const fetchClassDetails = async () => {
const classEntity = await getVideoroomById(params?.id ?? '')
if (classEntity) {
classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime)
setClassSession(classEntity.data)
}
}
useEffect(() => {
fetchClassDetails()
fetchClassChats()
fetchClassAttendances()
}, [])
useEffect(() => {
if (classSession.id) {
initializeServices()
return () => {
cleanup()
}
}
}, [classSession.id])
// Apply class settings
useEffect(() => {
if (classSession?.settingsDto) {
setClassSettings(classSession.settingsDto)
const selectedLayout =
layouts.find((l) => l.id === classSession.settingsDto!.defaultLayout) || layouts[0]
setCurrentLayout(selectedLayout)
// Apply default audio/video states for new participants
if (user.role === 'student') {
setIsAudioEnabled(classSession.settingsDto.defaultMicrophoneState === 'unmuted')
setIsVideoEnabled(classSession.settingsDto.defaultCameraState === 'on')
}
}
}, [classSession?.settingsDto, user.role])
useEffect(() => {
scrollToBottom()
}, [chatMessages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
const initializeServices = async () => {
try {
// Initialize SignalR
signalRServiceRef.current = new SignalRService()
await signalRServiceRef.current.start()
const micEnabled = classSession.settingsDto?.defaultMicrophoneState === 'unmuted'
const camEnabled = classSession.settingsDto?.defaultCameraState === 'on'
// WebRTC başlat
webRTCServiceRef.current = new WebRTCService()
webRTCServiceRef.current.setSignalRService(signalRServiceRef.current, classSession.id)
const stream = await webRTCServiceRef.current.initializeLocalStream(micEnabled, camEnabled)
if (stream) {
setLocalStream(stream)
}
setIsAudioEnabled(micEnabled)
setIsVideoEnabled(camEnabled)
// Setup WebRTC remote stream handler
webRTCServiceRef.current.onRemoteStreamReceived((userId, stream) => {
setParticipants((prev) => prev.map((p) => (p.id === userId ? { ...p, stream } : p)))
})
webRTCServiceRef.current.setIceCandidateHandler(async (toUserId, candidate) => {
if (signalRServiceRef.current) {
await signalRServiceRef.current.sendIceCandidate(classSession.id, toUserId, candidate)
}
})
signalRServiceRef.current.setOfferReceivedHandler(async (fromUserId, offer) => {
if (!webRTCServiceRef.current?.getPeerConnection(fromUserId)) {
await webRTCServiceRef.current?.createPeerConnection(fromUserId)
}
const answer = await webRTCServiceRef.current?.createAnswer(fromUserId, offer)
if (answer) {
await signalRServiceRef.current?.sendAnswer(classSession.id, fromUserId, answer)
}
})
signalRServiceRef.current.setAnswerReceivedHandler(async (fromUserId, answer) => {
await webRTCServiceRef.current?.handleAnswer(fromUserId, answer)
})
signalRServiceRef.current.setIceCandidateReceivedHandler(async (fromUserId, candidate) => {
await webRTCServiceRef.current?.addIceCandidate(fromUserId, candidate)
})
// 🔑 Yeni katılan birini gördüğünde
signalRServiceRef.current.setParticipantJoinHandler(
async (remoteUserId: string, name: string, isTeacher: boolean, isActive: boolean) => {
if (remoteUserId === user.id) return
if (!isActive) return
toast.push(<Notification title={`${name} sınıfa katıldı`} type="success" />, {
placement: 'bottom-end',
})
// Statee ekle
setParticipants((prev) => {
if (prev.find((p) => p.id === remoteUserId)) return prev
return [
...prev,
{
id: remoteUserId,
name,
sessionId: classSession.id,
isTeacher,
isAudioMuted: classSettings.defaultMicrophoneState === 'muted',
isVideoMuted: classSettings.defaultCameraState === 'off',
isActive: true,
},
]
})
// PeerConnection hazırla
if (!webRTCServiceRef.current?.getPeerConnection(remoteUserId)) {
await webRTCServiceRef.current?.createPeerConnection(remoteUserId)
}
// 🔑 Çakışmayı önle: sadece idsi küçük olan offer başlatır
if (user.id < remoteUserId) {
const offer = await webRTCServiceRef.current!.createOffer(remoteUserId)
await signalRServiceRef.current?.sendOffer(classSession.id, remoteUserId, offer)
}
},
)
// 🔑 Odaya girdiğinde var olan katılımcılar
signalRServiceRef.current.setExistingParticipantsHandler(
async (
existing: { userId: string; userName: string; isTeacher: boolean; isActive: boolean }[],
) => {
for (const participant of existing) {
if (!participant.isActive) continue
if (participant.userId === user.id) continue
// Statee ekle
setParticipants((prev) => {
if (prev.find((p) => p.id === participant.userId)) return prev
return [
...prev,
{
id: participant.userId,
name: participant.userName,
sessionId: classSession.id,
isTeacher: participant.isTeacher,
isAudioMuted: classSettings.defaultMicrophoneState === 'muted',
isVideoMuted: classSettings.defaultCameraState === 'off',
isActive: true,
},
]
})
// PeerConnection hazırla
if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) {
await webRTCServiceRef.current?.createPeerConnection(participant.userId)
}
// 🔑 Çakışmayı önle
if (user.id < participant.userId) {
const offer = await webRTCServiceRef.current!.createOffer(participant.userId)
await signalRServiceRef.current?.sendOffer(classSession.id, participant.userId, offer)
}
}
},
)
signalRServiceRef.current.setForceCleanupHandler(() => {
webRTCServiceRef.current?.closeAllConnections()
localStream?.getTracks().forEach((t) => t.stop())
})
signalRServiceRef.current.setParticipantLeaveHandler(({ userId, sessionId, userName }) => {
if (userId !== user.id) {
toast.push(
<Notification
title={`Katılımcı ayrıldı: ${userName ?? 'Bilinmeyen'}`}
type="warning"
/>,
{ placement: 'bottom-end' },
)
}
// peer connectionı kapat
webRTCServiceRef.current?.closePeerConnection(userId)
// katılımcıyı stateden tamamen sil
setParticipants((prev) =>
prev.filter((p) => !(p.id === userId && p.sessionId === sessionId)),
)
if (participants.find((p) => p.id === userId)?.isTeacher) {
setTeacherDisconnected(true)
}
})
signalRServiceRef.current.setAttendanceUpdatedHandler((record) => {
setAttendanceRecords((prev) => {
const existing = prev.find((r) => r.id === record.id)
if (existing) {
return prev.map((r) => (r.id === record.id ? record : r))
}
return [...prev, record]
})
})
signalRServiceRef.current.setChatMessageReceivedHandler((message) => {
setChatMessages((prev) => [...prev, message])
})
signalRServiceRef.current.setParticipantMutedHandler(async (userId, isMuted) => {
setParticipants((prev) =>
prev.map((p) => (p.id === userId ? { ...p, isAudioMuted: isMuted } : p)),
)
// Eğer mute edilen kişi currentUser ise → kendi mikrofonunu kapat
if (userId === user.id) {
await webRTCServiceRef.current?.toggleAudio(!isMuted)
setIsAudioEnabled(!isMuted)
}
})
// Hand raise events
signalRServiceRef.current.setHandRaiseReceivedHandler((studentId) => {
setParticipants((prev) =>
prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: true } : p)),
)
})
signalRServiceRef.current.setHandRaiseDismissedHandler((studentId) => {
setParticipants((prev) =>
prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: false } : p)),
)
// 👇 kendi stateini de sıfırla
if (studentId === user.id) {
setHasRaisedHand(false)
}
})
// Join the class
await signalRServiceRef.current.joinClass(
classSession.id,
user.id,
user.name,
user.role === 'teacher',
true,
)
} catch (error) {
console.error('Sınıf servisleri başlatılamadı:', error)
toast.push(
<Notification
title="❌ Sınıf servisleri başlatılamadı. Bağlantınızı veya tarayıcı izinlerini kontrol edin."
type="danger"
/>,
{ placement: 'bottom-end' },
)
}
}
const cleanup = async () => {
if (signalRServiceRef.current) {
// ✅ önce LeaveClass
await signalRServiceRef.current.leaveClass(classSession.id)
// sonra disconnect
await signalRServiceRef.current.disconnect()
}
webRTCServiceRef.current?.closeAllConnections()
}
const handleToggleAudio = () => {
setIsAudioEnabled(!isAudioEnabled)
webRTCServiceRef.current?.toggleAudio(!isAudioEnabled)
}
const handleToggleVideo = () => {
setIsVideoEnabled(!isVideoEnabled)
webRTCServiceRef.current?.toggleVideo(!isVideoEnabled)
}
const handleLeaveCall = async () => {
try {
// Eğer teacher ise sınıfı kapat
if (user.role === 'teacher' && user.id === classSession.teacherId) {
await endVideoroom(classSession.id)
}
// Bağlantıları kapat
await cleanup()
// Başka sayfaya yönlendir
navigate(ROUTES_ENUM.protected.admin.videoroom.roomList)
} catch (err) {
console.error(ıkış sırasında hata oluştu:', err)
toast.push(<Notification title="⚠️ Çıkış sırasında hata oluştu" type="warning" />, {
placement: 'bottom-end',
})
navigate(ROUTES_ENUM.protected.admin.videoroom.roomList)
}
}
const handleSendMessage = async (e: FormEvent) => {
e.preventDefault()
if (newMessage.trim() && signalRServiceRef.current) {
if (messageMode === 'private' && selectedRecipient) {
try {
await signalRServiceRef.current.sendPrivateMessage(
classSession.id,
user.id,
user.name,
newMessage.trim(),
selectedRecipient.id,
selectedRecipient.name,
user.role === 'teacher',
)
} catch (error) {
console.error('Özel mesaj gönderilemedi:', error)
toast.push(<Notification title="❌ Özel mesaj gönderilemedi" type="danger" />, {
placement: 'bottom-end',
})
}
} else if (messageMode === 'announcement' && user.role === 'teacher') {
try {
await signalRServiceRef.current.sendAnnouncement(
classSession.id,
user.id,
user.name,
newMessage.trim(),
user.role === 'teacher',
)
} catch (error) {
console.error('Duyuru gönderilemedi:', error)
toast.push(<Notification title="❌ Duyuru gönderilemedi" type="danger" />, {
placement: 'bottom-end',
})
}
} else {
try {
await signalRServiceRef.current.sendChatMessage(
classSession.id,
user.id,
user.name,
newMessage.trim(),
user.role === 'teacher',
)
} catch (error) {
console.error('Genel mesaj gönderilemedi:', error)
toast.push(<Notification title="❌ Genel mesaj gönderilemedi" type="danger" />, {
placement: 'bottom-end',
})
}
}
setNewMessage('')
}
}
const handleMuteParticipant = async (
participantId: string,
isMuted: boolean,
isTeacher: boolean,
) => {
if (signalRServiceRef.current && user.role === 'teacher') {
try {
await signalRServiceRef.current.muteParticipant(
classSession.id,
participantId,
isMuted,
isTeacher,
)
} catch (err) {
console.error('Katılımcı susturulamadı:', err)
toast.push(<Notification title="❌ Katılımcı susturulamadı" type="danger" />, {
placement: 'bottom-end',
})
}
}
}
const handleMuteAll = async () => {
if (signalRServiceRef.current && user.role === 'teacher') {
const newMuteState = !isAllMuted
setIsAllMuted(newMuteState)
// Mute all participants except teacher
for (const participant of participants) {
if (!participant.isTeacher) {
await signalRServiceRef.current.muteParticipant(
classSession.id,
participant.id,
newMuteState,
user.role === 'teacher',
)
}
}
}
}
const handleRaiseHand = async () => {
if (
signalRServiceRef.current &&
user.role === 'student' &&
!hasRaisedHand &&
classSettings.allowHandRaise
) {
await signalRServiceRef.current.raiseHand(classSession.id, user.id, user.name)
setHasRaisedHand(true)
}
}
const handleKickParticipant = async (participantId: string, participantName: string) => {
if (signalRServiceRef.current && user.role === 'teacher') {
try {
await signalRServiceRef.current.kickParticipant(
classSession.id,
participantId,
participantName,
)
setAttendanceRecords((prev) =>
prev.map((r) => {
if (r.studentId === participantId && !r.leaveTime) {
const leaveTime = new Date().toISOString()
const join = new Date(r.joinTime)
const leave = new Date(leaveTime)
const totalDurationMinutes = Math.max(
1,
Math.round((leave.getTime() - join.getTime()) / 60000),
)
return { ...r, leaveTime, totalDurationMinutes }
}
return r
}),
)
} catch (error) {
console.error('Katılımcı atılamadı:', error)
toast.push(<Notification title="❌ Katılımcı atılamadı" type="danger" />, {
placement: 'bottom-end',
})
}
}
}
const handleUploadDocument = async (file: File) => {
// In a real app, this would upload to a server
const newDoc: VideoroomDocumentDto = {
id: crypto.randomUUID(),
name: file.name,
url: URL.createObjectURL(file),
type: file.type,
size: file.size,
uploadedAt: new Date().toISOString(),
uploadedBy: user.name,
}
setDocuments((prev) => [...prev, newDoc])
}
const handleDeleteDocument = (documentId: string) => {
setDocuments((prev) => prev.filter((d) => d.id !== documentId))
}
const handleViewDocument = (document: VideoroomDocumentDto) => {
window.open(document.url, '_blank')
}
const handleStartScreenShare = async () => {
try {
// 1. sadece ekran videosu al
const screen = await navigator.mediaDevices.getDisplayMedia({
video: true,
})
// 2. mikrofonu ayrı al
let mic: MediaStream | null = null
try {
mic = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
console.error('Mikrofon alınamadı:', err)
toast.push(
<Notification
title="⚠️ Mikrofon alınamadı. Sadece ekran paylaşımı yapılacak."
type="warning"
/>,
{ placement: 'bottom-end' },
)
}
// 3. merge et
if (mic) {
mic.getAudioTracks().forEach((track) => screen.addTrack(track))
}
setScreenStream(screen)
setIsScreenSharing(true)
setScreenSharer(user.name)
webRTCServiceRef.current?.addStreamToPeers(screen)
// Handle stream end
screen.getVideoTracks()[0].onended = () => {
handleStopScreenShare()
}
} catch (error) {
console.error('Ekran paylaşımı başlatılamadı:', error)
toast.push(<Notification title="❌ Ekran paylaşımı başlatılamadı" type="danger" />, {
placement: 'bottom-end',
})
}
}
const handleStopScreenShare = () => {
if (screenStream) {
// PeerConnectionstan kaldır
screenStream.getTracks().forEach((track) => {
webRTCServiceRef.current?.removeTrackFromPeers(track)
track.stop()
})
setScreenStream(undefined)
}
setIsScreenSharing(false)
setScreenSharer(undefined)
}
const handleLayoutChange = (layout: VideoroomLayoutDto) => {
setCurrentLayout(layout)
if (layout.type === 'grid') {
setFocusedParticipant(undefined)
}
}
const handleParticipantFocus = (participantId: string | undefined) => {
setFocusedParticipant(participantId)
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
setIsFullscreen(true)
} else {
document.exitFullscreen()
setIsFullscreen(false)
}
}
const toggleSidePanel = (panelType: SidePanelType) => {
setActiveSidePanel(activeSidePanel === panelType ? null : panelType)
}
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('tr-TR', {
hour: '2-digit',
minute: '2-digit',
})
}
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}h ${mins}m`
}
return `${mins}m`
}
const getFileIcon = (type: string) => {
if (type.includes('pdf')) return <FaFilePdf className="text-red-500" />
if (
type.includes('word') ||
type.includes('doc') ||
type.includes('presentation') ||
type.includes('powerpoint')
)
return <FaFileWord className="text-blue-500" />
if (type.includes('image')) return <FaFileImage className="text-green-500" />
return <FaFileAlt className="text-gray-500" />
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// ...existing code...
// ...existing code...
// --- yan panel fonksiyonu ---
const renderSidePanel = () => {
if (!activeSidePanel) return null
switch (activeSidePanel) {
case 'chat':
return (
<ChatPanel
user={user}
participants={participants}
chatMessages={chatMessages}
newMessage={newMessage}
setNewMessage={setNewMessage}
messageMode={messageMode}
setMessageMode={setMessageMode}
selectedRecipient={selectedRecipient}
setSelectedRecipient={setSelectedRecipient}
onSendMessage={handleSendMessage}
onClose={() => setActiveSidePanel(null)}
formatTime={formatTime}
classSettings={classSettings}
/>
)
case 'participants':
return (
<ParticipantsPanel
user={user}
participants={participants}
attendanceRecords={attendanceRecords}
onMuteParticipant={handleMuteParticipant}
onKickParticipant={handleKickParticipant}
onDismissHandRaise={handleDismissHandRaise}
onClose={() => setActiveSidePanel(null)}
formatTime={formatTime}
formatDuration={formatDuration}
/>
)
// ...existing code...
case 'documents':
return (
<DocumentsPanel
user={user}
documents={documents}
onUpload={handleUploadDocument}
onDelete={handleDeleteDocument}
onView={handleViewDocument}
onClose={() => setActiveSidePanel(null)}
formatFileSize={formatFileSize}
getFileIcon={getFileIcon}
/>
)
case 'layout':
return (
<LayoutPanel
layouts={layouts}
currentLayout={currentLayout ?? layouts[0]}
onChangeLayout={handleLayoutChange}
onClose={() => setActiveSidePanel(null)}
/>
)
default:
return null
}
}
useEffect(() => {
window.addEventListener('beforeunload', handleLeaveCall)
window.addEventListener('pagehide', handleLeaveCall)
return () => {
window.removeEventListener('beforeunload', handleLeaveCall)
window.removeEventListener('pagehide', handleLeaveCall)
}
}, [classSession.id])
return (
<>
<Helmet
titleTemplate="%s | Erp Platform"
title={translate('::' + 'App.Videoroom.RoomDetail')}
defaultTitle="Erp Platform"
></Helmet>
{teacherDisconnected && (
<div className="bg-red-600 text-white p-2 text-center">
Öğretmenin bağlantısı koptu, tekrar bağlanmasını bekleyin...
</div>
)}
<div className="min-h-screen h-screen flex flex-col bg-gray-900 text-white overflow-hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col relative overflow-hidden"
>
{/* Main Content Area */}
<div className="flex-1 flex flex-col lg:flex-row relative min-h-0 h-full">
{/* Left Content Area - Video and Screen Share */}
<div
className={`flex-1 flex flex-col min-h-0 h-full transition-all duration-300 ${!activeSidePanel ? 'flex items-center justify-center' : ''}`}
>
{/* Video Container - Panel kapalıyken ortalanmış */}
<div
className={`${!activeSidePanel ? 'w-full max-w-6xl' : 'w-full h-full'} flex flex-col min-h-0 h-full`}
>
{/* Screen Share Panel */}
{(isScreenSharing || screenStream) && (
<div className="p-2 sm:p-4 flex-shrink-0">
<ScreenSharePanel
isSharing={isScreenSharing}
onStartShare={handleStartScreenShare}
onStopShare={handleStopScreenShare}
sharedScreen={screenStream}
sharerName={screenSharer}
/>
</div>
)}
{/* Video Grid */}
<div className="flex-1 relative overflow-hidden min-h-0 h-full">
<RoomParticipant
participants={participants.filter((p) => p.isActive)}
localStream={user.role === 'observer' ? undefined : localStream}
currentUserId={user.id}
currentUserName={user.name}
isTeacher={user.role === 'teacher'}
isAudioEnabled={isAudioEnabled}
isVideoEnabled={isVideoEnabled}
onMuteParticipant={handleMuteParticipant}
layout={currentLayout ?? layouts[0]}
focusedParticipant={focusedParticipant}
onParticipantFocus={handleParticipantFocus}
hasSidePanel={!!activeSidePanel}
onKickParticipant={
user.role === 'teacher'
? (participantId) => {
const participant = participants.find((p) => p.id === participantId)
if (participant) {
setKickingParticipant({ id: participant.id, name: participant.name })
}
}
: undefined
}
/>
</div>
</div>
</div>
{/* Side Panel */}
{activeSidePanel && (
<div className="fixed inset-0 z-40 bg-white flex flex-col w-full h-full lg:relative lg:inset-auto lg:w-80 lg:h-full lg:z-0 lg:bg-white">
{renderSidePanel()}
</div>
)}
</div>
{/* Bottom Control Bar - Google Meet Style */}
<div className="bg-gray-800 p-2 sm:p-3 flex-shrink-0">
{/* Mobile Layout */}
<div className="flex lg:hidden items-center justify-between">
{/* Left Side - Main Controls */}
<div className="flex items-center space-x-1 sm:space-x-2">
{/* Audio Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleAudio}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-2 text-white transition-all sm:!p-3 ${
isAudioEnabled
? '!bg-gray-700 hover:!bg-gray-600'
: '!bg-red-600 hover:!bg-red-700'
}`}
title={isAudioEnabled ? 'Mikrofonu Kapat' : 'Mikrofonu Aç'}
>
{isAudioEnabled ? <FaMicrophone size={16} /> : <FaMicrophoneSlash size={16} />}
</Button>
)}
{/* Video Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleVideo}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-2 text-white transition-all sm:!p-3 ${
isVideoEnabled
? '!bg-gray-700 hover:!bg-gray-600'
: '!bg-red-600 hover:!bg-red-700'
}`}
title={isVideoEnabled ? 'Kamerayı Kapat' : 'Kamerayı Aç'}
>
{isVideoEnabled ? <FaVideo size={16} /> : <FaVideoSlash size={16} />}
</Button>
)}
{/* Screen Share */}
{(user.role === 'teacher' || classSettings.allowStudentScreenShare) && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={isScreenSharing ? handleStopScreenShare : handleStartScreenShare}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-2 text-white transition-all sm:!p-3 ${
isScreenSharing
? '!bg-blue-600 hover:!bg-blue-700'
: '!bg-gray-700 hover:!bg-gray-600'
}`}
title={isScreenSharing ? 'Paylaşımı Durdur' : 'Ekranı Paylaş'}
>
<FaDesktop size={16} />
</Button>
)}
{/* Hand Raise (Students) */}
{user.role === 'student' && classSettings.allowHandRaise && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleRaiseHand}
disabled={hasRaisedHand}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-2 text-white transition-all sm:!p-3 ${
hasRaisedHand
? '!bg-yellow-600 cursor-not-allowed'
: '!bg-gray-700 hover:!bg-yellow-600'
}`}
title={hasRaisedHand ? 'Parmak Kaldırıldı' : 'Parmak Kaldır'}
>
<FaHandPaper size={16} />
</Button>
)}
{/* Leave Call */}
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleLeaveCall}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-red-600 !p-2 text-white transition-all hover:!bg-red-700 sm:!p-3"
title="Aramayı Sonlandır"
>
<FaPhone size={16} />
</Button>
</div>
{/* Right Side - Panel Controls */}
<div className="flex items-center">
<Button
type="button"
variant="plain"
shape="none"
className="!inline-flex !h-auto items-center justify-center rounded-lg !bg-gray-700 !p-2 text-white hover:!bg-gray-600"
onClick={() => setMobileMenuOpen(true)}
aria-label="Menüyü Aç"
>
<FaBars size={20} />
</Button>
</div>
{/* Hamburger Menu Modal */}
{mobileMenuOpen && (
<>
{/* Overlay */}
<div
className="fixed inset-0 z-40 bg-black bg-opacity-40"
onClick={() => setMobileMenuOpen(false)}
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed inset-0 z-50 w-full h-full bg-white shadow-2xl flex flex-col p-0 lg:top-0 lg:right-0 lg:w-80 lg:h-full lg:inset-y-0 lg:left-auto"
>
<div className="flex items-center justify-between px-4 py-4 border-b">
<span className="font-semibold text-gray-800 text-lg">Menü</span>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => setMobileMenuOpen(false)}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-transparent !p-2 hover:!bg-gray-100"
>
<FaTimes size={22} />
</Button>
</div>
<div className="flex-1 flex flex-col space-y-1 px-2 py-2 overflow-y-auto">
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('chat'), 200)
}}
className={`!inline-flex !h-auto items-center gap-2 rounded-lg !p-3 text-base transition-all ${activeSidePanel === 'chat' ? '!bg-blue-100 text-blue-700' : '!bg-transparent text-gray-700 hover:!bg-gray-100'}`}
>
<FaComments /> <span>Sohbet</span>
{chatMessages.length > 0 && (
<span className="ml-auto bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{chatMessages.length > 9 ? '9+' : chatMessages.length}
</span>
)}
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('participants'), 200)
}}
className={`!inline-flex !h-auto items-center gap-2 rounded-lg !p-3 text-base transition-all relative ${activeSidePanel === 'participants' ? '!bg-blue-100 text-blue-700' : '!bg-transparent text-gray-700 hover:!bg-gray-100'}`}
>
<FaUserFriends />
<span>Katılımcılar</span>
{/* El kaldıran badge */}
{raisedHandsCount > 0 && (
<span className="absolute top-2 right-3 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{raisedHandsCount > 9 ? '9+' : raisedHandsCount}
</span>
)}
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('documents'), 200)
}}
className={`!inline-flex !h-auto items-center gap-2 rounded-lg !p-3 text-base transition-all ${activeSidePanel === 'documents' ? '!bg-blue-100 text-blue-700' : '!bg-transparent text-gray-700 hover:!bg-gray-100'}`}
>
<FaFile /> <span>Dokümanlar</span>
</Button>
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleSidePanel('layout'), 200)
}}
className={`!inline-flex !h-auto items-center gap-2 rounded-lg !p-3 text-base transition-all ${activeSidePanel === 'layout' ? '!bg-blue-100 text-blue-700' : '!bg-transparent text-gray-700 hover:!bg-gray-100'}`}
>
<FaLayerGroup /> <span>Görünüm</span>
</Button>
{user.role === 'teacher' && (
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => handleMuteAll(), 200)
}}
className="!inline-flex !h-auto items-center gap-2 rounded-lg !bg-transparent !p-3 text-base text-gray-700 transition-all hover:!bg-gray-100"
>
{isAllMuted ? <FaVolumeUp /> : <FaVolumeMute />}{' '}
<span>{isAllMuted ? 'Hepsinin Sesini Aç' : 'Hepsini Sustur'}</span>
</Button>
)}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => {
setMobileMenuOpen(false)
setTimeout(() => toggleFullscreen(), 200)
}}
className="!inline-flex !h-auto items-center gap-2 rounded-lg !bg-transparent !p-3 text-base text-gray-700 transition-all hover:!bg-gray-100"
>
{isFullscreen ? <FaCompress /> : <FaExpand />}{' '}
<span>{isFullscreen ? 'Tam Ekrandan Çık' : 'Tam Ekran'}</span>
</Button>
</div>
</motion.div>
</>
)}
</div>
{/* Desktop Layout */}
<div className="hidden lg:flex items-center justify-center relative">
{/* Left Side - Meeting Info */}
<div className="flex items-center space-x-4 text-white absolute left-0">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium truncate">{classSession?.name}</span>
<div className="w-px h-4 bg-gray-600"></div>
<span className="text-sm text-gray-300">
{new Date().toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
{/* Center - Main Controls */}
<div className="flex items-center space-x-2">
{/* Audio Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleAudio}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-3 text-white transition-all ${
isAudioEnabled
? '!bg-gray-700 hover:!bg-gray-600'
: '!bg-red-600 hover:!bg-red-700'
}`}
title={isAudioEnabled ? 'Mikrofonu Kapat' : 'Mikrofonu Aç'}
>
{isAudioEnabled ? <FaMicrophone size={16} /> : <FaMicrophoneSlash size={16} />}
</Button>
)}
{/* Video Control */}
{user.role !== 'observer' && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleToggleVideo}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-3 text-white transition-all ${
isVideoEnabled
? '!bg-gray-700 hover:!bg-gray-600'
: '!bg-red-600 hover:!bg-red-700'
}`}
title={isVideoEnabled ? 'Kamerayı Kapat' : 'Kamerayı Aç'}
>
{isVideoEnabled ? <FaVideo size={16} /> : <FaVideoSlash size={16} />}
</Button>
)}
{/* Screen Share */}
{(user.role === 'teacher' || classSettings.allowStudentScreenShare) && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={isScreenSharing ? handleStopScreenShare : handleStartScreenShare}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-3 text-white transition-all ${
isScreenSharing
? '!bg-blue-600 hover:!bg-blue-700'
: '!bg-gray-700 hover:!bg-gray-600'
}`}
title={isScreenSharing ? 'Paylaşımı Durdur' : 'Ekranı Paylaş'}
>
<FaDesktop size={16} />
</Button>
)}
{/* Hand Raise (Students) */}
{user.role === 'student' && classSettings.allowHandRaise && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleRaiseHand}
disabled={hasRaisedHand}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-3 text-white transition-all ${
hasRaisedHand
? '!bg-yellow-600 cursor-not-allowed'
: '!bg-gray-700 hover:!bg-yellow-600'
}`}
title={hasRaisedHand ? 'Parmak Kaldırıldı' : 'Parmak Kaldır'}
>
<FaHandPaper size={16} />
</Button>
)}
{/* Leave Call */}
<Button
type="button"
variant="plain"
shape="circle"
onClick={handleLeaveCall}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-red-600 !p-3 text-white transition-all hover:!bg-red-700"
title="Aramayı Sonlandır"
>
<FaPhone size={16} />
</Button>
</div>
{/* Right Side - Panel Controls */}
<div className="flex items-center space-x-2 absolute right-0">
{/* Fullscreen Toggle */}
<Button
type="button"
variant="plain"
shape="none"
onClick={toggleFullscreen}
className="!inline-flex !h-auto items-center justify-center rounded-lg !bg-gray-700 !p-2 text-white transition-all hover:!bg-gray-600"
title={isFullscreen ? 'Tam Ekrandan Çık' : 'Tam Ekran'}
>
{isFullscreen ? <FaCompress size={14} /> : <FaExpand size={14} />}
</Button>
{/* Chat */}
{((user.role !== 'observer' && classSettings.allowStudentChat) ||
user.role === 'teacher') && (
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('chat')}
className={`!inline-flex !h-auto items-center justify-center rounded-lg !p-2 transition-all relative ${
activeSidePanel === 'chat'
? '!bg-blue-600 text-white'
: '!bg-gray-700 hover:!bg-gray-600 text-white'
}`}
title="Sohbet"
>
<FaComments size={14} />
{chatMessages.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{chatMessages.length > 9 ? '9+' : chatMessages.length}
</span>
)}
</Button>
)}
{/* Participants */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('participants')}
className={`!inline-flex !h-auto items-center justify-center rounded-lg !p-2 transition-all relative ${
activeSidePanel === 'participants'
? '!bg-blue-600 text-white'
: '!bg-gray-700 hover:!bg-gray-600 text-white'
}`}
title="Katılımcılar"
>
<FaUserFriends size={14} />
{/* El kaldıran badge */}
{raisedHandsCount > 0 && (
<span className="absolute -top-1 -right-3 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{raisedHandsCount > 9 ? '9+' : raisedHandsCount}
</span>
)}
</Button>
{/* Teacher Only Options */}
{user.role === 'teacher' && (
<>
{/* Documents Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('documents')}
className={`!inline-flex !h-auto items-center justify-center rounded-lg !p-2 transition-all ${
activeSidePanel === 'documents'
? '!bg-blue-600 text-white'
: '!bg-gray-700 hover:!bg-gray-600 text-white'
}`}
title="Dokümanlar"
>
<FaFile size={14} />
</Button>
{/* Mute All Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={handleMuteAll}
className="!inline-flex !h-auto items-center justify-center rounded-lg !bg-gray-700 !p-2 text-white transition-all hover:!bg-gray-600"
title={isAllMuted ? 'Hepsinin Sesini Aç' : 'Hepsini Sustur'}
>
{isAllMuted ? <FaVolumeUp size={14} /> : <FaVolumeMute size={14} />}
</Button>
</>
)}
{/* Layout Button */}
<Button
type="button"
variant="plain"
shape="none"
onClick={() => toggleSidePanel('layout')}
className={`!inline-flex !h-auto items-center justify-center rounded-lg !p-2 transition-all ${
activeSidePanel === 'layout'
? '!bg-blue-600 text-white'
: '!bg-gray-700 hover:!bg-gray-600 text-white'
}`}
title="Layout"
>
<FaLayerGroup size={14} />
</Button>
</div>
</div>
</div>
{/* Kick Participant Modal */}
<KickParticipantModal
participant={kickingParticipant}
isOpen={!!kickingParticipant}
onClose={() => setKickingParticipant(null)}
onConfirm={handleKickParticipant}
/>
</motion.div>
</div>
</>
)
}
export default RoomDetail