Classroom backend ve ui
This commit is contained in:
parent
217c68b853
commit
e4544ad1e7
27 changed files with 5530 additions and 2 deletions
|
|
@ -99,6 +99,10 @@
|
||||||
{
|
{
|
||||||
"Name": "App.Contact",
|
"Name": "App.Contact",
|
||||||
"DisplayName": "App.Contact"
|
"DisplayName": "App.Contact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "App.Classroom",
|
||||||
|
"DisplayName": "App.Classroom"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PermissionDefinitionRecords": [
|
"PermissionDefinitionRecords": [
|
||||||
|
|
@ -2799,6 +2803,38 @@
|
||||||
"DisplayName": "Import",
|
"DisplayName": "Import",
|
||||||
"IsEnabled": true,
|
"IsEnabled": true,
|
||||||
"MultiTenancySide": 2
|
"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": [
|
"Menus": [
|
||||||
|
|
@ -3501,6 +3537,16 @@
|
||||||
"Icon": "FaSynagogue",
|
"Icon": "FaSynagogue",
|
||||||
"RequiredPermissionName": "App.Definitions.UomCategory",
|
"RequiredPermissionName": "App.Definitions.UomCategory",
|
||||||
"IsDisabled": false
|
"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": [
|
"Routes": [
|
||||||
|
|
@ -3874,6 +3920,27 @@
|
||||||
"componentPath": "@/views/report/ReportViewerPage",
|
"componentPath": "@/views/report/ReportViewerPage",
|
||||||
"routeType": "protected",
|
"routeType": "protected",
|
||||||
"authority": ["App.Reports.Categories"]
|
"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": [
|
"Languages": [
|
||||||
|
|
@ -14095,6 +14162,30 @@
|
||||||
"key": "App.Contact",
|
"key": "App.Contact",
|
||||||
"tr": "İletişim",
|
"tr": "İletişim",
|
||||||
"en": "Contact"
|
"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": [
|
"Settings": [
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.c7pq42r4d5g"
|
"revision": "0.9qu602jrc3g"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|
|
||||||
182
ui/package-lock.json
generated
182
ui/package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"@fullcalendar/react": "^6.1.8",
|
"@fullcalendar/react": "^6.1.8",
|
||||||
"@fullcalendar/timegrid": "^6.1.8",
|
"@fullcalendar/timegrid": "^6.1.8",
|
||||||
"@marsidev/react-turnstile": "^0.2.1",
|
"@marsidev/react-turnstile": "^0.2.1",
|
||||||
|
"@microsoft/signalr": "^9.0.6",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "^4.29.19",
|
"@tanstack/react-query": "^4.29.19",
|
||||||
"@tanstack/react-table": "^8.8.5",
|
"@tanstack/react-table": "^8.8.5",
|
||||||
|
|
@ -2714,6 +2715,19 @@
|
||||||
"react-dom": ">=16.8.0"
|
"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": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz",
|
"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"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
|
|
@ -6687,11 +6713,29 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
|
"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": {
|
"node_modules/exceljs": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||||
|
|
@ -6804,6 +6848,16 @@
|
||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/fflate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
|
@ -8770,6 +8824,48 @@
|
||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -10460,6 +10573,12 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
|
|
@ -10709,6 +10828,12 @@
|
||||||
"randombytes": "^2.1.0"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
|
"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": {
|
"node_modules/tr46": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||||
|
|
@ -12144,6 +12293,16 @@
|
||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"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": {
|
"node_modules/xmlchars": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"format": "npm run prettier:fix && npm run lint:fix"
|
"format": "npm run prettier:fix && npm run lint:fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/signalr": "^9.0.6",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@babel/standalone": "^7.28.0",
|
"@babel/standalone": "^7.28.0",
|
||||||
|
|
|
||||||
941
ui/src/components/classroom/ClassList.tsx
Normal file
941
ui/src/components/classroom/ClassList.tsx
Normal 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">Açı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">Açı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">Açı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">Açı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>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
ui/src/components/classroom/Dashboard.tsx
Normal file
38
ui/src/components/classroom/Dashboard.tsx
Normal 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
|
||||||
|
}
|
||||||
81
ui/src/components/classroom/KickParticipantModal.tsx
Normal file
81
ui/src/components/classroom/KickParticipantModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
ui/src/components/classroom/Panels/AttendancePanel.tsx
Normal file
118
ui/src/components/classroom/Panels/AttendancePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
249
ui/src/components/classroom/Panels/ChatPanel.tsx
Normal file
249
ui/src/components/classroom/Panels/ChatPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
ui/src/components/classroom/Panels/ClassLayoutPanel.tsx
Normal file
116
ui/src/components/classroom/Panels/ClassLayoutPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
ui/src/components/classroom/Panels/DocumentPanel.tsx
Normal file
244
ui/src/components/classroom/Panels/DocumentPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
ui/src/components/classroom/Panels/HandRaisePanel.tsx
Normal file
113
ui/src/components/classroom/Panels/HandRaisePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
ui/src/components/classroom/Panels/ScreenSharePanel.tsx
Normal file
76
ui/src/components/classroom/Panels/ScreenSharePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
297
ui/src/components/classroom/ParticipantGrid.tsx
Normal file
297
ui/src/components/classroom/ParticipantGrid.tsx
Normal 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>
|
||||||
|
}
|
||||||
65
ui/src/components/classroom/RoleSelector.tsx
Normal file
65
ui/src/components/classroom/RoleSelector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1998
ui/src/components/classroom/Room.tsx
Normal file
1998
ui/src/components/classroom/Room.tsx
Normal file
File diff suppressed because it is too large
Load diff
72
ui/src/components/classroom/VideoPlayer.tsx
Normal file
72
ui/src/components/classroom/VideoPlayer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
ui/src/proxy/classroom/data.ts
Normal file
47
ui/src/proxy/classroom/data.ts
Normal 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
135
ui/src/proxy/classroom/models.ts
Normal file
135
ui/src/proxy/classroom/models.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,11 @@ export const ROUTES_ENUM = {
|
||||||
formEdit: '/admin/form/:listFormCode/:id/edit',
|
formEdit: '/admin/form/:listFormCode/:id/edit',
|
||||||
chart: '/admin/chart/:chartCode',
|
chart: '/admin/chart/:chartCode',
|
||||||
pivot: '/admin/pivot/:listFormCode',
|
pivot: '/admin/pivot/:listFormCode',
|
||||||
|
classroom: {
|
||||||
|
dashboard: '/admin/classroom/dashboard',
|
||||||
|
classes: '/admin/classroom/classes',
|
||||||
|
classroom: '/admin/classroom/room/:id',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
accessDenied: '/admin/access-denied',
|
accessDenied: '/admin/access-denied',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
394
ui/src/services/classroom/signalr.ts
Normal file
394
ui/src/services/classroom/signalr.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
150
ui/src/services/classroom/webrtc.ts
Normal file
150
ui/src/services/classroom/webrtc.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ export interface AuthStoreModel {
|
||||||
authority: string[]
|
authority: string[]
|
||||||
name: string
|
name: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
role: string
|
||||||
}
|
}
|
||||||
tenant?: {
|
tenant?: {
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
|
|
@ -56,6 +57,7 @@ export const initialState: AuthStoreModel = {
|
||||||
authority: [],
|
authority: [],
|
||||||
name: '',
|
name: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
|
role: 'teacher',
|
||||||
},
|
},
|
||||||
tenant: {
|
tenant: {
|
||||||
tenantId: '',
|
tenantId: '',
|
||||||
|
|
@ -101,6 +103,7 @@ export const authModel: AuthModel = {
|
||||||
state.authority = payload.authority
|
state.authority = payload.authority
|
||||||
state.email = payload.email
|
state.email = payload.email
|
||||||
state.avatar = payload.avatar
|
state.avatar = payload.avatar
|
||||||
|
state.role = payload.role
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
tenant: {
|
tenant: {
|
||||||
|
|
|
||||||
68
ui/src/utils/hooks/useClassroomLogic.ts
Normal file
68
ui/src/utils/hooks/useClassroomLogic.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ui/src/views/classroom/ClassListPage.tsx
Normal file
15
ui/src/views/classroom/ClassListPage.tsx
Normal 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
|
||||||
8
ui/src/views/classroom/DashboardPage.tsx
Normal file
8
ui/src/views/classroom/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Dashboard } from '@/components/classroom/Dashboard'
|
||||||
|
|
||||||
|
const DashboardPage: React.FC = () => {
|
||||||
|
return <Dashboard />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardPage
|
||||||
23
ui/src/views/classroom/RoomPage.tsx
Normal file
23
ui/src/views/classroom/RoomPage.tsx
Normal 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
|
||||||
Loading…
Reference in a new issue