Classroom backend ve ui

This commit is contained in:
Sedat ÖZTÜRK 2025-08-26 11:39:09 +03:00
parent 217c68b853
commit e4544ad1e7
27 changed files with 5530 additions and 2 deletions

View file

@ -99,6 +99,10 @@
{
"Name": "App.Contact",
"DisplayName": "App.Contact"
},
{
"Name": "App.Classroom",
"DisplayName": "App.Classroom"
}
],
"PermissionDefinitionRecords": [
@ -2799,6 +2803,38 @@
"DisplayName": "Import",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Classroom",
"Name": "App.Classroom",
"ParentName": null,
"DisplayName": "App.Classroom",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Classroom",
"Name": "App.Classroom.Dashboard",
"ParentName": "App.Classroom",
"DisplayName": "App.Classroom.Dashboard",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Classroom",
"Name": "App.Classroom.List",
"ParentName": "App.Classroom",
"DisplayName": "App.Classroom.List",
"IsEnabled": true,
"MultiTenancySide": 2
},
{
"GroupName": "App.Classroom",
"Name": "App.Classroom.RoomDetail",
"ParentName": "App.Classroom",
"DisplayName": "App.Classroom.RoomDetail",
"IsEnabled": true,
"MultiTenancySide": 2
}
],
"Menus": [
@ -3501,6 +3537,16 @@
"Icon": "FaSynagogue",
"RequiredPermissionName": "App.Definitions.UomCategory",
"IsDisabled": false
},
{
"ParentCode": "App.Administration",
"Code": "App.Classroom",
"DisplayName": "App.Classroom",
"Order": 5,
"Url": "/admin/classroom/dashboard",
"Icon": "FcNeutralDecision",
"RequiredPermissionName": "App.Classroom.Dashboard",
"IsDisabled": false
}
],
"Routes": [
@ -3874,6 +3920,27 @@
"componentPath": "@/views/report/ReportViewerPage",
"routeType": "protected",
"authority": ["App.Reports.Categories"]
},
{
"key": "admin.classroom.dashboard",
"path": "/admin/classroom/dashboard",
"componentPath": "@/views/classroom/DashboardPage",
"routeType": "protected",
"authority": ["App.Classroom.Dashboard"]
},
{
"key": "admin.classroom.classes",
"path": "/admin/classroom/classes",
"componentPath": "@/views/classroom/ClassListPage",
"routeType": "protected",
"authority": ["App.Classroom.List"]
},
{
"key": "admin.classroom.classroom",
"path": "/admin/classroom/room/:id",
"componentPath": "@/views/classroom/RoomPage",
"routeType": "protected",
"authority": ["App.Classroom.RoomDetail"]
}
],
"Languages": [
@ -14095,6 +14162,30 @@
"key": "App.Contact",
"tr": "İletişim",
"en": "Contact"
},
{
"resourceName": "Platform",
"key": "App.Classroom",
"tr": "Sınıf",
"en": "Classroom"
},
{
"resourceName": "Platform",
"key": "App.Classroom.Dashboard",
"tr": "Gösterge Paneli",
"en": "Dashboard"
},
{
"resourceName": "Platform",
"key": "App.Classroom.List",
"tr": "Sınıflar",
"en": "Classes"
},
{
"resourceName": "Platform",
"key": "App.Classroom.RoomDetail",
"tr": "Virtul Classroom",
"en": "Sanal Sınıf"
}
],
"Settings": [

View file

@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.c7pq42r4d5g"
"revision": "0.9qu602jrc3g"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

182
ui/package-lock.json generated
View file

@ -22,6 +22,7 @@
"@fullcalendar/react": "^6.1.8",
"@fullcalendar/timegrid": "^6.1.8",
"@marsidev/react-turnstile": "^0.2.1",
"@microsoft/signalr": "^9.0.6",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-table": "^8.8.5",
@ -2714,6 +2715,19 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@microsoft/signalr": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz",
"integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
@ -3985,6 +3999,18 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -6687,11 +6713,29 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exceljs": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@ -6804,6 +6848,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@ -8770,6 +8824,48 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -9871,15 +9967,32 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -10460,6 +10573,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@ -10709,6 +10828,12 @@
"randombytes": "^2.1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -11687,6 +11812,30 @@
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@ -12144,6 +12293,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
@ -12921,6 +13080,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",

View file

@ -15,6 +15,7 @@
"format": "npm run prettier:fix && npm run lint:fix"
},
"dependencies": {
"@microsoft/signalr": "^9.0.6",
"@babel/generator": "^7.28.3",
"@babel/parser": "^7.28.0",
"@babel/standalone": "^7.28.0",

View file

@ -0,0 +1,941 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
FaPlus,
FaCalendarAlt,
FaClock,
FaUsers,
FaPlay,
FaEdit,
FaTrash,
FaEye,
} from 'react-icons/fa'
import { ClassroomDto } from '@/proxy/classroom/models'
import { initialScheduledClasses } from '@/proxy/classroom/data'
import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant'
interface DashboardProps {
onCreateClass: (classData: Partial<ClassroomDto>) => void
onJoinClass: (classSession: ClassroomDto) => void
onEditClass: (classId: string, classData: Partial<ClassroomDto>) => void
onDeleteClass: (classId: string) => void
}
export const ClassList: React.FC<DashboardProps> = ({
onCreateClass,
onJoinClass,
onEditClass,
onDeleteClass,
}) => {
const navigate = useNavigate()
const { user } = useStoreState((state) => state.auth)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editingClass, setEditingClass] = useState<ClassroomDto | null>(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deletingClass, setDeletingClass] = useState<ClassroomDto | null>(null)
const [scheduledClasses, setScheduledClasses] = useState<ClassroomDto[]>(initialScheduledClasses)
const [formData, setFormData] = useState({
name: '',
description: '',
subject: '',
scheduledStartTime: '',
duration: 60,
maxParticipants: 30,
settings: {
allowHandRaise: true,
defaultMicrophoneState: 'muted' as 'muted' | 'unmuted',
defaultCameraState: 'on' as 'on' | 'off',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
recordSession: false,
waitingRoomEnabled: false,
},
})
const canJoinClass = (scheduledTime: string) => {
const scheduled = new Date(scheduledTime)
const now = new Date()
const tenMinutesBefore = new Date(scheduled.getTime() - 10 * 60 * 1000)
const twoHoursAfter = new Date(scheduled.getTime() + 2 * 60 * 60 * 1000) // 2 saat sonrasına kadar
return now >= tenMinutesBefore && now <= twoHoursAfter
}
const getTimeUntilClass = (scheduledTime: string) => {
const scheduled = new Date(scheduledTime)
const now = new Date()
const diff = scheduled.getTime() - now.getTime()
if (diff <= 0) {
// Sınıf başladıysa, ne kadar süredir devam ettiğini göster
const elapsed = Math.abs(diff)
const elapsedMinutes = Math.floor(elapsed / (1000 * 60))
if (elapsedMinutes < 60) {
return `${elapsedMinutes} dakikadır devam ediyor`
}
const elapsedHours = Math.floor(elapsedMinutes / 60)
const remainingMinutes = elapsedMinutes % 60
return `${elapsedHours}s ${remainingMinutes}d devam ediyor`
}
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
return `${hours}s ${minutes}d kaldı`
}
return `${minutes}d kaldı`
}
const handleCreateClass = (e: React.FormEvent) => {
e.preventDefault()
const newClass: Partial<ClassroomDto> = {
...formData,
id: `class-${Date.now()}`,
teacherId: user.id,
teacherName: user.name,
isActive: false,
isScheduled: true,
participantCount: 0,
}
onCreateClass(newClass)
setScheduledClasses((prev) => [...prev, newClass as ClassroomDto])
setShowCreateModal(false)
setFormData({
name: '',
description: '',
subject: '',
scheduledStartTime: '',
duration: 60,
maxParticipants: 30,
settings: {
allowHandRaise: true,
defaultMicrophoneState: 'muted',
defaultCameraState: 'on',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
recordSession: false,
waitingRoomEnabled: false,
},
})
// Yeni oluşturulan sınıfa yönlendir
if (newClass.id) {
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', newClass.id))
}
}
const handleEditClass = (e: React.FormEvent) => {
e.preventDefault()
if (!editingClass) return
const updatedClass = {
...editingClass,
...formData,
}
setScheduledClasses((prev) => prev.map((c) => (c.id === editingClass.id ? updatedClass : c)))
onEditClass(editingClass.id, formData)
setShowEditModal(false)
setEditingClass(null)
resetForm()
}
const handleDeleteClass = () => {
if (!deletingClass) return
setScheduledClasses((prev) => prev.filter((c) => c.id !== deletingClass.id))
onDeleteClass(deletingClass.id)
setShowDeleteModal(false)
setDeletingClass(null)
}
const openEditModal = (classSession: ClassroomDto) => {
setEditingClass(classSession)
setFormData({
name: classSession.name,
description: classSession.description || '',
subject: classSession.subject || '',
scheduledStartTime: new Date(classSession.scheduledStartTime).toISOString().slice(0, 16),
duration: classSession.duration || 60,
maxParticipants: classSession.maxParticipants || 30,
settings: classSession.settings || {
allowHandRaise: true,
defaultMicrophoneState: 'muted',
defaultCameraState: 'on',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
recordSession: false,
waitingRoomEnabled: false,
},
})
setShowEditModal(true)
}
const openDeleteModal = (classSession: ClassroomDto) => {
setDeletingClass(classSession)
setShowDeleteModal(true)
}
const resetForm = () => {
setFormData({
name: '',
description: '',
subject: '',
scheduledStartTime: '',
duration: 60,
maxParticipants: 30,
settings: {
allowHandRaise: true,
defaultMicrophoneState: 'muted',
defaultCameraState: 'on',
defaultLayout: 'grid',
allowStudentScreenShare: false,
allowStudentChat: true,
allowPrivateMessages: true,
autoMuteNewParticipants: true,
recordSession: false,
waitingRoomEnabled: false,
},
})
}
const handleStartClass = (classSession: ClassroomDto) => {
const updatedClass = {
...classSession,
isActive: true,
startTime: new Date().toISOString(),
}
setScheduledClasses((prev) => prev.map((c) => (c.id === classSession.id ? updatedClass : c)))
onJoinClass(updatedClass)
// Sınıf başlatıldığında classroom ekranına yönlendir
if (updatedClass.id) {
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', updatedClass.id))
}
}
const formatDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">
Sanal Sınıf Dashboard
</h1>
<p className="text-gray-600">Hoş geldiniz, {user.name}</p>
</div>
{user.role === 'teacher' && (
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center justify-center space-x-2 bg-blue-600 text-white px-4 sm:px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors w-full sm:w-auto"
>
<FaPlus size={20} />
<span className="hidden sm:inline">Yeni Sınıf Oluştur</span>
<span className="sm:hidden">Yeni Sınıf</span>
</button>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 mb-6 sm:mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-blue-100 rounded-full">
<FaCalendarAlt className="text-blue-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Toplam Sınıf</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{scheduledClasses.length}
</p>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-green-100 rounded-full">
<FaPlay className="text-green-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Aktif Sınıf</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{scheduledClasses.filter((c) => c.isActive).length}
</p>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6 sm:col-span-2 lg:col-span-1"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-purple-100 rounded-full">
<FaUsers className="text-purple-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Toplam Katılımcı</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{scheduledClasses.reduce((sum, c) => sum + c.participantCount, 0)}
</p>
</div>
</div>
</motion.div>
</div>
{/* Scheduled Classes */}
<div className="bg-white rounded-lg shadow-md">
<div className="px-4 sm:px-6 py-4 border-b border-gray-200">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">Programlı Sınıflar</h2>
</div>
<div className="p-4 sm:p-6">
{scheduledClasses.length === 0 ? (
<div className="text-center py-12">
<FaCalendarAlt size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-gray-500">Henüz programlanmış sınıf bulunmamaktadır.</p>
</div>
) : (
<div className="grid gap-4 sm:gap-6">
{scheduledClasses.map((classSession, index) => (
<motion.div
key={classSession.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="border border-gray-200 rounded-lg p-4 sm:p-6 hover:shadow-md transition-shadow"
>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between space-y-4 lg:space-y-0">
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-3 mb-2">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 break-words">
{classSession.name}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium self-start ${
classSession.isActive
? 'bg-green-100 text-green-800'
: canJoinClass(classSession.scheduledStartTime)
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{classSession.isActive
? 'Aktif'
: canJoinClass(classSession.scheduledStartTime)
? 'Katılım Açık'
: 'Beklemede'}
</span>
</div>
<p className="text-gray-600 mb-3 text-sm sm:text-base">
{classSession.description}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4 text-xs sm:text-sm text-gray-600">
<div className="flex items-center space-x-2">
<FaCalendarAlt size={12} className="flex-shrink-0" />
<span className="truncate">
{formatDateTime(classSession.scheduledStartTime)}
</span>
</div>
<div className="flex items-center space-x-2">
<FaClock size={12} className="flex-shrink-0" />
<span>{classSession.duration} dakika</span>
</div>
<div className="flex items-center space-x-2">
<FaUsers size={12} className="flex-shrink-0" />
<span>
{classSession.participantCount}/{classSession.maxParticipants}
</span>
</div>
<div className="flex items-center space-x-2">
<FaEye size={12} className="flex-shrink-0" />
<span className="truncate">
{getTimeUntilClass(classSession.scheduledStartTime)}
</span>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 lg:ml-4 w-full lg:w-auto">
{user.role === 'teacher' && classSession.teacherId === user.id && (
<div className="flex space-x-2">
<button
onClick={() => openEditModal(classSession)}
disabled={classSession.isActive}
className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
title="Sınıfı Düzenle"
>
<FaEdit size={14} />
</button>
<button
onClick={() => openDeleteModal(classSession)}
disabled={classSession.isActive}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
title="Sınıfı Sil"
>
<FaTrash size={14} />
</button>
</div>
)}
{canJoinClass(classSession.scheduledStartTime) && (
<button
onClick={() =>
user.role === 'teacher' && classSession.teacherId === user.id
? handleStartClass(classSession)
: (() => {
onJoinClass(classSession)
if (classSession.id)
navigate(
ROUTES_ENUM.protected.admin.classroom.classroom.replace(
':id',
classSession.id,
),
)
})()
}
className={`px-3 sm:px-4 py-2 rounded-lg font-medium transition-colors text-sm sm:text-base w-full sm:w-auto ${
user.role === 'teacher' && classSession.teacherId === user.id
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{user.role === 'teacher' && classSession.teacherId === user.id
? classSession.isActive
? 'Sınıfa Git'
: 'Dersi Başlat'
: 'Katıl'}
</button>
)}
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
</div>
</div>
{/* Create Class Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-2xl w-full max-h-[95vh] overflow-y-auto"
>
<div className="p-4 sm:p-6 border-b border-gray-200">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">Yeni Sınıf Oluştur</h2>
</div>
<form onSubmit={handleCreateClass} className="p-4 sm:p-6 space-y-4 sm:space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Sınıf Adı *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Örn: Matematik 101 - Diferansiyel Denklemler"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">ıklama</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ders hakkında kısa açıklama..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ders Konusu
</label>
<input
type="text"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Örn: Matematik, Fizik, Kimya"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Başlangıç Tarihi ve Saati *
</label>
<input
type="datetime-local"
required
value={formData.scheduledStartTime}
onChange={(e) =>
setFormData({
...formData,
scheduledStartTime: e.target.value,
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Süre (dakika)
</label>
<input
type="number"
min="15"
max="480"
value={formData.duration}
onChange={(e) =>
setFormData({
...formData,
duration: parseInt(e.target.value),
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Maksimum Katılımcı
</label>
<input
type="number"
min="1"
max="100"
value={formData.maxParticipants}
onChange={(e) =>
setFormData({
...formData,
maxParticipants: parseInt(e.target.value),
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Sınıf Ayarları */}
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4">Sınıf Ayarları</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-4">
<h4 className="font-medium text-gray-700">Katılımcı İzinleri</h4>{' '}
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={formData.settings.allowHandRaise}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
allowHandRaise: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Parmak kaldırma izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={formData.settings.allowStudentChat}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
allowStudentChat: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Öğrenci sohbet izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={formData.settings.allowPrivateMessages}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
allowPrivateMessages: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Özel mesaj izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={formData.settings.allowStudentScreenShare}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
allowStudentScreenShare: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Öğrenci ekran paylaşımı</span>
</label>
</div>
<div className="space-y-4">
<h4 className="font-medium text-gray-700">Varsayılan Ayarlar</h4>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan mikrofon durumu
</label>
<select
value={formData.settings.defaultMicrophoneState}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
defaultMicrophoneState: e.target.value as 'muted' | 'unmuted',
},
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="muted">Kapalı</option>
<option value="unmuted">ık</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan kamera durumu
</label>
<select
value={formData.settings.defaultCameraState}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
defaultCameraState: e.target.value as 'on' | 'off',
},
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="on">ık</option>
<option value="off">Kapalı</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan layout
</label>
<select
value={formData.settings.defaultLayout}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
defaultLayout: e.target.value,
},
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="grid">Izgara Görünümü</option>
<option value="teacher-focus">Öğretmen Odaklı</option>
<option value="presentation">Sunum Modu</option>
<option value="sidebar">Yan Panel</option>
</select>
</div>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={formData.settings.autoMuteNewParticipants}
onChange={(e) =>
setFormData({
...formData,
settings: {
...formData.settings,
autoMuteNewParticipants: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Yeni katılımcıları otomatik sustur</span>
</label>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Sınıf Oluştur
</button>
</div>
</form>
</motion.div>
</div>
)}
{/* Edit Class Modal */}
{showEditModal && editingClass && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-2xl w-full max-h-[95vh] overflow-y-auto"
>
<div className="p-4 sm:p-6 border-b border-gray-200">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">Sınıfı Düzenle</h2>
</div>
<form onSubmit={handleEditClass} className="p-4 sm:p-6 space-y-4 sm:space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Sınıf Adı *</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Örn: Matematik 101 - Diferansiyel Denklemler"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">ıklama</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ders hakkında kısa açıklama..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ders Konusu
</label>
<input
type="text"
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Örn: Matematik, Fizik, Kimya"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Başlangıç Tarihi ve Saati *
</label>
<input
type="datetime-local"
required
value={formData.scheduledStartTime}
onChange={(e) =>
setFormData({
...formData,
scheduledStartTime: e.target.value,
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Süre (dakika)
</label>
<input
type="number"
min="15"
max="480"
value={formData.duration}
onChange={(e) =>
setFormData({
...formData,
duration: parseInt(e.target.value),
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Maksimum Katılımcı
</label>
<input
type="number"
min="1"
max="100"
value={formData.maxParticipants}
onChange={(e) =>
setFormData({
...formData,
maxParticipants: parseInt(e.target.value),
})
}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200">
<button
type="button"
onClick={() => {
setShowEditModal(false)
setEditingClass(null)
resetForm()
}}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Değişiklikleri Kaydet
</button>
</div>
</form>
</motion.div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && deletingClass && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-md w-full mx-4"
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="p-3 bg-red-100 rounded-full mr-4">
<FaTrash className="text-red-600" size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Sınıfı Sil</h3>
<p className="text-sm text-gray-600">Bu işlem geri alınamaz</p>
</div>
</div>
<p className="text-gray-700 mb-6">
<strong>"{deletingClass.name}"</strong> adlı sınıfı silmek istediğinizden emin
misiniz?
</p>
<div className="flex items-center justify-end space-x-4">
<button
onClick={() => {
setShowDeleteModal(false)
setDeletingClass(null)
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
onClick={handleDeleteClass}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Sil
</button>
</div>
</div>
</motion.div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,38 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { RoleSelector } from './RoleSelector'
import { useClassroomLogic } from '@/utils/hooks/useClassroomLogic'
import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { Room } from './Room'
export function Dashboard() {
const {
appState,
currentClass,
handleRoleSelect,
handleJoinClass,
handleLeaveClass,
handleCreateClass,
handleEditClass,
handleDeleteClass,
} = useClassroomLogic()
const navigate = useNavigate()
const { user } = useStoreState((state) => state.auth)
useEffect(() => {
if (appState === 'dashboard') {
navigate(ROUTES_ENUM.protected.admin.classroom.classes, { replace: true })
}
}, [appState, navigate])
if (appState === 'role-selection') {
return <RoleSelector onRoleSelect={handleRoleSelect} />
} else if (appState === 'dashboard') {
// Yönlendirme yapılacağı için burada içerik render etmiyoruz
return null
} else if (appState === 'classroom' && currentClass) {
return <Room classSession={currentClass} onLeaveClass={handleLeaveClass} />
}
return null
}

View file

@ -0,0 +1,81 @@
import React from 'react';
import { motion } from 'framer-motion';
import { FaUserTimes, FaExclamationTriangle } from 'react-icons/fa';
interface KickParticipantModalProps {
participant: { id: string; name: string } | null;
isOpen: boolean;
onClose: () => void;
onConfirm: (participantId: string) => void;
}
export const KickParticipantModal: React.FC<KickParticipantModalProps> = ({
participant,
isOpen,
onClose,
onConfirm,
}) => {
if (!isOpen || !participant) return null;
const handleConfirm = () => {
onConfirm(participant.id);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-md w-full mx-4"
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="p-3 bg-red-100 rounded-full mr-4">
<FaExclamationTriangle className="text-red-600" size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Katılımcıyı Çıkar</h3>
<p className="text-sm text-gray-600">Bu işlem geri alınamaz</p>
</div>
</div>
<div className="mb-6">
<p className="text-gray-700 mb-2">
<strong>"{participant.name}"</strong> adlı katılımcıyı sınıftan çıkarmak istediğinizden emin misiniz?
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start">
<FaExclamationTriangle className="text-yellow-600 mt-0.5 mr-2" size={16} />
<div className="text-sm text-yellow-800">
<p className="font-medium">Dikkat:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>Katılımcı anında sınıftan çıkarılacak</li>
<li>Tekrar katılım için davet gerekebilir</li>
<li>Katılım süresi kaydedilecek</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-4">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center space-x-2"
>
<FaUserTimes size={16} />
<span>Çıkar</span>
</button>
</div>
</div>
</motion.div>
</div>
);
};

View file

@ -0,0 +1,118 @@
import { ClassAttendanceDto } from '@/proxy/classroom/models'
import React, { useEffect, useState } from 'react'
import { FaClock, FaUsers } from 'react-icons/fa'
interface AttendancePanelProps {
attendanceRecords: ClassAttendanceDto[]
isOpen: boolean
onClose: () => void
}
export const AttendancePanel: React.FC<AttendancePanelProps> = ({
attendanceRecords,
isOpen,
onClose,
}) => {
// Anlık süre güncellemesi için state ve timer
const [now, setNow] = useState(Date.now())
useEffect(() => {
if (!isOpen) return
const interval = setInterval(() => setNow(Date.now()), 60000) // her dakika
return () => clearInterval(interval)
}, [isOpen])
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 formatTime = (timeString: string) => {
return new Date(timeString).toLocaleTimeString('tr-TR', {
hour: '2-digit',
minute: '2-digit',
})
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FaUsers className="text-blue-600" size={24} />
<h2 className="text-2xl font-bold text-gray-800">Katılım Raporu</h2>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl font-bold"
>
×
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-96">
{attendanceRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FaClock size={48} className="mx-auto mb-4" />
<p>Henüz katılım kaydı bulunmamaktadır.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-50">
<th className="border border-gray-300 px-4 py-3 text-left font-semibold text-gray-700">
Öğrenci Adı
</th>
<th className="border border-gray-300 px-4 py-3 text-left font-semibold text-gray-700">
Giriş Saati
</th>
<th className="border border-gray-300 px-4 py-3 text-left font-semibold text-gray-700">
Çıkış Saati
</th>
<th className="border border-gray-300 px-4 py-3 text-left font-semibold text-gray-700">
Toplam Süre
</th>
</tr>
</thead>
<tbody>
{attendanceRecords.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="border border-gray-300 px-4 py-3 text-gray-800">
{record.studentName}
</td>
<td className="border border-gray-300 px-4 py-3 text-gray-600">
{formatTime(record.joinTime)}
</td>
<td className="border border-gray-300 px-4 py-3 text-gray-600">
{record.leaveTime ? formatTime(record.leaveTime) : 'Devam ediyor'}
</td>
<td className="border border-gray-300 px-4 py-3 text-gray-800 font-semibold">
{(() => {
// Her zaman canlı süreyi hesapla, çıkış varsa oraya kadar, yoksa şimdiye kadar
const endTime = record.leaveTime
? new Date(record.leaveTime).getTime()
: now
const join = new Date(record.joinTime).getTime()
const mins = Math.floor((endTime - join) / 60000)
return formatDuration(mins)
})()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,249 @@
import { ClassChatDto } from '@/proxy/classroom/models'
import { useStoreState } from '@/store/store'
import React, { useState, useRef, useEffect } from 'react'
import { FaPaperPlane, FaComments, FaTimes, FaUsers, FaUser, FaBullhorn } from 'react-icons/fa'
interface ChatPanelProps {
messages: ClassChatDto[]
isTeacher: boolean
isOpen: boolean
onClose: () => void
onSendMessage: (message: string) => void
participants: Array<{ id: string; name: string; isTeacher: boolean }>
onSendPrivateMessage: (message: string, recipientId: string, recipientName: string) => void
onSendAnnouncement?: (message: string) => void
}
export const ChatPanel: React.FC<ChatPanelProps> = ({
messages,
isTeacher,
isOpen,
onClose,
onSendMessage,
participants,
onSendPrivateMessage,
onSendAnnouncement,
}) => {
const { user } = useStoreState((state) => state.auth)
const [newMessage, setNewMessage] = useState('')
const [messageMode, setMessageMode] = useState<'public' | 'private' | 'announcement'>('public')
const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>(
null,
)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault()
if (newMessage.trim()) {
if (messageMode === 'private' && selectedRecipient) {
onSendPrivateMessage(newMessage.trim(), selectedRecipient.id, selectedRecipient.name)
} else if (messageMode === 'announcement' && onSendAnnouncement) {
onSendAnnouncement(newMessage.trim())
} else {
onSendMessage(newMessage.trim())
}
setNewMessage('')
}
}
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('tr-TR', {
hour: '2-digit',
minute: '2-digit',
})
}
if (!isOpen) return null
const availableRecipients = participants.filter((p) => p.id !== user.id)
return (
<div className="fixed right-4 bottom-4 w-96 h-[500px] bg-white rounded-lg shadow-xl border border-gray-200 flex flex-col z-50">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-blue-50 rounded-t-lg">
<div className="flex items-center space-x-2">
<FaComments className="text-blue-600" size={20} />
<h3 className="font-semibold text-gray-800">Sınıf Sohbeti</h3>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 transition-colors">
<FaTimes size={18} />
</button>
</div>
{/* Message Mode Selector */}
<div className="p-3 border-b border-gray-200 bg-gray-50">
<div className="flex space-x-2 mb-2">
<button
onClick={() => {
setMessageMode('public')
setSelectedRecipient(null)
}}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'public'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaUsers size={12} />
<span>Herkese</span>
</button>
<button
onClick={() => setMessageMode('private')}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'private'
? 'bg-green-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaUser size={12} />
<span>Özel</span>
</button>
{isTeacher && (
<button
onClick={() => {
setMessageMode('announcement')
setSelectedRecipient(null)
}}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'announcement'
? 'bg-red-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaBullhorn size={12} />
<span>Duyuru</span>
</button>
)}
</div>
{messageMode === 'private' && (
<select
value={selectedRecipient?.id || ''}
onChange={(e) => {
const recipient = availableRecipients.find((p) => p.id === e.target.value)
setSelectedRecipient(recipient ? { id: recipient.id, name: recipient.name } : null)
}}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded"
>
<option value="">Kişi seçin...</option>
{availableRecipients.map((participant) => (
<option key={participant.id} value={participant.id}>
{participant.name} {participant.isTeacher ? '(Öğretmen)' : ''}
</option>
))}
</select>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 ? (
<div className="text-center text-gray-500 text-sm">Henüz mesaj bulunmamaktadır.</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`${
message.messageType === 'announcement'
? 'w-full'
: message.senderId === user.id
? 'flex justify-end'
: 'flex justify-start'
}`}
>
<div
className={`max-w-xs px-3 py-2 rounded-lg ${
message.messageType === 'announcement'
? 'bg-red-100 text-red-800 border border-red-200 w-full text-center'
: message.messageType === 'private'
? message.senderId === user.id
? 'bg-green-600 text-white'
: 'bg-green-100 text-green-800 border border-green-200'
: message.senderId === user.id
? 'bg-blue-600 text-white'
: message.isTeacher
? 'bg-yellow-100 text-yellow-800 border border-yellow-200'
: 'bg-gray-100 text-gray-800'
}`}
>
{message.senderId !== user.id && (
<div className="text-xs font-semibold mb-1">
{message.senderName}
{message.isTeacher && ' (Öğretmen)'}
{message.messageType === 'private' &&
message.recipientId === user.id &&
' (Size özel)'}
</div>
)}
{message.messageType === 'private' && message.senderId === user.id && (
<div className="text-xs mb-1 opacity-75"> {message.recipientName}</div>
)}
{message.messageType === 'announcement' && (
<div className="text-xs font-semibold mb-1">📢 DUYURU - {message.senderName}</div>
)}
<div className="text-sm">{message.message}</div>
<div
className={`text-xs mt-1 opacity-75 ${
message.messageType === 'announcement'
? 'text-red-600'
: message.senderId === user.id
? 'text-white'
: 'text-gray-500'
}`}
>
{formatTime(message.timestamp)}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Message Input */}
<form onSubmit={handleSendMessage} className="p-4 border-t border-gray-200">
<div className="text-xs text-gray-500 mb-2">
{messageMode === 'public' && 'Herkese mesaj gönderiyorsunuz'}
{messageMode === 'private' &&
selectedRecipient &&
`${selectedRecipient.name} kişisine özel mesaj`}
{messageMode === 'private' && !selectedRecipient && 'Önce bir kişi seçin'}
{messageMode === 'announcement' && 'Sınıfa duyuru gönderiyorsunuz'}
</div>
<div className="flex space-x-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder={
messageMode === 'private' && !selectedRecipient
? 'Önce kişi seçin...'
: messageMode === 'announcement'
? 'Duyuru mesajınız...'
: 'Mesajınızı yazın...'
}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
maxLength={500}
disabled={messageMode === 'private' && !selectedRecipient}
/>
<button
type="submit"
disabled={!newMessage.trim() || (messageMode === 'private' && !selectedRecipient)}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
<FaPaperPlane size={16} />
</button>
</div>
</form>
</div>
)
}

View file

@ -0,0 +1,116 @@
import { VideoLayoutDto } from '@/proxy/classroom/models'
import React from 'react'
import { FaExpand, FaTh, FaColumns, FaDesktop, FaChalkboardTeacher } from 'react-icons/fa'
interface ClassLayoutPanelProps {
currentLayout: VideoLayoutDto
isOpen: boolean
onClose: () => void
}
const layouts: VideoLayoutDto[] = [
{
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: 'Yan Panel Görünümü',
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',
},
]
export const ClassLayoutPanel: React.FC<ClassLayoutPanelProps> = ({
currentLayout,
isOpen,
onClose,
}) => {
const getLayoutIcon = (type: string) => {
switch (type) {
case 'grid':
return <FaTh size={24} />
case 'speaker':
return <FaExpand size={24} />
case 'presentation':
return <FaDesktop size={24} />
case 'sidebar':
return <FaColumns size={24} />
case 'teacher-focus':
// Sade, tek kişilik bir ikon (öğretmen)
return <FaChalkboardTeacher size={26} />
default:
return <FaTh size={24} />
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Video Layout Seçin</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 text-xl font-bold">
×
</button>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{layouts.map((layout) => (
<div key={layout.id}>
<div className="flex items-center space-x-4 mb-3">
<div
className={`p-3 rounded-full ${
currentLayout.id === layout.id
? 'bg-blue-100 text-blue-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{getLayoutIcon(layout.type)}
</div>
<div>
<h3 className="font-semibold text-gray-900">{layout.name}</h3>
<p className="text-sm text-gray-600">{layout.description}</p>
</div>
</div>
<div className="bg-gray-100 rounded-lg p-4 h-24 flex items-center justify-center">
{layout.type === 'grid' && (
<div className="grid grid-cols-2 gap-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="w-6 h-4 bg-blue-300 rounded"></div>
))}
</div>
)}
{layout.type === 'sidebar' && (
<div className="flex items-center space-x-2">
<div className="w-12 h-8 bg-blue-500 rounded"></div>
<div className="grid grid-cols-3 gap-1">
<div className="w-1 h-1 bg-blue-300 rounded"></div>
<div className="w-1 h-1 bg-blue-300 rounded"></div>
<div className="w-1 h-1 bg-blue-300 rounded"></div>
<div className="w-1 h-1 bg-blue-300 rounded"></div>
<div className="w-1 h-1 bg-blue-300 rounded"></div>
<div className="w-1 h-1 bg-blue-300 rounded"></div>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,244 @@
import React, { useState, useRef } from 'react'
import { motion } from 'framer-motion'
import {
FaFile,
FaUpload,
FaDownload,
FaTrash,
FaEye,
FaTimes,
FaFilePdf,
FaFileWord,
FaFileImage,
FaFileAlt,
FaPlay,
FaStop,
} from 'react-icons/fa'
import { ClassDocumentDto } from '@/proxy/classroom/models'
interface DocumentPanelProps {
documents: ClassDocumentDto[]
isOpen: boolean
onClose: () => void
onUpload?: (file: File) => void
onDelete?: (documentId: string) => void
onView?: (document: ClassDocumentDto) => void
isTeacher: boolean
onStartPresentation?: (document: ClassDocumentDto) => void
onStopPresentation?: () => void
activePresentationId?: string
}
export const DocumentPanel: React.FC<DocumentPanelProps> = ({
documents,
isOpen,
onClose,
onUpload,
onDelete,
onView,
isTeacher,
onStartPresentation,
onStopPresentation,
activePresentationId,
}) => {
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
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]
}
const isPresentationFile = (type: string, name: string) => {
return (
type.includes('presentation') ||
type.includes('powerpoint') ||
name.toLowerCase().includes('.ppt') ||
name.toLowerCase().includes('.pptx') ||
type.includes('pdf')
)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (!isTeacher || !onUpload) return
const files = Array.from(e.dataTransfer.files)
files.forEach((file) => onUpload(file))
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isTeacher || !onUpload) return
const files = Array.from(e.target.files || [])
files.forEach((file) => onUpload(file))
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden"
>
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FaFile className="text-blue-600" size={24} />
<h2 className="text-2xl font-bold text-gray-800">Sınıf Dokümanları</h2>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl font-bold"
>
<FaTimes size={20} />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-96">
{/* Upload Area (Teacher Only) */}
{isTeacher && (
<div
className={`border-2 border-dashed rounded-lg p-8 mb-6 text-center transition-colors ${
dragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
>
<FaUpload size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-700 mb-2">Doküman Yükle</p>
<p className="text-gray-500 mb-4">Dosyaları buraya sürükleyin veya seçin</p>
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Dosya Seç
</button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
accept=".pdf,.doc,.docx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.odp"
/>
</div>
)}
{/* Documents List */}
{documents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FaFile size={48} className="mx-auto mb-4 text-gray-300" />
<p>Henüz doküman yüklenmemiş.</p>
</div>
) : (
<div className="grid gap-4">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
>
<div className="flex items-center space-x-4">
<div className="text-2xl">{getFileIcon(doc.type)}</div>
<div>
<h3 className="font-semibold text-gray-800">{doc.name}</h3>
<p className="text-sm text-gray-600">
{formatFileSize(doc.size)} {' '}
{new Date(doc.uploadedAt).toLocaleDateString('tr-TR')}
</p>
<p className="text-xs text-gray-500">Yükleyen: {doc.uploadedBy}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onView?.(doc)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Görüntüle"
>
<FaEye size={16} />
</button>
{/* Sunum Başlat/Durdur Butonu */}
{isTeacher && isPresentationFile(doc.type, doc.name) && (
<button
onClick={() => {
if (activePresentationId === doc.id) {
onStopPresentation?.()
} else {
onStartPresentation?.(doc)
}
}}
className={`p-2 rounded-lg transition-colors ${
activePresentationId === doc.id
? 'text-red-600 hover:bg-red-50'
: 'text-green-600 hover:bg-green-50'
}`}
title={activePresentationId === doc.id ? 'Sunumu Durdur' : 'Sunum Başlat'}
>
{activePresentationId === doc.id ? (
<FaStop size={16} />
) : (
<FaPlay size={16} />
)}
</button>
)}
<a
href={doc.url}
download={doc.name}
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="İndir"
>
<FaDownload size={16} />
</a>
{isTeacher && (
<button
onClick={() => onDelete?.(doc.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Sil"
>
<FaTrash size={16} />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,113 @@
import { HandRaiseDto } from '@/proxy/classroom/models'
import React from 'react'
import { FaHandPaper, FaTimes, FaCheck } from 'react-icons/fa'
interface HandRaisePanelProps {
handRaises: HandRaiseDto[]
isOpen: boolean
onClose: () => void
onApprove?: (handRaiseId: string) => void
onDismiss?: (handRaiseId: string) => void
isTeacher: boolean
}
export const HandRaisePanel: React.FC<HandRaisePanelProps> = ({
handRaises,
isOpen,
onClose,
onApprove,
onDismiss,
isTeacher,
}) => {
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('tr-TR', {
hour: '2-digit',
minute: '2-digit',
})
}
const getTimeSince = (timestamp: string) => {
const now = new Date()
const time = new Date(timestamp)
const diffMinutes = Math.floor((now.getTime() - time.getTime()) / 60000)
if (diffMinutes < 1) return 'Az önce'
if (diffMinutes < 60) return `${diffMinutes} dakika önce`
const hours = Math.floor(diffMinutes / 60)
return `${hours} saat önce`
}
if (!isOpen) return null
const activeHandRaises = handRaises.filter((hr) => hr.isActive)
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FaHandPaper className="text-yellow-600" size={24} />
<h2 className="text-2xl font-bold text-gray-800">
Parmak Kaldıranlar ({activeHandRaises.length})
</h2>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl font-bold"
>
×
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-96">
{activeHandRaises.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FaHandPaper size={48} className="mx-auto mb-4 text-gray-300" />
<p>Şu anda parmak kaldıran öğrenci bulunmamaktadır.</p>
</div>
) : (
<div className="space-y-3">
{activeHandRaises.map((handRaise) => (
<div
key={handRaise.id}
className="flex items-center justify-between p-4 bg-yellow-50 border border-yellow-200 rounded-lg"
>
<div className="flex items-center space-x-3">
<FaHandPaper className="text-yellow-600" size={20} />
<div>
<h3 className="font-semibold text-gray-800">{handRaise.studentName}</h3>
<p className="text-sm text-gray-600">
{formatTime(handRaise.timestamp)} {getTimeSince(handRaise.timestamp)}
</p>
</div>
</div>
{isTeacher && (
<div className="flex space-x-2">
<button
onClick={() => onApprove?.(handRaise.id)}
className="flex items-center space-x-1 px-3 py-1 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
>
<FaCheck size={14} />
<span>Onayla</span>
</button>
<button
onClick={() => onDismiss?.(handRaise.id)}
className="flex items-center space-x-1 px-3 py-1 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
<FaTimes size={14} />
<span>Reddet</span>
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,76 @@
import React from 'react';
import { FaDesktop, FaStop, FaPlay } from 'react-icons/fa';
interface ScreenSharePanelProps {
isSharing: boolean;
onStartShare: () => void;
onStopShare: () => void;
sharedScreen?: MediaStream;
sharerName?: string;
}
export const ScreenSharePanel: React.FC<ScreenSharePanelProps> = ({
isSharing,
onStartShare,
onStopShare,
sharedScreen,
sharerName,
}) => {
return (
<div className="bg-white rounded-lg shadow-md p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<FaDesktop className="text-blue-600" size={20} />
<h3 className="font-semibold text-gray-800">Ekran Paylaşımı</h3>
</div>
{isSharing ? (
<button
onClick={onStopShare}
className="flex items-center space-x-2 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<FaStop size={16} />
<span>Paylaşımı Durdur</span>
</button>
) : (
<button
onClick={onStartShare}
className="flex items-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<FaPlay size={16} />
<span>Ekranı Paylaş</span>
</button>
)}
</div>
{sharedScreen && (
<div className="relative bg-gray-900 rounded-lg overflow-hidden aspect-video">
<video
autoPlay
playsInline
muted
className="w-full h-full object-contain"
ref={(video) => {
if (video && sharedScreen) {
video.srcObject = sharedScreen;
}
}}
/>
{sharerName && (
<div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-sm">
{sharerName} ekranını paylaşıyor
</div>
)}
</div>
)}
{isSharing && !sharedScreen && (
<div className="bg-gray-100 rounded-lg p-8 text-center">
<FaDesktop size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-gray-600">Ekran paylaşımı başlatılıyor...</p>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,297 @@
import React from 'react'
import { FaMicrophoneSlash, FaExpand, FaUserTimes } from 'react-icons/fa'
import { VideoPlayer } from './VideoPlayer'
import { ClassParticipantDto, VideoLayoutDto } from '@/proxy/classroom/models'
interface ParticipantGridProps {
participants: ClassParticipantDto[]
localStream?: MediaStream
currentUserId: string
currentUserName: string
isTeacher: boolean
isAudioEnabled: boolean
isVideoEnabled: boolean
onToggleAudio: () => void
onToggleVideo: () => void
onLeaveCall: () => void
onMuteParticipant?: (participantId: string, isMuted: boolean) => void
layout: VideoLayoutDto
focusedParticipant?: string
onParticipantFocus?: (participantId: string | undefined) => void
onKickParticipant?: (participantId: string) => void
hasSidePanel?: boolean
}
export const ParticipantGrid: React.FC<ParticipantGridProps> = ({
participants,
localStream,
currentUserId,
currentUserName,
isTeacher,
isAudioEnabled,
isVideoEnabled,
onToggleAudio,
onToggleVideo,
onLeaveCall,
onMuteParticipant,
layout,
focusedParticipant,
onParticipantFocus,
onKickParticipant,
hasSidePanel = false,
}) => {
// Only show current user's video once
const currentUserParticipant = {
id: currentUserId,
name: currentUserName,
isTeacher,
stream: localStream,
}
// Eğer hiç katılımcı yoksa ve localStream de yoksa hiçbir şey render etme
if (!localStream && (!participants || participants.length === 0)) {
return null
}
const allParticipants = [currentUserParticipant, ...participants]
// Ortak ana video kutusu container class'ı
const mainVideoContainerClass = 'w-full h-full flex flex-col justify-center'
const renderGridLayout = () => {
const getGridClass = (participantCount: number) => {
if (participantCount === 1) return 'grid-cols-1'
if (participantCount <= 2) return 'grid-cols-1 sm:grid-cols-2'
if (participantCount <= 4) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2'
if (participantCount <= 6) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
if (participantCount <= 9) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
const getGridRows = (participantCount: number) => {
if (participantCount === 1) return 'grid-rows-1'
if (participantCount <= 2) return 'grid-rows-1 sm:grid-rows-1'
if (participantCount <= 4) return 'grid-rows-2 lg:grid-rows-2'
if (participantCount <= 6) return 'grid-rows-3 sm:grid-rows-2'
if (participantCount <= 9) return 'grid-rows-3'
return 'grid-rows-4 sm:grid-rows-3'
}
const getPadding = (participantCount: number) => {
if (participantCount === 1) return ''
if (participantCount <= 4) return 'p-2 sm:p-4'
return 'p-1 sm:p-2'
}
const getGap = (participantCount: number) => {
if (participantCount === 1) return 'gap-0'
if (participantCount <= 4) return 'gap-2 sm:gap-3'
return 'gap-1 sm:gap-2'
}
// Mobilde: En üstte öğretmen, altında katılımcılar 2'li grid ve dikey scroll
const mainParticipant = allParticipants[0]
const otherParticipants = allParticipants.slice(1)
return (
<>
{/* Mobil özel layout */}
<div className="sm:hidden w-full h-full flex flex-col items-center overflow-hidden p-2">
{/* Ana katılımcı */}
<div className="w-full max-w-md mx-auto flex-none flex items-center justify-center mb-2">
<div className="w-full aspect-video max-h-[40vh] rounded-xl overflow-hidden flex bg-white/10 shadow-md border border-white/10">
{renderParticipant(mainParticipant, true)}
</div>
</div>
{/* Diğer katılımcılar 2'li grid ve dikey scroll */}
{otherParticipants.length > 0 && (
<div
className="w-full max-w-md mx-auto flex-1 overflow-y-auto grid grid-cols-1 gap-2 pb-2 min-h-0"
style={{ maxHeight: '55vh' }}
>
{Array.from({ length: Math.ceil(otherParticipants.length / 2) }).map((_, rowIdx) => (
<div key={rowIdx} className="flex gap-2">
{otherParticipants.slice(rowIdx * 2, rowIdx * 2 + 2).map((participant) => (
<div
key={participant.id}
className="flex-1 aspect-video rounded-lg overflow-hidden flex bg-white/10 shadow border border-white/10"
>
{renderParticipant(participant, false, true)}
</div>
))}
{otherParticipants.length % 2 === 1 &&
rowIdx === Math.floor(otherParticipants.length / 2) ? (
<div className="flex-1" />
) : null}
</div>
))}
</div>
)}
</div>
{/* Masaüstü ve tablet için eski grid layout */}
<div className="hidden sm:flex h-full items-center justify-center overflow-hidden">
<div
className={`w-full h-full flex flex-col justify-center ${getPadding(allParticipants.length)}`}
>
<div
className={`h-full grid ${getGridClass(allParticipants.length)} ${getGridRows(allParticipants.length)} ${getGap(allParticipants.length)} place-items-stretch`}
>
{allParticipants.map((participant) => (
<div
key={participant.id}
className="w-full h-full max-h-full flex items-stretch justify-stretch min-h-0"
>
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden flex">
{renderParticipant(participant, false)}
</div>
</div>
))}
</div>
</div>
</div>
</>
)
}
const renderSidebarLayout = () => {
const mainParticipant = focusedParticipant
? allParticipants.find((p) => p.id === focusedParticipant) || allParticipants[0]
: allParticipants[0]
const otherParticipants = allParticipants.filter((p) => p.id !== mainParticipant.id)
const sidebarWidth = hasSidePanel
? 'w-20 sm:w-24 md:w-32 lg:w-40'
: 'w-24 sm:w-32 md:w-40 lg:w-48'
// Eğer hiç katılımcı yoksa, video player öğretmen odaklı gibi ortalanır ve geniş olur
return (
<div className="h-screen flex items-center justify-center p-0">
<div className={mainVideoContainerClass + ' h-full'}>
<div className="flex h-full">
<div className={`flex-1 min-w-0 flex items-center justify-center`}>
<div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full rounded-xl overflow-hidden transition-all duration-200">
{renderParticipant(mainParticipant, true)}
</div>
</div>
</div>
{otherParticipants.length > 0 && (
<div className={`${sidebarWidth} p-2 overflow-y-auto rounded-l-lg h-full`}>
<div className="flex flex-col gap-2 h-full min-w-0">
{otherParticipants.map((participant) => (
<div
key={participant.id}
className="rounded-lg border border-blue-300/40 shadow shadow-blue-200/20 backdrop-blur-sm transition-all duration-200"
>
{renderParticipant(participant, false, true)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
const renderTeacherFocusLayout = () => {
// Sadece öğretmen gösterilecek, katılımcılar asla gösterilmeyecek
const teacher = allParticipants.find((p) => p.isTeacher) || allParticipants[0]
return (
<div className="h-full flex items-center justify-center overflow-hidden">
<div className="w-full h-full flex flex-col justify-center ">
<div className="h-full w-full max-h-full flex items-center justify-center">
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden">
{renderParticipant(teacher, true)}
</div>
</div>
</div>
</div>
)
}
const renderParticipant = (
participant: ClassParticipantDto,
isMain: boolean = false,
isSmall: boolean = false,
) => (
<div
key={participant.id}
className={`relative w-full h-full ${isMain ? '' : isSmall ? 'aspect-video' : ''} ${!isMain && onParticipantFocus ? 'cursor-pointer' : ''}`}
onClick={() => !isMain && onParticipantFocus?.(participant.id)}
style={{ minHeight: 0, minWidth: 0 }}
>
<div className="absolute inset-0 w-full h-full">
<VideoPlayer
stream={participant.stream}
isLocal={participant.id === currentUserId}
userName={participant.name}
isAudioEnabled={
participant.id === currentUserId ? isAudioEnabled : !participant.isAudioMuted
}
isVideoEnabled={
participant.id === currentUserId ? isVideoEnabled : !participant.isVideoMuted
}
onToggleAudio={participant.id === currentUserId ? onToggleAudio : undefined}
onToggleVideo={participant.id === currentUserId ? onToggleVideo : undefined}
onLeaveCall={participant.id === currentUserId ? onLeaveCall : undefined}
/>
</div>
{/* Teacher controls for students */}
{isTeacher && participant.id !== currentUserId && (
<div className="absolute top-2 left-2 flex space-x-1 z-10">
<button
onClick={(e) => {
e.stopPropagation()
onMuteParticipant?.(participant.id, !participant.isAudioMuted)
}}
className={`p-1 rounded-full text-white text-xs ${
participant.isAudioMuted ? 'bg-red-600' : 'bg-gray-600 hover:bg-gray-700'
} transition-colors`}
title={participant.isAudioMuted ? 'Sesi Aç' : 'Sesi Kapat'}
>
<FaMicrophoneSlash size={12} />
</button>
<button
onClick={(e) => {
e.stopPropagation()
onKickParticipant?.(participant.id)
}}
className="p-1 rounded-full bg-red-600 hover:bg-red-700 text-white text-xs transition-colors"
title="Sınıftan Çıkar"
>
<FaUserTimes size={12} />
</button>
</div>
)}
{/* Expand button for non-main participants */}
{!isMain && onParticipantFocus && (
<div className="absolute top-2 right-2 z-10">
<button
onClick={(e) => {
e.stopPropagation()
onParticipantFocus(participant.id)
}}
className="p-1 bg-black bg-opacity-50 text-white rounded-full hover:bg-opacity-70 transition-all"
title="Büyüt"
>
<FaExpand size={12} />
</button>
</div>
)}
</div>
)
const renderLayout = () => {
switch (layout.type) {
case 'sidebar':
return renderSidebarLayout()
case 'teacher-focus':
return renderTeacherFocusLayout()
default:
return renderGridLayout()
}
}
return <div className="h-full min-h-0 flex flex-col">{renderLayout()}</div>
}

View file

@ -0,0 +1,65 @@
import React from 'react'
import { motion } from 'framer-motion'
import { FaGraduationCap, FaUserCheck, FaEye } from 'react-icons/fa'
interface RoleSelectorProps {
onRoleSelect: (role: 'teacher' | 'student' | 'observer') => void
}
export const RoleSelector: React.FC<RoleSelectorProps> = ({ onRoleSelect }) => {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center w-full max-w-4xl"
>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-800 mb-4">
Sanal Sınıf Sistemine Hoş Geldiniz
</h1>
<p className="text-lg sm:text-xl text-gray-600 mb-8 sm:mb-12">Lütfen rolünüzü seçin</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onRoleSelect('teacher')}
className="bg-white rounded-lg shadow-lg p-6 sm:p-8 hover:shadow-xl transition-all duration-300 border-2 border-transparent hover:border-blue-500"
>
<FaGraduationCap size={48} className="mx-auto text-blue-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Öğretmen</h2>
<p className="text-gray-600 text-sm sm:text-base">
Ders başlatın, öğrencilerle iletişim kurun ve katılım raporlarını görün
</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onRoleSelect('student')}
className="bg-white rounded-lg shadow-lg p-6 sm:p-8 hover:shadow-xl transition-all duration-300 border-2 border-transparent hover:border-green-500"
>
<FaUserCheck size={48} className="mx-auto text-green-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Öğrenci</h2>
<p className="text-gray-600 text-sm sm:text-base">
Aktif derslere katılın, öğretmeniniz ve diğer öğrencilerle etkileşim kurun
</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onRoleSelect('observer')}
className="bg-white rounded-lg shadow-lg p-6 sm:p-8 hover:shadow-xl transition-all duration-300 border-2 border-transparent hover:border-purple-500 md:col-span-2 lg:col-span-1"
>
<FaEye size={48} className="mx-auto text-purple-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Gözlemci</h2>
<p className="text-gray-600 text-sm sm:text-base">
Sınıfı gözlemleyin, eğitim sürecini takip edin (ses/video paylaşımı yok)
</p>
</motion.button>
</div>
</motion.div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
import React, { useRef, useEffect } from 'react'
import { FaMicrophoneSlash, FaVideoSlash } from 'react-icons/fa'
// VideoOff component replacement
const VideoOff: React.FC<{ size?: number; className?: string }> = ({
size = 24,
className = '',
}) => <FaVideoSlash size={size} className={className} />
interface VideoPlayerProps {
stream?: MediaStream
isLocal?: boolean
userName: string
isAudioEnabled?: boolean
isVideoEnabled?: boolean
onToggleAudio?: () => void
onToggleVideo?: () => void
onLeaveCall?: () => void
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
stream,
isLocal = false,
userName,
isAudioEnabled = true,
isVideoEnabled = true,
onToggleAudio,
onToggleVideo,
onLeaveCall,
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = stream
}
}, [stream])
return (
<div className="relative bg-gray-900 rounded-md sm:rounded-lg overflow-hidden p-1 sm:p-2 h-full">
<video
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="w-full h-full object-cover"
/>
{/* User name overlay */}
<div className="absolute bottom-1 sm:bottom-2 left-1 sm:left-2 bg-black bg-opacity-50 text-white px-1 sm:px-2 py-0.5 sm:py-1 rounded text-xs sm:text-sm">
{userName} {isLocal && '(You)'}
</div>
{/* Video disabled overlay */}
{!isVideoEnabled && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800">
<div className="text-center text-white">
<VideoOff size={24} className="mx-auto mb-1 sm:mb-2 text-white sm:size-8" />
<p className="text-xs sm:text-sm">{userName}</p>
</div>
</div>
)}
{/* Audio indicator */}
{!isAudioEnabled && (
<div className="absolute top-1 sm:top-2 right-1 sm:right-2 bg-red-500 rounded-full p-0.5 sm:p-1">
<FaMicrophoneSlash size={12} className="text-white sm:size-4" />
</div>
)}
</div>
)
}

View file

@ -0,0 +1,47 @@
export const initialScheduledClasses = [
{
id: '1',
name: 'Matematik 101 - Diferansiyel Denklemler',
description: 'İleri matematik konuları ve uygulamaları',
teacherId: 'teacher1',
teacherName: 'Prof. Dr. Mehmet Özkan',
scheduledStartTime: new Date(Date.now() - 300000).toISOString(), // 5 minutes ago (can join)
startTime: '',
isActive: false,
isScheduled: true,
participantCount: 0,
maxParticipants: 30,
subject: 'Matematik',
duration: 90,
},
{
id: '2',
name: 'Fizik 201 - Kuantum Mekaniği',
description: 'Modern fizik ve kuantum teorisi temelleri',
teacherId: 'teacher2',
teacherName: 'Dr. Ayşe Kaya',
scheduledStartTime: new Date(Date.now() + 1800000).toISOString(), // 30 minutes from now
startTime: '',
isActive: false,
isScheduled: true,
participantCount: 0,
maxParticipants: 25,
subject: 'Fizik',
duration: 120,
},
{
id: '3',
name: 'Kimya 301 - Organik Kimya',
description: 'Organik bileşikler ve reaksiyon mekanizmaları',
teacherId: 'current-teacher',
teacherName: 'Dr. Ali Veli',
scheduledStartTime: new Date(Date.now() - 120000).toISOString(), // 2 minutes ago (can join)
startTime: '',
isActive: false,
isScheduled: true,
participantCount: 0,
maxParticipants: 20,
subject: 'Kimya',
duration: 75,
},
]

View file

@ -0,0 +1,135 @@
export type Role = 'teacher' | 'student' | 'observer'
export interface User {
id: string
name: string
email: string
role: Role
}
export interface ClassroomDto {
id: string
name: string
description?: string
teacherId: string
teacherName: string
startTime: string
scheduledStartTime: string
endTime?: string
isActive: boolean
isScheduled: boolean
participantCount: number
maxParticipants?: number
subject?: string
duration?: number // in minutes
settings?: ClassroomSettingsDto
}
export interface ClassroomSettingsDto {
allowHandRaise: boolean
defaultMicrophoneState: 'muted' | 'unmuted'
defaultCameraState: 'on' | 'off'
defaultLayout: string
allowStudentScreenShare: boolean
allowStudentChat: boolean
allowPrivateMessages: boolean
autoMuteNewParticipants: boolean
recordSession: boolean
waitingRoomEnabled: boolean
}
export interface ClassAttendanceDto {
id: string
sessionId: string
studentId: string
studentName: string
joinTime: string
leaveTime?: string
totalDurationMinutes: number
}
export type MediaType = 'audio' | 'video' | 'screen'
export interface SignalingMessageDto {
type: MediaType
fromUserId: string
toUserId: string
data: any
}
export interface ClassParticipantDto {
id: string
name: string
isTeacher: boolean
isObserver?: boolean
isAudioMuted?: boolean
isVideoMuted?: boolean
stream?: MediaStream
screenStream?: MediaStream
isScreenSharing?: boolean
peerConnection?: RTCPeerConnection
}
export type messageType = 'public' | 'private' | 'announcement'
export interface ClassChatDto {
id: string
senderId: string
senderName: string
message: string
timestamp: string
isTeacher: boolean
recipientId?: string // Özel mesaj için
recipientName?: string
messageType: messageType
}
export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus'
export interface VideoLayoutDto {
id: string
name: string
type: VideoLayoutType
description: string
}
export interface TeacherLayoutDto extends VideoLayoutDto {
id: 'teacher-focus'
name: 'Öğretmen Odaklı'
type: 'teacher-focus'
description: 'Öğretmen tam ekranda, öğrenciler küçük panelde'
}
export interface ScheduledClassDto {
id: string
name: string
scheduledTime: string
duration: number
canJoin: boolean
}
export interface HandRaiseDto {
id: string
studentId: string
studentName: string
timestamp: string
isActive: boolean
}
export interface ClassDocumentDto {
id: string
name: string
url: string
type: string
size: number
uploadedAt: string
uploadedBy: string
isPresentation?: boolean
totalPages?: number
}
export interface ScreenShareRequestDto {
userId: string
userName: string
isActive: boolean
}

View file

@ -76,6 +76,11 @@ export const ROUTES_ENUM = {
formEdit: '/admin/form/:listFormCode/:id/edit',
chart: '/admin/chart/:chartCode',
pivot: '/admin/pivot/:listFormCode',
classroom: {
dashboard: '/admin/classroom/dashboard',
classes: '/admin/classroom/classes',
classroom: '/admin/classroom/room/:id',
},
},
accessDenied: '/admin/access-denied',
},

View file

@ -0,0 +1,394 @@
import {
ClassAttendanceDto,
ClassChatDto,
HandRaiseDto,
SignalingMessageDto,
} from '@/proxy/classroom/models'
import * as signalR from '@microsoft/signalr'
export class SignalRService {
private connection!: signalR.HubConnection
private isConnected: boolean = false
private demoMode: boolean = true // Start in demo mode by default
private onSignalingMessage?: (message: SignalingMessageDto) => void
private onAttendanceUpdate?: (record: ClassAttendanceDto) => void
private onParticipantJoined?: (userId: string, name: string) => void
private onParticipantLeft?: (userId: string) => void
private onChatMessage?: (message: ClassChatDto) => void
private onParticipantMuted?: (userId: string, isMuted: boolean) => void
private onHandRaiseReceived?: (handRaise: HandRaiseDto) => void
private onHandRaiseDismissed?: (handRaiseId: string) => void
constructor() {
// Only initialize connection if not in demo mode
if (!this.demoMode) {
// In production, replace with your actual SignalR hub URL
this.connection = new signalR.HubConnectionBuilder()
.withUrl('https://localhost:5001/classroomhub', {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
})
.withAutomaticReconnect()
.build()
this.setupEventHandlers()
}
}
private setupEventHandlers() {
if (this.demoMode || !this.connection) return
this.connection.on('ReceiveSignalingMessage', (message: SignalingMessageDto) => {
this.onSignalingMessage?.(message)
})
this.connection.on('AttendanceUpdated', (record: ClassAttendanceDto) => {
this.onAttendanceUpdate?.(record)
})
this.connection.on('ParticipantJoined', (userId: string, name: string) => {
this.onParticipantJoined?.(userId, name)
})
this.connection.on('ParticipantLeft', (userId: string) => {
this.onParticipantLeft?.(userId)
})
this.connection.on('ChatMessage', (message: any) => {
this.onChatMessage?.(message)
})
this.connection.on('ParticipantMuted', (userId: string, isMuted: boolean) => {
this.onParticipantMuted?.(userId, isMuted)
})
this.connection.on('HandRaiseReceived', (handRaise: HandRaiseDto) => {
this.onHandRaiseReceived?.(handRaise)
})
this.connection.on('HandRaiseDismissed', (handRaiseId: string) => {
this.onHandRaiseDismissed?.(handRaiseId)
})
this.connection.onreconnected(() => {
console.log('SignalR reconnected')
})
this.connection.onclose(() => {
console.log('SignalR connection closed')
})
}
async start(): Promise<void> {
if (this.demoMode) {
console.log('SignalR running in demo mode - no backend connection required')
return
}
try {
await this.connection.start()
this.isConnected = true
console.log('SignalR connection started')
} catch (error) {
console.error('Error starting SignalR connection:', error)
// Switch to demo mode if connection fails
this.demoMode = true
this.isConnected = false
console.log('Switched to demo mode - SignalR simulation active')
}
}
async joinClass(sessionId: string, userId: string, userName: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating join class for', userName)
// Simulate successful join in demo mode
// Don't auto-add participants in demo mode - let manual simulation handle this
return
}
try {
await this.connection.invoke('JoinClass', sessionId, userId, userName)
} catch (error) {
console.error('Error joining class:', error)
}
}
async leaveClass(sessionId: string, userId: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating leave class for user', userId)
// Simulate successful leave in demo mode
setTimeout(() => {
this.onParticipantLeft?.(userId)
}, 100)
return
}
try {
await this.connection.invoke('LeaveClass', sessionId, userId)
} catch (error) {
console.error('Error leaving class:', error)
}
}
async sendSignalingMessage(message: SignalingMessageDto): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating signaling message', message.type)
// In demo mode, we can't send real signaling messages
// WebRTC will need to work in local-only mode
return
}
try {
await this.connection.invoke('SendSignalingMessage', message)
} catch (error) {
console.error('Error sending signaling message:', error)
}
}
async sendChatMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
isTeacher: boolean,
): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating chat message from', senderName)
const chatMessage: ClassChatDto = {
id: `msg-${Date.now()}`,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher,
messageType: 'public',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke(
'SendChatMessage',
sessionId,
senderId,
senderName,
message,
isTeacher,
'public',
)
} catch (error) {
console.error('Error sending chat message:', error)
}
}
async sendPrivateMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
recipientId: string,
recipientName: string,
isTeacher: boolean,
): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating private message from', senderName, 'to', recipientName)
const chatMessage: ClassChatDto = {
id: `msg-${Date.now()}`,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher,
recipientId,
recipientName,
messageType: 'private',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke(
'SendPrivateMessage',
sessionId,
senderId,
senderName,
message,
recipientId,
recipientName,
isTeacher,
)
} catch (error) {
console.error('Error sending private message:', error)
}
}
async sendAnnouncement(
sessionId: string,
senderId: string,
senderName: string,
message: string,
): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating announcement from', senderName)
const chatMessage: ClassChatDto = {
id: `msg-${Date.now()}`,
senderId,
senderName,
message,
timestamp: new Date().toISOString(),
isTeacher: true,
messageType: 'announcement',
}
setTimeout(() => {
this.onChatMessage?.(chatMessage)
}, 100)
return
}
try {
await this.connection.invoke('SendAnnouncement', sessionId, senderId, senderName, message)
} catch (error) {
console.error('Error sending chat message:', error)
}
}
async muteParticipant(sessionId: string, userId: string, isMuted: boolean): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating mute participant', userId, isMuted)
setTimeout(() => {
this.onParticipantMuted?.(userId, isMuted)
}, 100)
return
}
try {
await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted)
} catch (error) {
console.error('Error muting participant:', error)
}
}
async raiseHand(sessionId: string, studentId: string, studentName: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating hand raise from', studentName)
const handRaise: HandRaiseDto = {
id: `hand-${Date.now()}`,
studentId,
studentName,
timestamp: new Date().toISOString(),
isActive: true,
}
setTimeout(() => {
this.onHandRaiseReceived?.(handRaise)
}, 100)
return
}
try {
await this.connection.invoke('RaiseHand', sessionId, studentId, studentName)
} catch (error) {
console.error('Error raising hand:', error)
}
}
async kickParticipant(sessionId: string, participantId: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating kick participant', participantId)
setTimeout(() => {
this.onParticipantLeft?.(participantId)
}, 100)
return
}
try {
await this.connection.invoke('KickParticipant', sessionId, participantId)
} catch (error) {
console.error('Error kicking participant:', error)
}
}
async approveHandRaise(sessionId: string, handRaiseId: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating hand raise approval')
setTimeout(() => {
this.onHandRaiseDismissed?.(handRaiseId)
}, 100)
return
}
try {
await this.connection.invoke('ApproveHandRaise', sessionId, handRaiseId)
} catch (error) {
console.error('Error approving hand raise:', error)
}
}
async dismissHandRaise(sessionId: string, handRaiseId: string): Promise<void> {
if (this.demoMode || !this.isConnected) {
console.log('Demo mode: Simulating hand raise dismissal')
setTimeout(() => {
this.onHandRaiseDismissed?.(handRaiseId)
}, 100)
return
}
try {
await this.connection.invoke('DismissHandRaise', sessionId, handRaiseId)
} catch (error) {
console.error('Error dismissing hand raise:', error)
}
}
setSignalingHandler(callback: (message: SignalingMessageDto) => void) {
this.onSignalingMessage = callback
}
setAttendanceUpdatedHandler(callback: (record: ClassAttendanceDto) => void) {
this.onAttendanceUpdate = callback
}
setParticipantJoinHandler(callback: (userId: string, name: string) => void) {
this.onParticipantJoined = callback
}
setParticipantLeaveHandler(callback: (userId: string) => void) {
this.onParticipantLeft = callback
}
setChatMessageReceivedHandler(callback: (message: ClassChatDto) => void) {
this.onChatMessage = callback
}
setParticipantMutedHandler(callback: (userId: string, isMuted: boolean) => void) {
this.onParticipantMuted = callback
}
setHandRaiseReceivedHandler(callback: (handRaise: HandRaiseDto) => void) {
this.onHandRaiseReceived = callback
}
setHandRaiseDismissedHandler(callback: (handRaiseId: string) => void) {
this.onHandRaiseDismissed = callback
}
async disconnect(): Promise<void> {
if (this.isConnected && this.connection) {
await this.connection.stop()
this.isConnected = false
}
}
isInDemoMode(): boolean {
return this.demoMode
}
getConnectionState(): boolean {
return this.isConnected
}
}

View file

@ -0,0 +1,150 @@
export class WebRTCService {
private peerConnections: Map<string, RTCPeerConnection> = new Map()
private localStream: MediaStream | null = null
private onRemoteStream?: (userId: string, stream: MediaStream) => void
// STUN servers for NAT traversal
private rtcConfiguration: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
}
async initializeLocalStream(): Promise<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
return this.localStream
} catch (error) {
console.error('Error accessing media devices:', error)
throw error
}
}
async createPeerConnection(userId: string): Promise<RTCPeerConnection> {
const peerConnection = new RTCPeerConnection(this.rtcConfiguration)
this.peerConnections.set(userId, peerConnection)
// Add local stream tracks to peer connection
if (this.localStream) {
this.localStream.getTracks().forEach((track) => {
peerConnection.addTrack(track, this.localStream!)
})
}
// Handle remote stream
peerConnection.ontrack = (event) => {
const [remoteStream] = event.streams
console.log('Remote stream received from user:', userId)
this.onRemoteStream?.(userId, remoteStream)
}
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log('ICE candidate generated for user:', userId, event.candidate)
// In a real implementation, this would be sent via SignalR
}
}
peerConnection.onconnectionstatechange = () => {
console.log(`Connection state for ${userId}:`, peerConnection.connectionState)
if (peerConnection.connectionState === 'connected') {
console.log(`Successfully connected to ${userId}`)
}
}
return peerConnection
}
async createOffer(userId: string): Promise<RTCSessionDescriptionInit> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
return offer
}
async createAnswer(
userId: string,
offer: RTCSessionDescriptionInit,
): Promise<RTCSessionDescriptionInit> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
await peerConnection.setRemoteDescription(offer)
const answer = await peerConnection.createAnswer()
await peerConnection.setLocalDescription(answer)
return answer
}
async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
await peerConnection.setRemoteDescription(answer)
}
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
await peerConnection.addIceCandidate(candidate)
}
onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) {
this.onRemoteStream = callback
}
toggleVideo(enabled: boolean): void {
if (this.localStream) {
const videoTrack = this.localStream.getVideoTracks()[0]
if (videoTrack) {
videoTrack.enabled = enabled
}
}
}
toggleAudio(enabled: boolean): void {
if (this.localStream) {
const audioTrack = this.localStream.getAudioTracks()[0]
if (audioTrack) {
audioTrack.enabled = enabled
}
}
}
getLocalStream(): MediaStream | null {
return this.localStream
}
closePeerConnection(userId: string): void {
const peerConnection = this.peerConnections.get(userId)
if (peerConnection) {
peerConnection.close()
this.peerConnections.delete(userId)
}
}
closeAllConnections(): void {
this.peerConnections.forEach((pc) => pc.close())
this.peerConnections.clear()
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop())
this.localStream = null
}
}
}

View file

@ -20,6 +20,7 @@ export interface AuthStoreModel {
authority: string[]
name: string
avatar?: string
role: string
}
tenant?: {
tenantId?: string
@ -56,6 +57,7 @@ export const initialState: AuthStoreModel = {
authority: [],
name: '',
avatar: '',
role: 'teacher',
},
tenant: {
tenantId: '',
@ -101,6 +103,7 @@ export const authModel: AuthModel = {
state.authority = payload.authority
state.email = payload.email
state.avatar = payload.avatar
state.role = payload.role
}),
},
tenant: {

View file

@ -0,0 +1,68 @@
import { ClassroomDto } from '@/proxy/classroom/models'
import { useStoreActions, useStoreState } from '@/store/store'
import { useState } from 'react'
export type RoleState = 'role-selection' | 'dashboard' | 'classroom'
export function useClassroomLogic() {
const { user } = useStoreState((state) => state.auth)
const { setUser } = useStoreActions((actions) => actions.auth.user)
const [roleState, setRoleState] = useState<RoleState>('role-selection')
const [currentClass, setCurrentClass] = useState<ClassroomDto | null>(null)
const [allClasses, setAllClasses] = useState<ClassroomDto[]>([])
const handleRoleSelect = (role: 'teacher' | 'student' | 'observer') => {
setUser({
...user,
role,
})
setRoleState('dashboard')
}
const handleJoinClass = (classSession: ClassroomDto, userName?: string) => {
setCurrentClass(classSession)
setRoleState('classroom')
}
const handleLeaveClass = () => {
setCurrentClass(null)
setRoleState('dashboard')
}
const handleCreateClass = (classData: Partial<ClassroomDto>) => {
const newClass = {
...classData,
id: `class-${Date.now()}`,
teacherId: '',
teacherName: '',
isActive: false,
isScheduled: true,
participantCount: 0,
} as ClassroomDto
setAllClasses((prev) => [...prev, newClass])
}
const handleEditClass = (classId: string, classData: Partial<ClassroomDto>) => {
setAllClasses((prev) => prev.map((c) => (c.id === classId ? { ...c, ...classData } : c)))
}
const handleDeleteClass = (classId: string) => {
setAllClasses((prev) => prev.filter((c) => c.id !== classId))
}
return {
appState: roleState,
setAppState: setRoleState,
currentClass,
setCurrentClass,
allClasses,
setAllClasses,
handleRoleSelect,
handleJoinClass,
handleLeaveClass,
handleCreateClass,
handleEditClass,
handleDeleteClass,
}
}

View file

@ -0,0 +1,15 @@
import { ClassList } from '@/components/classroom/ClassList'
import React from 'react'
const ClassListPage: React.FC = () => {
return (
<ClassList
onCreateClass={() => {}}
onJoinClass={() => {}}
onEditClass={() => {}}
onDeleteClass={() => {}}
/>
)
}
export default ClassListPage

View file

@ -0,0 +1,8 @@
import React from 'react'
import { Dashboard } from '@/components/classroom/Dashboard'
const DashboardPage: React.FC = () => {
return <Dashboard />
}
export default DashboardPage

View file

@ -0,0 +1,23 @@
import { Room } from '@/components/classroom/Room'
import React from 'react'
const RoomPage: React.FC = () => {
return (
<Room
classSession={{
id: '',
name: '',
teacherId: '',
teacherName: '',
startTime: '',
scheduledStartTime: '',
isActive: false,
isScheduled: false,
participantCount: 0,
}}
onLeaveClass={() => {}}
/>
)
}
export default RoomPage