From bdc7f744aa59c71256596008398a9d78cdd9953a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Fri, 8 May 2026 08:34:29 +0300 Subject: [PATCH] Video Rooms --- .../Videoroom/IVideoroomAppService.cs | 21 + .../Videoroom/VideoroomChatDto.cs | 17 + .../Videoroom/VideoroomDto.cs | 64 + .../Videoroom/VideoroomFilterInputDto.cs | 9 + .../Videoroom/VideoroomParticipantDto.cs | 19 + .../Public/PublicAppService.cs | 2 +- .../Videoroom/VideoroomAppService.cs | 303 ++++ .../Videoroom/VideoroomAutoMapperProfile.cs | 16 + .../Seeds/LanguagesData.json | 66 +- .../Seeds/MenusData.json | 47 +- .../Seeds/PermissionsData.json | 37 + .../Enums/TableNameEnum.cs | 6 +- .../TableNameResolver.cs | 4 + .../Entities/Tenant/Videoroom/Videoroom.cs | 33 + .../Tenant/Videoroom/VideoroomAttandance.cs | 56 + .../Tenant/Videoroom/VideoroomChat.cs | 53 + .../Tenant/Videoroom/VideoroomParticipant.cs | 82 + .../EntityFrameworkCore/PlatformDbContext.cs | 79 + ....cs => 20260507202053_Initial.Designer.cs} | 394 ++++- ...1_Initial.cs => 20260507202053_Initial.cs} | 206 +++ .../PlatformDbContextModelSnapshot.cs | 392 +++++ configs/deployment/configs/nginx.conf | 4 +- ui/src/proxy/videoroom/models.ts | 111 ++ ui/src/routes/route.constant.ts | 17 +- ui/src/services/classroom.service.ts | 72 - ui/src/services/videoroom.service.ts | 72 + ui/src/services/videoroom/signalr.tsx | 573 +++++++ ui/src/services/videoroom/webrtc.tsx | 358 +++++ ui/src/utils/hooks/useClassroomLogic.ts | 14 +- ui/src/views/admin/videoroom/ChatPanel.tsx | 196 +++ ui/src/views/admin/videoroom/Dashboard.tsx | 87 ++ .../views/admin/videoroom/DocumentsPanel.tsx | 153 ++ .../admin/videoroom/KickParticipantModal.tsx | 82 + ui/src/views/admin/videoroom/LayoutPanel.tsx | 112 ++ .../admin/videoroom/ParticipantsPanel.tsx | 226 +++ ui/src/views/admin/videoroom/RoomDetail.tsx | 1320 +++++++++++++++++ ui/src/views/admin/videoroom/RoomList.tsx | 918 ++++++++++++ .../views/admin/videoroom/RoomParticipant.tsx | 282 ++++ .../admin/videoroom/ScreenSharePanel.tsx | 76 + ui/src/views/admin/videoroom/VideoPlayer.tsx | 81 + ui/src/views/forum/admin/AdminView.tsx | 2 +- 41 files changed, 6534 insertions(+), 128 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Videoroom/IVideoroomAppService.cs create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomChatDto.cs create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomDto.cs create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomFilterInputDto.cs create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomParticipantDto.cs create mode 100644 api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAppService.cs create mode 100644 api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAutoMapperProfile.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/Videoroom.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomAttandance.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomChat.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomParticipant.cs rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260507140651_Initial.Designer.cs => 20260507202053_Initial.Designer.cs} (95%) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260507140651_Initial.cs => 20260507202053_Initial.cs} (94%) create mode 100644 ui/src/proxy/videoroom/models.ts delete mode 100644 ui/src/services/classroom.service.ts create mode 100644 ui/src/services/videoroom.service.ts create mode 100644 ui/src/services/videoroom/signalr.tsx create mode 100644 ui/src/services/videoroom/webrtc.tsx create mode 100644 ui/src/views/admin/videoroom/ChatPanel.tsx create mode 100644 ui/src/views/admin/videoroom/Dashboard.tsx create mode 100644 ui/src/views/admin/videoroom/DocumentsPanel.tsx create mode 100644 ui/src/views/admin/videoroom/KickParticipantModal.tsx create mode 100644 ui/src/views/admin/videoroom/LayoutPanel.tsx create mode 100644 ui/src/views/admin/videoroom/ParticipantsPanel.tsx create mode 100644 ui/src/views/admin/videoroom/RoomDetail.tsx create mode 100644 ui/src/views/admin/videoroom/RoomList.tsx create mode 100644 ui/src/views/admin/videoroom/RoomParticipant.tsx create mode 100644 ui/src/views/admin/videoroom/ScreenSharePanel.tsx create mode 100644 ui/src/views/admin/videoroom/VideoPlayer.tsx diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/IVideoroomAppService.cs b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/IVideoroomAppService.cs new file mode 100644 index 0000000..acdd7b5 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/IVideoroomAppService.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Sozsoft.Platform.VideoRooms; + +public interface IVideoroomAppService : IApplicationService +{ + Task GetAsync(Guid id); + Task> GetListAsync(VideoroomFilterInputDto input); + Task CreateAsync(VideoroomDto input); + Task UpdateAsync(Guid id, VideoroomDto input); + Task DeleteAsync(Guid id); + Task StartClassAsync(Guid id); + Task EndClassAsync(Guid id); + Task JoinClassAsync(Guid id); + Task LeaveClassAsync(Guid id); + Task> GetAttendanceAsync(Guid sessionId); +} diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomChatDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomChatDto.cs new file mode 100644 index 0000000..ef0a4b3 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomChatDto.cs @@ -0,0 +1,17 @@ +using System; + +namespace Sozsoft.Platform.VideoRooms; + +public class VideoroomChatDto +{ + public Guid Id { get; set; } + public Guid SessionId { get; set; } + public Guid SenderId { get; set; } + public string SenderName { get; set; } + public string Message { get; set; } + public DateTime Timestamp { get; set; } + public Guid? RecipientId { get; set; } + public string? RecipientName { get; set; } + public bool IsTeacher { get; set; } + public string MessageType { get; set; } +} diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomDto.cs new file mode 100644 index 0000000..6f29031 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomDto.cs @@ -0,0 +1,64 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Volo.Abp.Application.Dtos; + +namespace Sozsoft.Platform.VideoRooms; + +public class VideoroomDto : FullAuditedEntityDto +{ + public string Name { get; set; } + public string Description { get; set; } + public string Subject { get; set; } + public Guid TeacherId { get; set; } + public string TeacherName { get; set; } + public DateTime ScheduledStartTime { get; set; } + public DateTime? ScheduledEndTime { get; set; } + public int Duration { get; set; } + public DateTime? ActualStartTime { get; set; } + public DateTime? ActualEndTime { get; set; } + public int MaxParticipants { get; set; } + public int ParticipantCount { get; set; } + + [JsonIgnore] + public string SettingsJson { get; set; } + public VideoroomSettingsDto SettingsDto + { + get + { + if (!string.IsNullOrEmpty(SettingsJson)) + return JsonSerializer.Deserialize(SettingsJson); + + return new VideoroomSettingsDto(); + } + set { SettingsJson = JsonSerializer.Serialize(value); } + } +} + +public class VideoroomSettingsDto +{ + public bool AllowHandRaise { get; set; } + public bool AllowStudentChat { get; set; } + public bool AllowPrivateMessages { get; set; } + public bool AllowStudentScreenShare { get; set; } + public string DefaultMicrophoneState { get; set; } = "muted"; // 'muted' | 'unmuted' + public string DefaultCameraState { get; set; } = "off"; // 'on' | 'off' + public string DefaultLayout { get; set; } = "grid"; + public bool AutoMuteNewParticipants { get; set; } +} + +public class VideoroomListDto : PagedAndSortedResultRequestDto +{ + public bool? IsActive { get; set; } + public Guid? TeacherId { get; set; } +} + +public class VideoroomAttendanceDto : EntityDto +{ + public Guid SessionId { get; set; } + public Guid StudentId { get; set; } + public string StudentName { get; set; } + public DateTime JoinTime { get; set; } + public DateTime? LeaveTime { get; set; } + public int TotalDurationMinutes { get; set; } +} diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomFilterInputDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomFilterInputDto.cs new file mode 100644 index 0000000..de96191 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomFilterInputDto.cs @@ -0,0 +1,9 @@ +using Volo.Abp.Application.Dtos; + +namespace Sozsoft.Platform.VideoRooms; + +public class VideoroomFilterInputDto : PagedAndSortedResultRequestDto +{ + public string Search { get; set; } + public string Status { get; set; } +} \ No newline at end of file diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomParticipantDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomParticipantDto.cs new file mode 100644 index 0000000..14c2ad9 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Videoroom/VideoroomParticipantDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Sozsoft.Platform.VideoRooms; + +public class VideoroomParticipantDto +{ + public Guid Id { get; set; } + public Guid SessionId { get; set; } + public Guid UserId { get; set; } + public string UserName { get; set; } + public bool IsTeacher { get; set; } + public bool IsAudioMuted { get; set; } + public bool IsVideoMuted { get; set; } + public bool IsHandRaised { get; set; } + public bool IsKicked { get; set; } + public DateTime JoinTime { get; set; } + public bool IsActive { get; set; } +} + diff --git a/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs b/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs index bd7e0c4..fdbcfba 100644 --- a/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs +++ b/api/src/Sozsoft.Platform.Application/Public/PublicAppService.cs @@ -650,7 +650,7 @@ public class PublicAppService : PlatformAppService var features = new List { new() { Icon = "FaUsers", TitleKey = "Public.features.reliable", DescriptionKey = "Public.features.reliable.desc" }, - new() { Icon = "FaCalendarAlt", TitleKey = "App.Coordinator.Classroom.Planning", DescriptionKey = "Public.features.rapid.desc" }, + new() { Icon = "FaCalendarAlt", TitleKey = "App.Videoroom.Planning", DescriptionKey = "Public.features.rapid.desc" }, new() { Icon = "FaBookOpen", TitleKey = "Public.features.expert", DescriptionKey = "Public.features.expert.desc" }, new() { Icon = "FaCreditCard", TitleKey = "Public.features.muhasebe", DescriptionKey = "Public.features.muhasebe.desc" }, new() { Icon = "FaRegComment", TitleKey = "Public.features.iletisim", DescriptionKey = "Public.features.iletisim.desc" }, diff --git a/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAppService.cs b/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAppService.cs new file mode 100644 index 0000000..5550f59 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAppService.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Sozsoft.Platform.Entities; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; + +namespace Sozsoft.Platform.VideoRooms; + +[Authorize] +public class VideoroomAppService : PlatformAppService, IVideoroomAppService +{ + private readonly IRepository _classSessionRepository; + private readonly IRepository _participantRepository; + private readonly IRepository _attendanceRepository; + private readonly IRepository _chatRepository; + + public VideoroomAppService( + IRepository classSessionRepository, + IRepository participantRepository, + IRepository attendanceRepository, + IRepository chatRepository) + { + _classSessionRepository = classSessionRepository; + _participantRepository = participantRepository; + _attendanceRepository = attendanceRepository; + _chatRepository = chatRepository; + } + + public async Task GetAsync(Guid id) + { + var classSession = await _classSessionRepository.GetAsync(id); + return ObjectMapper.Map(classSession); + } + + public async Task> GetListAsync(VideoroomFilterInputDto input) + { + var query = await _classSessionRepository.GetQueryableAsync(); + + if (!string.IsNullOrWhiteSpace(input.Search)) + { + query = query.Where(x => + x.Name.Contains(input.Search) || + x.Description.Contains(input.Search) || + x.Subject.Contains(input.Search) || + x.TeacherName.Contains(input.Search) + ); + } + + if (!string.IsNullOrWhiteSpace(input.Status)) + { + switch (input.Status) + { + case "Active": + query = query.Where(x => x.ActualStartTime == null && x.ActualEndTime == null); + break; + + case "Open": + query = query.Where(x => x.ActualStartTime != null && x.ActualEndTime == null); + break; + + case "Passive": + query = query.Where(x => x.ActualEndTime != null); + break; + } + } + + + var totalCount = query.Count(); + var items = query + .OrderBy(x => x.ScheduledStartTime) + .Skip(input.SkipCount) + .Take(input.MaxResultCount) + .ToList(); + + return new PagedResultDto( + totalCount, + ObjectMapper.Map, List>(items) + ); + } + + public async Task CreateAsync(VideoroomDto input) + { + var classSession = new Videoroom + { + Name = input.Name, + Description = input.Description, + Subject = input.Subject, + TeacherId = CurrentUser.Id, + TeacherName = CurrentUser.Name, + ScheduledStartTime = input.ScheduledStartTime, + ScheduledEndTime = input.ScheduledStartTime.AddMinutes(input.Duration), + Duration = input.Duration, + MaxParticipants = input.MaxParticipants, + SettingsJson = JsonSerializer.Serialize(input.SettingsDto) + }; + + await _classSessionRepository.InsertAsync(classSession); + + return ObjectMapper.Map(classSession); + } + + public async Task UpdateAsync(Guid id, VideoroomDto input) + { + var classSession = await _classSessionRepository.GetAsync(id); + + if (classSession.TeacherId != CurrentUser.Id) + { + throw new UnauthorizedAccessException("Only the teacher can update this class"); + } + + classSession.Name = input.Name; + classSession.Description = input.Description; + classSession.Subject = input.Subject; + classSession.TeacherId = input.TeacherId; + classSession.TeacherName = input.TeacherName; + classSession.ScheduledStartTime = input.ScheduledStartTime; + classSession.ScheduledEndTime = input.ScheduledStartTime.AddMinutes(input.Duration); + classSession.Duration = input.Duration; + classSession.MaxParticipants = input.MaxParticipants; + classSession.SettingsJson = JsonSerializer.Serialize(input.SettingsDto); + + await _classSessionRepository.UpdateAsync(classSession); + return ObjectMapper.Map(classSession); + } + + public async Task DeleteAsync(Guid id) + { + var classSession = await _classSessionRepository.GetAsync(id); + + if (classSession.TeacherId != CurrentUser.Id) + { + throw new UnauthorizedAccessException("Only the teacher can delete this class"); + } + + await _classSessionRepository.DeleteAsync(id); + } + + [HttpPut] + public async Task StartClassAsync(Guid id) + { + var classSession = await _classSessionRepository.GetAsync(id); + + if (classSession.TeacherId != CurrentUser.Id) + { + throw new UnauthorizedAccessException("Only the teacher can start this class"); + } + + classSession.ActualStartTime = DateTime.Now; + + await _classSessionRepository.UpdateAsync(classSession); + + return ObjectMapper.Map(classSession); + } + + [HttpPut] + public async Task EndClassAsync(Guid id) + { + var classSession = await _classSessionRepository.GetAsync(id); + + if (classSession.TeacherId != CurrentUser.Id) + { + throw new UnauthorizedAccessException("Only the teacher can end this class"); + } + + classSession.ActualEndTime = DateTime.Now; + + await _classSessionRepository.UpdateAsync(classSession); + + // Update attendance records + var activeAttendances = await _attendanceRepository.GetListAsync( + x => x.SessionId == id && x.LeaveTime == null + ); + + foreach (var attendance in activeAttendances) + { + attendance.LeaveTime = DateTime.Now; + attendance.CalculateDuration(); + await _attendanceRepository.UpdateAsync(attendance); + } + } + + public async Task JoinClassAsync(Guid id) + { + var classSession = await _classSessionRepository.GetAsync(id); + if (classSession == null) + { + throw new InvalidOperationException("Class not found"); + } + + var videoroomSettings = string.IsNullOrWhiteSpace(classSession.SettingsJson) + ? new VideoroomSettingsDto() // default ayarlar + : JsonSerializer.Deserialize(classSession.SettingsJson); + + + if (classSession.ParticipantCount >= classSession.MaxParticipants) + { + throw new InvalidOperationException("Class is full"); + } + + // Check if user is already in the class + var existingParticipant = await _participantRepository.FirstOrDefaultAsync( + x => x.SessionId == id && x.UserId == CurrentUser.Id + ); + + if (existingParticipant == null) + { + // Add participant + var participant = new VideoroomParticipant( + GuidGenerator.Create(), + id, + CurrentUser.Id, + CurrentUser.Name, + false, // isTeacher + videoroomSettings?.DefaultMicrophoneState == "muted", + videoroomSettings?.DefaultCameraState == "off", + false, // HandRaised + false, // isKicked + true // isActive + ); + + await _participantRepository.InsertAsync(participant); + + // Create attendance record + var attendance = new VideoroomAttandance( + GuidGenerator.Create(), + id, + CurrentUser.Id, + CurrentUser.Name, + DateTime.Now + ); + + await _attendanceRepository.InsertAsync(attendance); + + // Update participant count + classSession.ParticipantCount++; + await _classSessionRepository.UpdateAsync(classSession); + } + + return ObjectMapper.Map(classSession); + } + + public async Task LeaveClassAsync(Guid id) + { + var participant = await _participantRepository.FirstOrDefaultAsync( + x => x.SessionId == id && x.UserId == CurrentUser.Id + ); + + if (participant != null) + { + await _participantRepository.DeleteAsync(participant); + + // Update attendance record + var attendance = await _attendanceRepository.FirstOrDefaultAsync( + x => x.SessionId == id && x.StudentId == CurrentUser.Id && x.LeaveTime == null + ); + + if (attendance != null) + { + attendance.LeaveTime = DateTime.UtcNow; + attendance.CalculateDuration(); + await _attendanceRepository.UpdateAsync(attendance); + } + + // Update participant count + var classSession = await _classSessionRepository.GetAsync(id); + classSession.ParticipantCount = Math.Max(0, classSession.ParticipantCount - 1); + await _classSessionRepository.UpdateAsync(classSession); + } + } + + public async Task> GetAttendanceAsync(Guid sessionId) + { + var attendanceRecords = await _attendanceRepository.GetListAsync( + x => x.SessionId == sessionId + ); + + return ObjectMapper.Map, List>(attendanceRecords); + } + + public async Task> GetParticipantAsync(Guid sessionId) + { + var participantRecords = await _participantRepository.GetListAsync( + x => x.SessionId == sessionId + ); + + return ObjectMapper.Map, List>(participantRecords); + } + + public async Task> GetChatAsync(Guid sessionId) + { + var chatRecords = await _chatRepository.GetListAsync( + x => x.SessionId == sessionId + ); + + return ObjectMapper.Map, List>(chatRecords); + } + +} diff --git a/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAutoMapperProfile.cs b/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAutoMapperProfile.cs new file mode 100644 index 0000000..36459ee --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/Videoroom/VideoroomAutoMapperProfile.cs @@ -0,0 +1,16 @@ +using AutoMapper; +using Sozsoft.Platform.Entities; + +namespace Sozsoft.Platform.VideoRooms; + +public class VideoroomAutoMapperProfile : Profile +{ + public VideoroomAutoMapperProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + } +} + diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index ed81159..7acc7df 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -3638,9 +3638,33 @@ }, { "resourceName": "Platform", - "key": "App.Intranet", - "en": "Intranet", - "tr": "Intranet" + "key": "App.Videoroom", + "en": "Video Rooms", + "tr": "Video Odaları" + }, + { + "resourceName": "Platform", + "key": "App.Videoroom.Dashboard", + "en": "Video Rooms", + "tr": "Video Odaları" + }, + { + "resourceName": "Platform", + "key": "App.Videoroom.List", + "en": "Video Rooms", + "tr": "Video Odaları" + }, + { + "resourceName": "Platform", + "key": "App.Videoroom.RoomDetail", + "en": "Video Room Detail", + "tr": "Video Oda Detayı" + }, + { + "resourceName": "Platform", + "key": "App.Videoroom.Planning", + "en": "Video Room Planning", + "tr": "Video Oda Planlama" }, { "resourceName": "Platform", @@ -6090,6 +6114,12 @@ "tr": "Eğitim Durumu", "en": "Education Status" }, + { + "resourceName": "Platform", + "key": "App.Intranet", + "tr": "Intranet", + "en": "Intranet" + }, { "resourceName": "Platform", "key": "App.Intranet.SocialComment", @@ -9744,36 +9774,6 @@ "tr": "İletişim", "en": "Contact" }, - { - "resourceName": "Platform", - "key": "App.Coordinator.Classroom", - "tr": "Sınıf", - "en": "Classroom" - }, - { - "resourceName": "Platform", - "key": "App.Coordinator.Classroom.Dashboard", - "tr": "Gösterge Paneli", - "en": "Dashboard" - }, - { - "resourceName": "Platform", - "key": "App.Coordinator.Classroom.List", - "tr": "Sınıflar", - "en": "Classes" - }, - { - "resourceName": "Platform", - "key": "App.Coordinator.Classroom.RoomDetail", - "tr": "Sanal Sınıf", - "en": "Virtul Class" - }, - { - "resourceName": "Platform", - "key": "App.Coordinator.Classroom.Planning", - "tr": "Sınıf Planlama", - "en": "Class Planning" - }, { "resourceName": "Platform", "key": "App.SupplyChain.PaymentTerm", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json index c85f689..8d76fd1 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json @@ -453,6 +453,33 @@ "authority": [ "App.Contact" ] + }, + { + "key": "admin.videoroom.dashboard", + "path": "/admin/videoroom/dashboard", + "componentPath": "@/views/admin/videoroom/Dashboard", + "routeType": "protected", + "authority": [ + "App.Videoroom.Dashboard" + ] + }, + { + "key": "admin.videoroom.list", + "path": "/admin/videoroom/list", + "componentPath": "@/views/admin/videoroom/RoomList", + "routeType": "protected", + "authority": [ + "App.Videoroom.List" + ] + }, + { + "key": "admin.videoroom.roomdetail", + "path": "/admin/videoroom/room/:id", + "componentPath": "@/views/admin/videoroom/RoomDetail", + "routeType": "protected", + "authority": [ + "App.Videoroom.RoomDetail" + ] } ], "MenuGroups": [ @@ -1096,11 +1123,21 @@ "RequiredPermissionName": "App.Intranet.Events.Event", "IsDisabled": false }, + { + "ParentCode": "App.Administration", + "Code": "App.Videoroom.Dashboard", + "DisplayName": "App.Videoroom.Dashboard", + "Order": 4, + "Url": "/admin/videoroom/dashboard", + "Icon": "FcVideoCall", + "RequiredPermissionName": "App.Videoroom.Dashboard", + "IsDisabled": false + }, { "ParentCode": "App.Administration", "Code": "App.Administration.Restrictions", "DisplayName": "App.Restrictions", - "Order": 4, + "Order": 5, "Url": null, "Icon": "FaLock", "RequiredPermissionName": null, @@ -1130,7 +1167,7 @@ "ParentCode": "App.Administration", "Code": "Abp.Identity", "DisplayName": "Abp.Identity", - "Order": 5, + "Order": 6, "Url": null, "Icon": "FcConferenceCall", "RequiredPermissionName": null, @@ -1220,7 +1257,7 @@ "ParentCode": "App.Administration", "Code": "App.Reports.Management", "DisplayName": "App.Reports.Management", - "Order": 6, + "Order": 7, "Url": null, "Icon": "FcDocument", "RequiredPermissionName": null, @@ -1250,7 +1287,7 @@ "ParentCode": "App.Administration", "Code": "App.Files", "DisplayName": "App.Files", - "Order": 7, + "Order": 8, "Url": "/admin/files", "Icon": "FcFolder", "RequiredPermissionName": "App.Files", @@ -1260,7 +1297,7 @@ "ParentCode": "App.Administration", "Code": "App.Forum", "DisplayName": "App.Forum", - "Order": 8, + "Order": 9, "Url": "/admin/forum", "Icon": "FcLink", "RequiredPermissionName": "App.ForumManagement.Publish", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json index 01ae9d1..dc5b123 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json @@ -4071,6 +4071,43 @@ "IsEnabled": true, "MultiTenancySide": 3, "MenuGroup": "Erp" + }, + + { + "GroupName": "App.Administration", + "Name": "App.Videoroom", + "ParentName": null, + "DisplayName": "App.Videoroom", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Kurs" + }, + { + "GroupName": "App.Administration", + "Name": "App.Videoroom.Dashboard", + "ParentName": "App.Videoroom", + "DisplayName": "App.Videoroom.Dashboard", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Kurs" + }, + { + "GroupName": "App.Administration", + "Name": "App.Videoroom.List", + "ParentName": "App.Videoroom", + "DisplayName": "App.Videoroom.List", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Kurs" + }, + { + "GroupName": "App.Administration", + "Name": "App.Videoroom.RoomDetail", + "ParentName": "App.Videoroom", + "DisplayName": "App.Videoroom.RoomDetail", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Kurs" } ] } diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/TableNameEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/TableNameEnum.cs index 359d766..f3ebec8 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Enums/TableNameEnum.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/TableNameEnum.cs @@ -80,5 +80,9 @@ public enum TableNameEnum EventType, Event, EventPhoto, - EventComment + EventComment, + Videoroom, + VideoroomParticipant, + VideoroomAttandance, + VideoroomChat } diff --git a/api/src/Sozsoft.Platform.Domain.Shared/TableNameResolver.cs b/api/src/Sozsoft.Platform.Domain.Shared/TableNameResolver.cs index 377bfc7..030268c 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/TableNameResolver.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/TableNameResolver.cs @@ -76,6 +76,10 @@ public static class TableNameResolver { nameof(TableNameEnum.Note), (TablePrefix.TenantByName, MenuPrefix.Administration) }, { nameof(TableNameEnum.ReportCategory), (TablePrefix.TenantByName, MenuPrefix.Administration) }, { nameof(TableNameEnum.ReportTemplate), (TablePrefix.TenantByName, MenuPrefix.Administration) }, + { nameof(TableNameEnum.Videoroom), (TablePrefix.TenantByName, MenuPrefix.Administration) }, + { nameof(TableNameEnum.VideoroomParticipant), (TablePrefix.TenantByName, MenuPrefix.Administration) }, + { nameof(TableNameEnum.VideoroomAttandance), (TablePrefix.TenantByName, MenuPrefix.Administration) }, + { nameof(TableNameEnum.VideoroomChat), (TablePrefix.TenantByName, MenuPrefix.Administration) }, // 🔹 INTRANET TABLOLARI { nameof(TableNameEnum.Announcement), (TablePrefix.TenantByName, MenuPrefix.Administration) }, diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/Videoroom.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/Videoroom.cs new file mode 100644 index 0000000..826c3cd --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/Videoroom.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Sozsoft.Platform.Entities; + +public class Videoroom : FullAuditedEntity, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? BranchId { get; set; } + + public string Name { get; set; } + public string Description { get; set; } + public string Subject { get; set; } + public Guid? TeacherId { get; set; } + public string TeacherName { get; set; } + public DateTime ScheduledStartTime { get; set; } + public DateTime? ScheduledEndTime { get; set; } + public int Duration { get; set; } + public DateTime? ActualStartTime { get; set; } + public DateTime? ActualEndTime { get; set; } + public int MaxParticipants { get; set; } + public int ParticipantCount { get; set; } + public string SettingsJson { get; set; } + + [JsonIgnore] + public virtual ICollection Participants { get; set; } = new HashSet(); + public virtual ICollection AttendanceRecords { get; set; } = new HashSet(); + public virtual ICollection ChatMessages { get; set; } = new HashSet(); +} + diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomAttandance.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomAttandance.cs new file mode 100644 index 0000000..3d81d45 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomAttandance.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.Json.Serialization; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Sozsoft.Platform.Entities; + +public class VideoroomAttandance : FullAuditedEntity, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? BranchId { get; set; } + + public Guid SessionId { get; set; } + public Guid? StudentId { get; set; } + public string StudentName { get; set; } + public DateTime JoinTime { get; set; } + public DateTime? LeaveTime { get; set; } + public int TotalDurationMinutes { get; set; } + + [JsonIgnore] + public virtual Videoroom Session { get; set; } + + protected VideoroomAttandance() + { + } + + public VideoroomAttandance( + Guid id, + Guid sessionId, + Guid? studentId, + string studentName, + DateTime joinTime + ) : base(id) + { + SessionId = sessionId; + StudentId = studentId; + StudentName = studentName; + JoinTime = joinTime; + TotalDurationMinutes = 0; + } + + public void CalculateDuration() + { + if (LeaveTime.HasValue) + { + var duration = LeaveTime.Value - JoinTime; + TotalDurationMinutes = (int)duration.TotalMinutes; + } + else + { + var duration = DateTime.UtcNow - JoinTime; + TotalDurationMinutes = (int)duration.TotalMinutes; + } + } +} + diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomChat.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomChat.cs new file mode 100644 index 0000000..4149ff6 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomChat.cs @@ -0,0 +1,53 @@ +using System; +using System.Text.Json.Serialization; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Sozsoft.Platform.Entities; + +public class VideoroomChat : FullAuditedEntity, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? BranchId { get; set; } + + public Guid SessionId { get; set; } + public Guid? SenderId { get; set; } + public string SenderName { get; set; } + public string Message { get; set; } + public DateTime Timestamp { get; set; } + public Guid? RecipientId { get; set; } + public string? RecipientName { get; set; } + public bool IsTeacher { get; set; } + public string MessageType { get; set; } + + [JsonIgnore] + public virtual Videoroom Session { get; set; } + + protected VideoroomChat() + { + } + + public VideoroomChat( + Guid id, + Guid sessionId, + Guid? senderId, + string senderName, + string message, + Guid? recipientId, + string recipientName, + bool isTeacher, + string messageType + ) : base(id) + { + SessionId = sessionId; + SenderId = senderId; + SenderName = senderName; + Message = message; + RecipientId = recipientId; + RecipientName = recipientName; + IsTeacher = isTeacher; + Timestamp = DateTime.UtcNow; + MessageType = messageType; + } +} + diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomParticipant.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomParticipant.cs new file mode 100644 index 0000000..689797c --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Videoroom/VideoroomParticipant.cs @@ -0,0 +1,82 @@ +using System; +using System.Text.Json.Serialization; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Sozsoft.Platform.Entities; + +public class VideoroomParticipant : FullAuditedEntity, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? BranchId { get; set; } + + public Guid SessionId { get; set; } + public Guid? UserId { get; set; } + public string UserName { get; set; } + public bool IsTeacher { get; set; } + public bool IsAudioMuted { get; set; } = false; + public bool IsVideoMuted { get; set; } = false; + public bool IsHandRaised { get; set; } = false; + public bool IsKicked { get; set; } = false; + public bool IsActive { get; set; } = true; + public DateTime JoinTime { get; set; } + public string ConnectionId { get; set; } + + [JsonIgnore] + public virtual Videoroom Session { get; set; } + + protected VideoroomParticipant() + { + } + + public VideoroomParticipant( + Guid id, + Guid sessionId, + Guid? userId, + string userName, + bool isTeacher, + bool isAudioMuted, + bool isVideoMuted, + bool isHandRaised, + bool isKicked, + bool isActive + ) : base(id) + { + SessionId = sessionId; + UserId = userId; + UserName = userName; + IsTeacher = isTeacher; + IsAudioMuted = isAudioMuted; + IsVideoMuted = isVideoMuted; + IsHandRaised = isHandRaised; + IsActive = isActive; + IsKicked = isKicked; + JoinTime = DateTime.UtcNow; + } + + public void MuteAudio() + { + IsAudioMuted = true; + } + + public void UnmuteAudio() + { + IsAudioMuted = false; + } + + public void MuteVideo() + { + IsVideoMuted = true; + } + + public void UnmuteVideo() + { + IsVideoMuted = false; + } + + public void UpdateConnectionId(string connectionId) + { + ConnectionId = connectionId; + } +} + diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs index f804a0a..ee4bc61 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs @@ -131,6 +131,13 @@ public class PlatformDbContext : public DbSet SocialLikes { get; set; } #endregion + #region VideoCall + public DbSet ClassSessions { get; set; } + public DbSet Participants { get; set; } + public DbSet AttendanceRecords { get; set; } + public DbSet ChatMessages { get; set; } + #endregion + public PlatformDbContext(DbContextOptions options) : base(options) { @@ -1267,5 +1274,77 @@ public class PlatformDbContext : .HasForeignKey(x => x.EventId) .OnDelete(DeleteBehavior.Cascade); }); + + //Videoroom + builder.Entity(b => + { + b.ToTable(TableNameResolver.GetFullTableName(nameof(TableNameEnum.Videoroom)), Prefix.DbSchema); + b.ConfigureByConvention(); + + b.Property(x => x.Name).IsRequired().HasMaxLength(256); + b.Property(x => x.Description).HasMaxLength(1024); + b.Property(x => x.Subject).HasMaxLength(128); + b.Property(x => x.TeacherName).IsRequired().HasMaxLength(128); + + b.HasIndex(x => x.TeacherId); + b.HasIndex(x => x.ScheduledStartTime); + + b.HasMany(x => x.Participants) + .WithOne(x => x.Session) + .HasForeignKey(x => x.SessionId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.AttendanceRecords) + .WithOne(x => x.Session) + .HasForeignKey(x => x.SessionId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.ChatMessages) + .WithOne(x => x.Session) + .HasForeignKey(x => x.SessionId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(b => + { + b.ToTable(TableNameResolver.GetFullTableName(nameof(TableNameEnum.VideoroomParticipant)), Prefix.DbSchema); + b.ConfigureByConvention(); + + b.Property(x => x.UserName).IsRequired().HasMaxLength(128); + b.Property(x => x.ConnectionId).HasMaxLength(128); + b.Property(x => x.IsActive).HasDefaultValue(true); + b.Property(x => x.IsKicked).HasDefaultValue(false); + + b.HasIndex(x => x.SessionId); + b.HasIndex(x => x.UserId); + b.HasIndex(x => new { x.SessionId, x.UserId }).IsUnique(); + }); + + builder.Entity(b => + { + b.ToTable(TableNameResolver.GetFullTableName(nameof(TableNameEnum.VideoroomAttandance)), Prefix.DbSchema); + b.ConfigureByConvention(); + + b.Property(x => x.StudentName).IsRequired().HasMaxLength(128); + + b.HasIndex(x => x.SessionId); + b.HasIndex(x => x.StudentId); + b.HasIndex(x => x.JoinTime); + }); + + builder.Entity(b => + { + b.ToTable(TableNameResolver.GetFullTableName(nameof(TableNameEnum.VideoroomChat)), Prefix.DbSchema); + b.ConfigureByConvention(); + + b.Property(x => x.SenderName).IsRequired().HasMaxLength(128); + b.Property(x => x.Message).IsRequired().HasMaxLength(2048); + b.Property(x => x.MessageType).HasMaxLength(64); + b.Property(x => x.RecipientName).HasMaxLength(256); + + b.HasIndex(x => x.SessionId); + b.HasIndex(x => x.SenderId); + b.HasIndex(x => x.Timestamp); + }); } } diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.Designer.cs similarity index 95% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.Designer.cs index b4928dc..3cc8529 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260507140651_Initial")] + [Migration("20260507202053_Initial")] partial class Initial { /// @@ -4894,6 +4894,356 @@ namespace Sozsoft.Platform.Migrations b.ToTable("Sas_H_UomCategory", (string)null); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEndTime") + .HasColumnType("datetime2"); + + b.Property("ActualStartTime") + .HasColumnType("datetime2"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Duration") + .HasColumnType("int"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ParticipantCount") + .HasColumnType("int"); + + b.Property("ScheduledEndTime") + .HasColumnType("datetime2"); + + b.Property("ScheduledStartTime") + .HasColumnType("datetime2"); + + b.Property("SettingsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeacherId") + .HasColumnType("uniqueidentifier"); + + b.Property("TeacherName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScheduledStartTime"); + + b.HasIndex("TeacherId"); + + b.ToTable("Adm_T_Videoroom", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomAttandance", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("JoinTime") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("LeaveTime") + .HasColumnType("datetime2"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StudentId") + .HasColumnType("uniqueidentifier"); + + b.Property("StudentName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("TotalDurationMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JoinTime"); + + b.HasIndex("SessionId"); + + b.HasIndex("StudentId"); + + b.ToTable("Adm_T_VideoroomAttandance", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomChat", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsTeacher") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("MessageType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RecipientId") + .HasColumnType("uniqueidentifier"); + + b.Property("RecipientName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("SessionId"); + + b.HasIndex("Timestamp"); + + b.ToTable("Adm_T_VideoroomChat", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("ConnectionId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAudioMuted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsHandRaised") + .HasColumnType("bit"); + + b.Property("IsKicked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsTeacher") + .HasColumnType("bit"); + + b.Property("IsVideoMuted") + .HasColumnType("bit"); + + b.Property("JoinTime") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.HasIndex("SessionId", "UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Adm_T_VideoroomParticipant", (string)null); + }); + modelBuilder.Entity("Sozsoft.Platform.Entities.WorkHour", b => { b.Property("Id") @@ -7599,6 +7949,39 @@ namespace Sozsoft.Platform.Migrations b.Navigation("UomCategory"); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomAttandance", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("AttendanceRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomChat", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("ChatMessages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomParticipant", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("Participants") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + modelBuilder.Entity("Sozsoft.Platform.Forum.ForumPost", b => { b.HasOne("Sozsoft.Platform.Forum.ForumPost", "ParentPost") @@ -7885,6 +8268,15 @@ namespace Sozsoft.Platform.Migrations b.Navigation("Uoms"); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b => + { + b.Navigation("AttendanceRecords"); + + b.Navigation("ChatMessages"); + + b.Navigation("Participants"); + }); + modelBuilder.Entity("Sozsoft.Platform.Forum.ForumCategory", b => { b.Navigation("Topics"); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.cs similarity index 94% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.cs index afd2eec..57b1273 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507140651_Initial.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260507202053_Initial.cs @@ -691,6 +691,39 @@ namespace Sozsoft.Platform.Migrations table.PrimaryKey("PK_Adm_T_Survey", x => x.Id); }); + migrationBuilder.CreateTable( + name: "Adm_T_Videoroom", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + BranchId = table.Column(type: "uniqueidentifier", nullable: true), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Subject = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + TeacherId = table.Column(type: "uniqueidentifier", nullable: true), + TeacherName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ScheduledStartTime = table.Column(type: "datetime2", nullable: false), + ScheduledEndTime = table.Column(type: "datetime2", nullable: true), + Duration = table.Column(type: "int", nullable: false), + ActualStartTime = table.Column(type: "datetime2", nullable: true), + ActualEndTime = table.Column(type: "datetime2", nullable: true), + MaxParticipants = table.Column(type: "int", nullable: false), + ParticipantCount = table.Column(type: "int", nullable: false), + SettingsJson = table.Column(type: "nvarchar(max)", nullable: true), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorId = table.Column(type: "uniqueidentifier", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierId = table.Column(type: "uniqueidentifier", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false, defaultValue: false), + DeleterId = table.Column(type: "uniqueidentifier", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Adm_T_Videoroom", x => x.Id); + }); + migrationBuilder.CreateTable( name: "Adm_T_WorkHour", columns: table => new @@ -2205,6 +2238,110 @@ namespace Sozsoft.Platform.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "Adm_T_VideoroomAttandance", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + BranchId = table.Column(type: "uniqueidentifier", nullable: true), + SessionId = table.Column(type: "uniqueidentifier", nullable: false), + StudentId = table.Column(type: "uniqueidentifier", nullable: true), + StudentName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + JoinTime = table.Column(type: "datetime2", nullable: false), + LeaveTime = table.Column(type: "datetime2", nullable: true), + TotalDurationMinutes = table.Column(type: "int", nullable: false), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorId = table.Column(type: "uniqueidentifier", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierId = table.Column(type: "uniqueidentifier", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false, defaultValue: false), + DeleterId = table.Column(type: "uniqueidentifier", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Adm_T_VideoroomAttandance", x => x.Id); + table.ForeignKey( + name: "FK_Adm_T_VideoroomAttandance_Adm_T_Videoroom_SessionId", + column: x => x.SessionId, + principalTable: "Adm_T_Videoroom", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Adm_T_VideoroomChat", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + BranchId = table.Column(type: "uniqueidentifier", nullable: true), + SessionId = table.Column(type: "uniqueidentifier", nullable: false), + SenderId = table.Column(type: "uniqueidentifier", nullable: true), + SenderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Message = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + Timestamp = table.Column(type: "datetime2", nullable: false), + RecipientId = table.Column(type: "uniqueidentifier", nullable: true), + RecipientName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + IsTeacher = table.Column(type: "bit", nullable: false), + MessageType = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorId = table.Column(type: "uniqueidentifier", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierId = table.Column(type: "uniqueidentifier", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false, defaultValue: false), + DeleterId = table.Column(type: "uniqueidentifier", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Adm_T_VideoroomChat", x => x.Id); + table.ForeignKey( + name: "FK_Adm_T_VideoroomChat_Adm_T_Videoroom_SessionId", + column: x => x.SessionId, + principalTable: "Adm_T_Videoroom", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Adm_T_VideoroomParticipant", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: true), + BranchId = table.Column(type: "uniqueidentifier", nullable: true), + SessionId = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + UserName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + IsTeacher = table.Column(type: "bit", nullable: false), + IsAudioMuted = table.Column(type: "bit", nullable: false), + IsVideoMuted = table.Column(type: "bit", nullable: false), + IsHandRaised = table.Column(type: "bit", nullable: false), + IsKicked = table.Column(type: "bit", nullable: false, defaultValue: false), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + JoinTime = table.Column(type: "datetime2", nullable: false), + ConnectionId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + CreationTime = table.Column(type: "datetime2", nullable: false), + CreatorId = table.Column(type: "uniqueidentifier", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierId = table.Column(type: "uniqueidentifier", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: false, defaultValue: false), + DeleterId = table.Column(type: "uniqueidentifier", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Adm_T_VideoroomParticipant", x => x.Id); + table.ForeignKey( + name: "FK_Adm_T_VideoroomParticipant_Adm_T_Videoroom_SessionId", + column: x => x.SessionId, + principalTable: "Adm_T_Videoroom", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "OpenIddictAuthorizations", columns: table => new @@ -3306,6 +3443,63 @@ namespace Sozsoft.Platform.Migrations table: "Adm_T_SurveyResponse", column: "SurveyId"); + migrationBuilder.CreateIndex( + name: "IX_Adm_T_Videoroom_ScheduledStartTime", + table: "Adm_T_Videoroom", + column: "ScheduledStartTime"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_Videoroom_TeacherId", + table: "Adm_T_Videoroom", + column: "TeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomAttandance_JoinTime", + table: "Adm_T_VideoroomAttandance", + column: "JoinTime"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomAttandance_SessionId", + table: "Adm_T_VideoroomAttandance", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomAttandance_StudentId", + table: "Adm_T_VideoroomAttandance", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomChat_SenderId", + table: "Adm_T_VideoroomChat", + column: "SenderId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomChat_SessionId", + table: "Adm_T_VideoroomChat", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomChat_Timestamp", + table: "Adm_T_VideoroomChat", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomParticipant_SessionId", + table: "Adm_T_VideoroomParticipant", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomParticipant_SessionId_UserId", + table: "Adm_T_VideoroomParticipant", + columns: new[] { "SessionId", "UserId" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Adm_T_VideoroomParticipant_UserId", + table: "Adm_T_VideoroomParticipant", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_OpenIddictApplications_ClientId", table: "OpenIddictApplications", @@ -3629,6 +3823,15 @@ namespace Sozsoft.Platform.Migrations migrationBuilder.DropTable( name: "Adm_T_SurveyQuestionOption"); + migrationBuilder.DropTable( + name: "Adm_T_VideoroomAttandance"); + + migrationBuilder.DropTable( + name: "Adm_T_VideoroomChat"); + + migrationBuilder.DropTable( + name: "Adm_T_VideoroomParticipant"); + migrationBuilder.DropTable( name: "Adm_T_WorkHour"); @@ -3782,6 +3985,9 @@ namespace Sozsoft.Platform.Migrations migrationBuilder.DropTable( name: "Adm_T_SurveyQuestion"); + migrationBuilder.DropTable( + name: "Adm_T_Videoroom"); + migrationBuilder.DropTable( name: "OpenIddictAuthorizations"); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs index ec465ef..8c9f690 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs @@ -4891,6 +4891,356 @@ namespace Sozsoft.Platform.Migrations b.ToTable("Sas_H_UomCategory", (string)null); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEndTime") + .HasColumnType("datetime2"); + + b.Property("ActualStartTime") + .HasColumnType("datetime2"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Duration") + .HasColumnType("int"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ParticipantCount") + .HasColumnType("int"); + + b.Property("ScheduledEndTime") + .HasColumnType("datetime2"); + + b.Property("ScheduledStartTime") + .HasColumnType("datetime2"); + + b.Property("SettingsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeacherId") + .HasColumnType("uniqueidentifier"); + + b.Property("TeacherName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScheduledStartTime"); + + b.HasIndex("TeacherId"); + + b.ToTable("Adm_T_Videoroom", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomAttandance", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("JoinTime") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("LeaveTime") + .HasColumnType("datetime2"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StudentId") + .HasColumnType("uniqueidentifier"); + + b.Property("StudentName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("TotalDurationMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JoinTime"); + + b.HasIndex("SessionId"); + + b.HasIndex("StudentId"); + + b.ToTable("Adm_T_VideoroomAttandance", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomChat", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsTeacher") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("MessageType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RecipientId") + .HasColumnType("uniqueidentifier"); + + b.Property("RecipientName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SenderId") + .HasColumnType("uniqueidentifier"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("SessionId"); + + b.HasIndex("Timestamp"); + + b.ToTable("Adm_T_VideoroomChat", (string)null); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomParticipant", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("BranchId") + .HasColumnType("uniqueidentifier"); + + b.Property("ConnectionId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreationTime") + .HasColumnType("datetime2") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uniqueidentifier") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uniqueidentifier") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("datetime2") + .HasColumnName("DeletionTime"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAudioMuted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsHandRaised") + .HasColumnType("bit"); + + b.Property("IsKicked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsTeacher") + .HasColumnType("bit"); + + b.Property("IsVideoMuted") + .HasColumnType("bit"); + + b.Property("JoinTime") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uniqueidentifier") + .HasColumnName("LastModifierId"); + + b.Property("SessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.HasIndex("SessionId", "UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Adm_T_VideoroomParticipant", (string)null); + }); + modelBuilder.Entity("Sozsoft.Platform.Entities.WorkHour", b => { b.Property("Id") @@ -7596,6 +7946,39 @@ namespace Sozsoft.Platform.Migrations b.Navigation("UomCategory"); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomAttandance", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("AttendanceRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomChat", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("ChatMessages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Sozsoft.Platform.Entities.VideoroomParticipant", b => + { + b.HasOne("Sozsoft.Platform.Entities.Videoroom", "Session") + .WithMany("Participants") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + modelBuilder.Entity("Sozsoft.Platform.Forum.ForumPost", b => { b.HasOne("Sozsoft.Platform.Forum.ForumPost", "ParentPost") @@ -7882,6 +8265,15 @@ namespace Sozsoft.Platform.Migrations b.Navigation("Uoms"); }); + modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b => + { + b.Navigation("AttendanceRecords"); + + b.Navigation("ChatMessages"); + + b.Navigation("Participants"); + }); + modelBuilder.Entity("Sozsoft.Platform.Forum.ForumCategory", b => { b.Navigation("Topics"); diff --git a/configs/deployment/configs/nginx.conf b/configs/deployment/configs/nginx.conf index 2f12957..f69347b 100644 --- a/configs/deployment/configs/nginx.conf +++ b/configs/deployment/configs/nginx.conf @@ -82,8 +82,8 @@ server { large_client_header_buffers 4 16k; # SignalR için özel ayar - location /classroomhub { - proxy_pass http://127.0.0.1:8080/classroomhub; + location /videoroomhub { + proxy_pass http://127.0.0.1:8080/videoroomhub; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/ui/src/proxy/videoroom/models.ts b/ui/src/proxy/videoroom/models.ts new file mode 100644 index 0000000..724a341 --- /dev/null +++ b/ui/src/proxy/videoroom/models.ts @@ -0,0 +1,111 @@ +import { PagedAndSortedResultRequestDto } from '../abp' + +export type Role = 'teacher' | 'student' | 'observer' +export type RoleState = 'role-selection' | 'dashboard' | 'videoroom' + +export type MessageType = 'public' | 'private' | 'announcement' + +export type VideoLayoutType = 'grid' | 'sidebar' | 'teacher-focus' + +export interface VideoroomDto { + id: string + name: string + description?: string + subject?: string + teacherId: string + teacherName: string + scheduledStartTime: string + scheduledEndTime: string + duration?: number + actualStartTime?: string + actualEndTime?: string + maxParticipants?: number + participantCount: number + settingsDto?: VideoroomSettingsDto +} + +export interface VideoroomSettingsDto { + allowHandRaise: boolean + allowStudentChat: boolean + allowPrivateMessages: boolean + allowStudentScreenShare: boolean + defaultMicrophoneState: 'muted' | 'unmuted' + defaultCameraState: 'on' | 'off' + defaultLayout: string + autoMuteNewParticipants: boolean +} + +export interface VideoroomAttendanceDto { + id: string + sessionId: string + studentId: string + studentName: string + joinTime: string + leaveTime?: string + totalDurationMinutes: number +} + +export interface VideoroomParticipantDto { + id: string + name: string + sessionId: string + isTeacher: boolean + isObserver?: boolean + isAudioMuted?: boolean + isVideoMuted?: boolean + isHandRaised?: boolean + isKicked?: boolean + isActive?: boolean + stream?: MediaStream + screenStream?: MediaStream + isScreenSharing?: boolean + peerConnection?: RTCPeerConnection +} + +export interface VideoroomChatDto { + id: string + sessionId: string + senderId: string + senderName: string + message: string + timestamp: string + isTeacher: boolean + recipientId?: string + recipientName?: string + messageType: MessageType +} + +export interface VideoroomLayoutDto { + id: string + name: string + type: VideoLayoutType + description: string +} + +export interface VideoroomDocumentDto { + id: string + name: string + url: string + type: string + size: number + uploadedAt: string + uploadedBy: string + isPresentation?: boolean + totalPages?: number +} + +export interface Videoroom { + id: string + name: string + layoutType: string + rows: number + columns: number + capacity: number + creationTime: string + lastModificationTime: string +} + +export interface VideoroomFilterInputDto extends PagedAndSortedResultRequestDto { + search: string + status: string +} diff --git a/ui/src/routes/route.constant.ts b/ui/src/routes/route.constant.ts index 8de8d5c..809c3cd 100644 --- a/ui/src/routes/route.constant.ts +++ b/ui/src/routes/route.constant.ts @@ -73,6 +73,13 @@ export const ROUTES_ENUM = { }, forum: '/admin/forum', + videoroom: { + dashboard: '/admin/videoroom/dashboard', + roomList: '/admin/videoroom/list', + roomDetail: '/admin/videoroom/room/:id', + planning: '/admin/videoroom/planning/:id', + }, + list: '/admin/list/:listFormCode', formNew: '/admin/form/:listFormCode', formView: '/admin/form/:listFormCode/:id', @@ -86,11 +93,11 @@ export const ROUTES_ENUM = { accessDenied: '/admin/access-denied', coordinator: { - classroom: { - dashboard: '/admin/coordinator/classroom/dashboard', - classes: '/admin/coordinator/classroom/classes', - roomDetail: '/admin/coordinator/classroom/room/:id', - planning: '/admin/coordinator/classroom/planning/:id', + videoroom: { + dashboard: '/admin/coordinator/videoroom/dashboard', + roomList: '/admin/coordinator/videoroom/rooms', + roomDetail: '/admin/coordinator/videoroom/room/:id', + planning: '/admin/coordinator/videoroom/planning/:id', }, exams: '/admin/coordinator/exams', examDetail: '/admin/coordinator/exam/:id', diff --git a/ui/src/services/classroom.service.ts b/ui/src/services/classroom.service.ts deleted file mode 100644 index 78c7295..0000000 --- a/ui/src/services/classroom.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - ClassroomAttendanceDto, - ClassroomChatDto, - ClassroomDto, - ClassroomFilterInputDto, - ClassroomParticipantDto, -} from '@/proxy/classroom/models' -import apiService from './api.service' -import { PagedAndSortedResultRequestDto, PagedResultDto } from '@/proxy' - -export const getClassroomById = (id: string) => - apiService.fetchData({ - method: 'GET', - url: `/api/app/classroom/${id}`, - }) - -export const getClassrooms = (input: ClassroomFilterInputDto) => - apiService.fetchData>({ - method: 'GET', - url: `/api/app/classroom`, - params: input, - }) - -export const createClassroom = (input: ClassroomDto) => - apiService.fetchData({ - method: 'POST', - url: `/api/app/classroom`, - data: input as any, - }) - -export const updateClassroom = (input: ClassroomDto) => - apiService.fetchData({ - method: 'PUT', - url: `/api/app/classroom/${input.id}`, - data: input, - }) - -export const deleteClassroom = (id: string) => - apiService.fetchData({ - method: 'DELETE', - url: `/api/app/classroom/${id}`, - }) - -export const startClassroom = (id: string) => - apiService.fetchData({ - method: 'PUT', - url: `/api/app/classroom/${id}/start-class`, - }) - -export const endClassroom = (id: string) => - apiService.fetchData({ - method: 'PUT', - url: `/api/app/classroom/${id}/end-class`, - }) - -export const getClassroomAttandances = (id: string) => - apiService.fetchData({ - method: 'GET', - url: `/api/app/classroom/attendance/${id}`, - }) - -export const getClassroomParticipants = (id: string) => - apiService.fetchData({ - method: 'GET', - url: `/api/app/classroom/participant/${id}`, - }) - -export const getClassroomChats = (id: string) => - apiService.fetchData({ - method: 'GET', - url: `/api/app/classroom/chat/${id}`, - }) diff --git a/ui/src/services/videoroom.service.ts b/ui/src/services/videoroom.service.ts new file mode 100644 index 0000000..bba7dc2 --- /dev/null +++ b/ui/src/services/videoroom.service.ts @@ -0,0 +1,72 @@ +import { + VideoroomAttendanceDto, + VideoroomChatDto, + VideoroomDto, + VideoroomFilterInputDto, + VideoroomParticipantDto, +} from '@/proxy/videoroom/models' +import apiService from './api.service' +import { PagedResultDto } from '@/proxy' + +export const getVideoroomById = (id: string) => + apiService.fetchData({ + method: 'GET', + url: `/api/app/videoroom/${id}`, + }) + +export const getVideorooms = (input: VideoroomFilterInputDto) => + apiService.fetchData>({ + method: 'GET', + url: `/api/app/videoroom`, + params: input, + }) + +export const createVideoroom = (input: VideoroomDto) => + apiService.fetchData({ + method: 'POST', + url: `/api/app/videoroom`, + data: input as any, + }) + +export const updateVideoroom = (input: VideoroomDto) => + apiService.fetchData({ + method: 'PUT', + url: `/api/app/videoroom/${input.id}`, + data: input, + }) + +export const deleteVideoroom = (id: string) => + apiService.fetchData({ + method: 'DELETE', + url: `/api/app/videoroom/${id}`, + }) + +export const startVideoroom = (id: string) => + apiService.fetchData({ + method: 'PUT', + url: `/api/app/videoroom/${id}/start-class`, + }) + +export const endVideoroom = (id: string) => + apiService.fetchData({ + method: 'PUT', + url: `/api/app/videoroom/${id}/end-class`, + }) + +export const getVideoroomAttandances = (id: string) => + apiService.fetchData({ + method: 'GET', + url: `/api/app/videoroom/attendance/${id}`, + }) + +export const getVideoroomParticipants = (id: string) => + apiService.fetchData({ + method: 'GET', + url: `/api/app/videoroom/participant/${id}`, + }) + +export const getVideoroomChats = (id: string) => + apiService.fetchData({ + method: 'GET', + url: `/api/app/videoroom/chat/${id}`, + }) diff --git a/ui/src/services/videoroom/signalr.tsx b/ui/src/services/videoroom/signalr.tsx new file mode 100644 index 0000000..f799b50 --- /dev/null +++ b/ui/src/services/videoroom/signalr.tsx @@ -0,0 +1,573 @@ +import { ROUTES_ENUM } from '@/routes/route.constant' +import { store } from '@/store/store' +import * as signalR from '@microsoft/signalr' +import { toast } from '@/components/ui' +import Notification from '@/components/ui/Notification' +import { VideoroomAttendanceDto, VideoroomChatDto } from '@/proxy/videoroom/models' + +export class SignalRService { + private connection!: signalR.HubConnection + private isConnected: boolean = false + private currentSessionId?: string + private isKicked: boolean = false + + private onAttendanceUpdate?: (record: VideoroomAttendanceDto) => void + private onParticipantJoined?: ( + userId: string, + name: string, + isTeacher: boolean, + isActive: boolean, + ) => void + private onParticipantLeft?: (payload: { + userId: string + sessionId: string + userName: string + }) => void + private onChatMessage?: (message: VideoroomChatDto) => void + private onParticipantMuted?: (userId: string, isMuted: boolean) => void + private onHandRaiseReceived?: (studentId: string) => void + private onHandRaiseDismissed?: (studentId: string) => void + private onOfferReceived?: (fromUserId: string, offer: RTCSessionDescriptionInit) => void + private onAnswerReceived?: (fromUserId: string, answer: RTCSessionDescriptionInit) => void + private onIceCandidateReceived?: (fromUserId: string, candidate: RTCIceCandidateInit) => void + private onForceCleanup?: () => void + + constructor() { + const { auth } = store.getState() + + this.connection = new signalR.HubConnectionBuilder() + .withUrl(`${import.meta.env.VITE_API_URL}/videoroomhub`, { + accessTokenFactory: () => auth.session.token || '', + }) + .configureLogging(signalR.LogLevel.Information) + .build() + + this.setupEventHandlers() + } + + private setupEventHandlers() { + if (!this.connection) return + + this.connection.on('AttendanceUpdated', (record: VideoroomAttendanceDto) => { + this.onAttendanceUpdate?.(record) + }) + + this.connection.on( + 'ParticipantJoined', + (userId: string, name: string, isTeacher: boolean, isActive: boolean) => { + this.onParticipantJoined?.(userId, name, isTeacher, isActive) + }, + ) + + this.connection.on( + 'ParticipantLeft', + (payload: { userId: string; sessionId: string; userName: string }) => { + this.onParticipantLeft?.(payload) + }, + ) + + 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', (payload: any) => { + this.onHandRaiseReceived?.(payload.studentId) + }) + + this.connection.on('HandRaiseDismissed', (payload: any) => { + this.onHandRaiseDismissed?.(payload.studentId) + }) + + this.connection.on('ReceiveOffer', (fromUserId: string, offer: RTCSessionDescriptionInit) => { + this.onOfferReceived?.(fromUserId, offer) + }) + + this.connection.on('ReceiveAnswer', (fromUserId: string, answer: RTCSessionDescriptionInit) => { + this.onAnswerReceived?.(fromUserId, answer) + }) + + this.connection.on( + 'ReceiveIceCandidate', + (fromUserId: string, candidate: RTCIceCandidateInit) => { + this.onIceCandidateReceived?.(fromUserId, candidate) + }, + ) + + this.connection.onreconnected(async () => { + this.isConnected = true + toast.push(, { + placement: 'top-end', + }) + + if (this.currentSessionId && store.getState().auth.user) { + const u = store.getState().auth.user + await this.joinClass(this.currentSessionId, u.id, u.name, u.role === 'teacher', true) + } + }) + + this.connection.onclose(async () => { + if (this.isKicked) { + toast.push( + , + { placement: 'top-end' }, + ) + this.isConnected = false + this.currentSessionId = undefined + return + } + + this.isConnected = false + try { + if (this.currentSessionId) { + await this.connection.invoke('LeaveClass', this.currentSessionId) + } + } finally { + this.currentSessionId = undefined + } + }) + + this.connection.on('Error', (message: string) => { + toast.push(, { + placement: 'top-end', + }) + }) + + this.connection.on('Warning', (message: string) => { + toast.push(, { + placement: 'top-end', + }) + }) + + this.connection.on('Info', (message: string) => { + toast.push(, { + placement: 'top-end', + }) + }) + + this.connection.onreconnecting(() => { + if (this.isKicked) { + toast.push( + , + ) + this.connection.stop() + throw new Error('Reconnect blocked after kick') + } + }) + + this.connection.on('ForceDisconnect', async (message: string) => { + this.isKicked = true + toast.push(, { + placement: 'top-end', + }) + + if (this.onForceCleanup) { + this.onForceCleanup() + } + + try { + await this.connection.stop() + } catch {} + + this.isConnected = false + + if (this.currentSessionId && store.getState().auth.user) { + this.onParticipantLeft?.({ + userId: store.getState().auth.user.id, + sessionId: this.currentSessionId, + userName: store.getState().auth.user.name, + }) + } + + this.currentSessionId = undefined + window.location.href = ROUTES_ENUM.protected.admin.videoroom.roomList + }) + } + + async start(): Promise { + try { + const startPromise = this.connection.start() + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Bağlantı zaman aşımına uğradı')), 10000), + ) + + await Promise.race([startPromise, timeout]) + this.isConnected = true + toast.push(, { + placement: 'top-end', + }) + } catch { + toast.push( + , + { placement: 'top-end' }, + ) + this.isConnected = false + } + } + + async joinClass( + sessionId: string, + userId: string, + userName: string, + isTeacher: boolean, + isActive: boolean, + ): Promise { + if (!this.isConnected) { + toast.push( + , + ) + return + } + + this.currentSessionId = sessionId + try { + await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async leaveClass(sessionId: string): Promise { + const { auth } = store.getState() + + if (!this.isConnected) { + this.onParticipantLeft?.({ userId: auth.user.id, sessionId, userName: auth.user.name }) + return + } + + try { + await this.connection.invoke('LeaveClass', sessionId) + this.currentSessionId = undefined + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async sendChatMessage( + sessionId: string, + senderId: string, + senderName: string, + message: string, + isTeacher: boolean, + ): Promise { + if (!this.isConnected) { + const chatMessage: VideoroomChatDto = { + id: crypto.randomUUID(), + sessionId, + 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 { + toast.push(, { + placement: 'top-end', + }) + } + } + + async sendPrivateMessage( + sessionId: string, + senderId: string, + senderName: string, + message: string, + recipientId: string, + recipientName: string, + isTeacher: boolean, + ): Promise { + if (!this.isConnected) { + const chatMessage: VideoroomChatDto = { + id: crypto.randomUUID(), + sessionId, + 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, + 'private', + ) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async sendAnnouncement( + sessionId: string, + senderId: string, + senderName: string, + message: string, + isTeacher: boolean, + ): Promise { + if (!this.isConnected) { + const chatMessage: VideoroomChatDto = { + id: crypto.randomUUID(), + sessionId, + senderId, + senderName, + message, + timestamp: new Date().toISOString(), + isTeacher, + messageType: 'announcement', + } + setTimeout(() => { + this.onChatMessage?.(chatMessage) + }, 100) + return + } + + try { + await this.connection.invoke( + 'SendAnnouncement', + sessionId, + senderId, + senderName, + message, + isTeacher, + ) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async muteParticipant( + sessionId: string, + userId: string, + isMuted: boolean, + isTeacher: boolean, + ): Promise { + if (!this.isConnected) { + setTimeout(() => { + this.onParticipantMuted?.(userId, isMuted) + }, 100) + return + } + + try { + await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async raiseHand(sessionId: string, studentId: string, studentName: string): Promise { + if (!this.isConnected) { + setTimeout(() => { + this.onHandRaiseReceived?.(studentId) + }, 100) + return + } + + try { + await this.connection.invoke('RaiseHand', sessionId, studentId, studentName) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async kickParticipant(sessionId: string, participantId: string, userName: string): Promise { + if (!this.isConnected) { + setTimeout(() => { + this.onParticipantLeft?.({ userId: participantId, sessionId, userName }) + }, 100) + return + } + + try { + await this.connection.invoke('KickParticipant', sessionId, participantId) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async approveHandRaise(sessionId: string, studentId: string): Promise { + if (!this.isConnected) { + setTimeout(() => { + this.onHandRaiseDismissed?.(studentId) + }, 100) + return + } + + try { + await this.connection.invoke('ApproveHandRaise', sessionId, studentId) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async dismissHandRaise(sessionId: string, studentId: string): Promise { + if (!this.isConnected) { + setTimeout(() => { + this.onHandRaiseDismissed?.(studentId) + }, 100) + return + } + + try { + await this.connection.invoke('DismissHandRaise', sessionId, studentId) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + async sendOffer(sessionId: string, targetUserId: string, offer: RTCSessionDescriptionInit) { + if (!this.isConnected) return + await this.connection.invoke('SendOffer', sessionId, targetUserId, offer) + } + + async sendAnswer(sessionId: string, targetUserId: string, answer: RTCSessionDescriptionInit) { + if (!this.isConnected) return + await this.connection.invoke('SendAnswer', sessionId, targetUserId, answer) + } + + async sendIceCandidate(sessionId: string, targetUserId: string, candidate: RTCIceCandidateInit) { + if (!this.isConnected) return + await this.connection.invoke('SendIceCandidate', sessionId, targetUserId, candidate) + } + + setExistingParticipantsHandler(callback: (participants: any[]) => void) { + this.connection.on('ExistingParticipants', callback) + } + + setAttendanceUpdatedHandler(callback: (record: VideoroomAttendanceDto) => void) { + this.onAttendanceUpdate = callback + } + + setParticipantJoinHandler( + callback: (userId: string, name: string, isTeacher: boolean, isActive: boolean) => void, + ) { + this.onParticipantJoined = callback + } + + setParticipantLeaveHandler( + callback: (payload: { userId: string; sessionId: string; userName: string }) => void, + ) { + this.onParticipantLeft = callback + } + + setChatMessageReceivedHandler(callback: (message: VideoroomChatDto) => void) { + this.onChatMessage = callback + } + + setParticipantMutedHandler(callback: (userId: string, isMuted: boolean) => void) { + this.onParticipantMuted = callback + } + + setHandRaiseReceivedHandler(callback: (studentId: string) => void) { + this.onHandRaiseReceived = callback + } + + setHandRaiseDismissedHandler(callback: (studentId: string) => void) { + this.onHandRaiseDismissed = callback + } + + setOfferReceivedHandler( + callback: (fromUserId: string, offer: RTCSessionDescriptionInit) => void, + ) { + this.onOfferReceived = callback + } + + setAnswerReceivedHandler( + callback: (fromUserId: string, answer: RTCSessionDescriptionInit) => void, + ) { + this.onAnswerReceived = callback + } + + setIceCandidateReceivedHandler( + callback: (fromUserId: string, candidate: RTCIceCandidateInit) => void, + ) { + this.onIceCandidateReceived = callback + } + + async disconnect(): Promise { + if (this.isConnected && this.currentSessionId) { + try { + await this.connection.invoke('LeaveClass', this.currentSessionId) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + if (this.connection) { + await this.connection.stop() + } + this.isConnected = false + this.currentSessionId = undefined + } + + getConnectionState(): boolean { + return this.isConnected + } + + setForceCleanupHandler(callback: () => void) { + this.onForceCleanup = callback + } +} diff --git a/ui/src/services/videoroom/webrtc.tsx b/ui/src/services/videoroom/webrtc.tsx new file mode 100644 index 0000000..ae4f5fd --- /dev/null +++ b/ui/src/services/videoroom/webrtc.tsx @@ -0,0 +1,358 @@ +import { toast } from '@/components/ui' +import Notification from '@/components/ui/Notification' + +export class WebRTCService { + private peerConnections: Map = new Map() + private retryCounts: Map = new Map() + private maxRetries = 3 + private signalRService: any + private sessionId: string = '' + + private localStream: MediaStream | null = null + private onRemoteStream?: (userId: string, stream: MediaStream) => void + private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void + private candidateBuffer: Map = new Map() + + private rtcConfiguration: RTCConfiguration = { + iceServers: [ + { + urls: [ + 'stun:turn.sozsoft.com:3478', + 'turn:turn.sozsoft.com:3478?transport=udp', + 'turn:turn.sozsoft.com:3478?transport=tcp', + 'turns:turn.sozsoft.com:5349?transport=tcp', + ], + username: 'webrtc', + credential: 'strongpassword123', + }, + ], + } + + async initializeLocalStream(enableAudio: boolean, enableVideo: boolean): Promise { + try { + this.localStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30 }, + }, + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }) + + this.localStream.getAudioTracks().forEach((track) => (track.enabled = enableAudio)) + this.localStream.getVideoTracks().forEach((track) => (track.enabled = enableVideo)) + + return this.localStream + } catch { + toast.push( + , + { placement: 'top-end' }, + ) + throw new Error('Media devices access failed') + } + } + + async createPeerConnection(userId: string): Promise { + const peerConnection = new RTCPeerConnection(this.rtcConfiguration) + this.peerConnections.set(userId, peerConnection) + this.retryCounts.set(userId, 0) + + if (this.localStream) { + this.localStream.getTracks().forEach((track) => { + peerConnection.addTrack(track, this.localStream!) + }) + } + + peerConnection.ontrack = (event) => { + const [remoteStream] = event.streams + this.onRemoteStream?.(userId, remoteStream) + } + + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + this.onIceCandidate?.(userId, event.candidate) + } + } + + peerConnection.onconnectionstatechange = async () => { + const state = peerConnection.connectionState + + if (state === 'closed') { + this.closePeerConnection(userId) + } + + if (state === 'failed') { + let retries = this.retryCounts.get(userId) ?? 0 + if (retries < this.maxRetries) { + toast.push( + , + ) + this.retryCounts.set(userId, retries + 1) + await this.restartIce(peerConnection, userId) + } else { + toast.push( + , + { placement: 'top-end' }, + ) + this.closePeerConnection(userId) + } + } + } + + if (this.candidateBuffer.has(userId)) { + for (const cand of this.candidateBuffer.get(userId)!) { + try { + await peerConnection.addIceCandidate(cand) + } catch { + toast.push( + , + ) + } + } + this.candidateBuffer.delete(userId) + } + + return peerConnection + } + + setSignalRService(signalRService: any, sessionId: string) { + this.signalRService = signalRService + this.sessionId = sessionId + } + + setIceCandidateHandler(callback: (userId: string, candidate: RTCIceCandidateInit) => void) { + this.onIceCandidate = callback + } + + async createOffer(userId: string): Promise { + const pc = this.peerConnections.get(userId) + if (!pc) throw new Error('Peer connection not found') + + try { + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + return offer + } catch { + toast.push(, { + placement: 'top-end', + }) + throw new Error('Offer creation failed') + } + } + + async createAnswer( + userId: string, + offer: RTCSessionDescriptionInit, + ): Promise { + const pc = this.peerConnections.get(userId) + if (!pc) throw new Error('Peer connection not found') + + try { + await pc.setRemoteDescription(offer) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + return answer + } catch { + toast.push(, { + placement: 'top-end', + }) + throw new Error('Answer creation failed') + } + } + + async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise { + 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 { + const pc = this.peerConnections.get(userId) + if (!pc) { + if (!this.candidateBuffer.has(userId)) { + this.candidateBuffer.set(userId, []) + } + this.candidateBuffer.get(userId)!.push(candidate) + return + } + + if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') { + try { + await pc.addIceCandidate(candidate) + } catch { + toast.push( + , + ) + } + } else { + if (!this.candidateBuffer.has(userId)) { + this.candidateBuffer.set(userId, []) + } + this.candidateBuffer.get(userId)!.push(candidate) + } + } + + onRemoteStreamReceived(callback: (userId: string, stream: MediaStream) => void) { + this.onRemoteStream = callback + } + + async toggleVideo(enabled: boolean): Promise { + if (!this.localStream) return + let videoTrack = this.localStream.getVideoTracks()[0] + + if (videoTrack) { + videoTrack.enabled = enabled + } else if (enabled) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }) + const newTrack = stream.getVideoTracks()[0] + if (newTrack) { + this.localStream!.addTrack(newTrack) + this.peerConnections.forEach((pc) => { + const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind) + if (sender) { + sender.replaceTrack(newTrack) + } else { + pc.addTrack(newTrack, this.localStream!) + } + }) + } + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + } + + async toggleAudio(enabled: boolean): Promise { + if (!this.localStream) return + let audioTrack = this.localStream.getAudioTracks()[0] + + if (audioTrack) { + audioTrack.enabled = enabled + } else if (enabled) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const newTrack = stream.getAudioTracks()[0] + if (newTrack) { + this.localStream!.addTrack(newTrack) + this.peerConnections.forEach((pc) => { + const sender = pc.getSenders().find((s) => s.track?.kind === newTrack.kind) + if (sender) { + sender.replaceTrack(newTrack) + } else { + pc.addTrack(newTrack, this.localStream!) + } + }) + } + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + } + + getLocalStream(): MediaStream | null { + return this.localStream + } + + private async restartIce(peerConnection: RTCPeerConnection, userId: string) { + try { + const offer = await peerConnection.createOffer({ iceRestart: true }) + await peerConnection.setLocalDescription(offer) + + if (this.signalRService) { + await this.signalRService.sendOffer(this.sessionId, userId, offer) + } else { + toast.push(, { + placement: 'top-end', + }) + } + } catch { + toast.push(, { + placement: 'top-end', + }) + } + } + + closePeerConnection(userId: string): void { + const peerConnection = this.peerConnections.get(userId) + if (peerConnection) { + peerConnection.getSenders().forEach((sender) => sender.track?.stop()) + peerConnection.close() + this.peerConnections.delete(userId) + this.retryCounts.delete(userId) + } + } + + getPeerConnection(userId: string): RTCPeerConnection | undefined { + return this.peerConnections.get(userId) + } + + closeAllConnections(): void { + this.peerConnections.forEach((pc) => { + pc.getSenders().forEach((sender) => sender.track?.stop()) + pc.close() + }) + this.peerConnections.clear() + + if (this.localStream) { + this.localStream.getTracks().forEach((track) => track.stop()) + this.localStream = null + } + } + + addStreamToPeers(stream: MediaStream) { + this.peerConnections.forEach((pc) => { + stream.getTracks().forEach((track) => { + const alreadyHas = pc.getSenders().some((s) => s.track?.id === track.id) + if (!alreadyHas) { + pc.addTrack(track, stream) + track.onended = () => { + this.removeTrackFromPeers(track) + } + } + }) + }) + } + + removeTrackFromPeers(track: MediaStreamTrack) { + this.peerConnections.forEach((pc) => { + pc.getSenders().forEach((sender) => { + if (sender.track === track) { + try { + pc.removeTrack(sender) + } catch { + toast.push(, { + placement: 'top-end', + }) + } + if (sender.track?.readyState !== 'ended') { + sender.track?.stop() + } + } + }) + }) + } +} diff --git a/ui/src/utils/hooks/useClassroomLogic.ts b/ui/src/utils/hooks/useClassroomLogic.ts index 9c2139f..888810e 100644 --- a/ui/src/utils/hooks/useClassroomLogic.ts +++ b/ui/src/utils/hooks/useClassroomLogic.ts @@ -1,14 +1,14 @@ -import { ClassroomDto, Role, RoleState } from '@/proxy/classroom/models' +import { Role, RoleState, VideoroomDto } from '@/proxy/videoroom/models' import { useStoreActions, useStoreState } from '@/store/store' import { useState } from 'react' -export function useClassroomLogic() { +export function useVideoroomLogic() { const { user } = useStoreState((state) => state.auth) const { setUser } = useStoreActions((actions) => actions.auth.user) const [roleState, setRoleState] = useState('role-selection') - const [currentClass, setCurrentClass] = useState(null) - const [allClasses, setAllClasses] = useState([]) + const [currentClass, setCurrentClass] = useState(null) + const [allClasses, setAllClasses] = useState([]) const handleRoleSelect = (role: Role) => { setUser({ @@ -18,7 +18,7 @@ export function useClassroomLogic() { setRoleState('dashboard') } - const handleCreateClass = (classData: Partial) => { + const handleCreateClass = (classData: Partial) => { const newClass = { ...classData, id: crypto.randomUUID(), @@ -27,11 +27,11 @@ export function useClassroomLogic() { isActive: false, isScheduled: true, participantCount: 0, - } as ClassroomDto + } as VideoroomDto setAllClasses((prev) => [...prev, newClass]) } - const handleEditClass = (classId: string, classData: Partial) => { + const handleEditClass = (classId: string, classData: Partial) => { setAllClasses((prev) => prev.map((c) => (c.id === classId ? { ...c, ...classData } : c))) } diff --git a/ui/src/views/admin/videoroom/ChatPanel.tsx b/ui/src/views/admin/videoroom/ChatPanel.tsx new file mode 100644 index 0000000..656e4e8 --- /dev/null +++ b/ui/src/views/admin/videoroom/ChatPanel.tsx @@ -0,0 +1,196 @@ +import { + VideoroomChatDto, + VideoroomParticipantDto, + VideoroomSettingsDto, + MessageType, +} from '@/proxy/videoroom/models' +import React, { useRef, useEffect } from 'react' +import { FaTimes, FaUsers, FaUser, FaBullhorn, FaPaperPlane } from 'react-icons/fa' + +interface ChatPanelProps { + user: { id: string; name: string; role: string } + participants: VideoroomParticipantDto[] + chatMessages: VideoroomChatDto[] + newMessage: string + setNewMessage: (msg: string) => void + messageMode: MessageType + setMessageMode: (mode: MessageType) => void + selectedRecipient: { id: string; name: string } | null + setSelectedRecipient: (recipient: { id: string; name: string } | null) => void + onSendMessage: (e: React.FormEvent) => void + onClose: () => void + formatTime: (timestamp: string) => string + classSettings: VideoroomSettingsDto +} + +const ChatPanel: React.FC = ({ + user, + participants, + chatMessages, + newMessage, + setNewMessage, + messageMode, + setMessageMode, + selectedRecipient, + setSelectedRecipient, + onSendMessage, + onClose, + formatTime, + classSettings, +}) => { + const messagesEndRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [chatMessages]) + + const availableRecipients = participants.filter((p) => p.id !== user.id) + + return ( +
+ {/* Header */} +
+

Sohbet

+ +
+ + {/* Mesaj Modu */} +
+
+ + + {classSettings.allowPrivateMessages && ( + + )} + {user.role === 'teacher' && ( + + )} +
+ + {messageMode === 'private' && ( + + )} +
+ + {/* Mesaj Listesi */} +
+ {chatMessages.length === 0 ? ( +
Henüz mesaj yok.
+ ) : ( + chatMessages.map((m) => ( +
+
+ {m.senderId !== user.id && ( +
+ {m.senderName} + {m.isTeacher && ' (Öğretmen)'} +
+ )} +
{m.message}
+
{formatTime(m.timestamp)}
+
+
+ )) + )} +
+
+ + {/* Mesaj Gönderme */} +
+
+ setNewMessage(e.target.value)} + placeholder="Mesaj yaz..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> + +
+
+
+ ) +} + +export default ChatPanel diff --git a/ui/src/views/admin/videoroom/Dashboard.tsx b/ui/src/views/admin/videoroom/Dashboard.tsx new file mode 100644 index 0000000..3b58369 --- /dev/null +++ b/ui/src/views/admin/videoroom/Dashboard.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { FaGraduationCap, FaUserCheck, FaEye } from 'react-icons/fa' +import { useStoreActions, useStoreState } from '@/store/store' +import { useNavigate } from 'react-router-dom' +import { ROUTES_ENUM } from '@/routes/route.constant' +import { Helmet } from 'react-helmet' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { Role } from '@/proxy/videoroom/models' + +const Dashboard: React.FC = () => { + const navigate = useNavigate() + const { translate } = useLocalization() + const { user } = useStoreState((state) => state.auth) + const { setUser } = useStoreActions((actions) => actions.auth.user) + + const handleRoleSelect = (role: Role) => { + setUser({ + ...user, + role, + }) + + navigate(ROUTES_ENUM.protected.admin.videoroom.roomList, { replace: true }) + } + + return ( + <> + +
+ +

Lütfen rolünüzü seçin

+ +
+ handleRoleSelect('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" + > + +

Öğretmen

+

+ Ders başlatın, öğrencilerle iletişim kurun ve katılım raporlarını görün +

+
+ + handleRoleSelect('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" + > + +

Öğrenci

+

+ Aktif derslere katılın, öğretmeniniz ve diğer öğrencilerle etkileşim kurun +

+
+ + handleRoleSelect('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" + > + +

Gözlemci

+

+ Sınıfı gözlemleyin, eğitim sürecini takip edin (ses/video paylaşımı yok) +

+
+
+
+
+ + ) +} + +export default Dashboard diff --git a/ui/src/views/admin/videoroom/DocumentsPanel.tsx b/ui/src/views/admin/videoroom/DocumentsPanel.tsx new file mode 100644 index 0000000..840fd44 --- /dev/null +++ b/ui/src/views/admin/videoroom/DocumentsPanel.tsx @@ -0,0 +1,153 @@ +import { VideoroomDocumentDto } from '@/proxy/videoroom/models'; +import React, { useRef, useState } from 'react' +import { FaTimes, FaFile, FaEye, FaDownload, FaTrash } from 'react-icons/fa' + +interface DocumentsPanelProps { + user: { role: string; name: string } + documents: VideoroomDocumentDto[] + onUpload: (file: File) => void + onDelete: (id: string) => void + onView: (doc: VideoroomDocumentDto) => void + onClose: () => void + formatFileSize: (bytes: number) => string + getFileIcon: (type: string) => JSX.Element +} + +const DocumentsPanel: React.FC = ({ + user, + documents, + onUpload, + onDelete, + onView, + onClose, + formatFileSize, + getFileIcon, +}) => { + const fileInputRef = useRef(null) + const [dragOver, setDragOver] = useState(false) + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + if (user.role !== 'teacher') return + const files = Array.from(e.dataTransfer.files) + files.forEach((file) => onUpload(file)) + } + + const handleFileSelect = (e: React.ChangeEvent) => { + if (user.role !== 'teacher') return + const files = Array.from(e.target.files || []) + files.forEach((file) => onUpload(file)) + if (fileInputRef.current) fileInputRef.current.value = '' + } + + return ( +
+ {/* Header */} +
+
+

Sınıf Dokümanları

+ +
+
+ + {/* Content */} +
+ {/* Upload Area (Teacher Only) */} + {user.role === 'teacher' && ( +
{ + e.preventDefault() + setDragOver(true) + }} + onDragLeave={() => setDragOver(false)} + > + +

Doküman Yükle

+

Dosyaları buraya sürükleyin veya seçin

+ + +
+ )} + + {/* Documents List */} + {documents.length === 0 ? ( +
+ +

Henüz doküman yüklenmemiş.

+
+ ) : ( +
+ {documents.map((doc) => ( +
+
+
{getFileIcon(doc.type)}
+
+

{doc.name}

+

+ {formatFileSize(doc.size)} •{' '} + {new Date(doc.uploadedAt).toLocaleDateString('tr-TR')} +

+

{doc.uploadedBy}

+
+
+ +
+ + + + + + + {user.role === 'teacher' && ( + + )} +
+
+ ))} +
+ )} +
+
+ ) +} + +export default DocumentsPanel diff --git a/ui/src/views/admin/videoroom/KickParticipantModal.tsx b/ui/src/views/admin/videoroom/KickParticipantModal.tsx new file mode 100644 index 0000000..6c1d990 --- /dev/null +++ b/ui/src/views/admin/videoroom/KickParticipantModal.tsx @@ -0,0 +1,82 @@ +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, participantName: string) => void +} + +export const KickParticipantModal: React.FC = ({ + participant, + isOpen, + onClose, + onConfirm, +}) => { + if (!isOpen || !participant) return null + + const handleConfirm = () => { + onConfirm(participant.id, participant.name) + onClose() + } + + return ( +
+ +
+
+
+ +
+
+

Katılımcıyı Çıkar

+

Bu işlem geri alınamaz

+
+
+ +
+

+ "{participant.name}" adlı katılımcıyı sınıftan çıkarmak + istediğinizden emin misiniz? +

+
+
+ +
+

Dikkat:

+
    +
  • Katılımcı anında sınıftan çıkarılacak
  • +
  • Tekrar katılım için davet gerekebilir
  • +
  • Katılım süresi kaydedilecek
  • +
+
+
+
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/ui/src/views/admin/videoroom/LayoutPanel.tsx b/ui/src/views/admin/videoroom/LayoutPanel.tsx new file mode 100644 index 0000000..18da57f --- /dev/null +++ b/ui/src/views/admin/videoroom/LayoutPanel.tsx @@ -0,0 +1,112 @@ +import { VideoroomLayoutDto } from '@/proxy/videoroom/models' +import React from 'react' +import { FaTimes, FaTh, FaExpand, FaDesktop, FaUsers } from 'react-icons/fa' + +interface LayoutPanelProps { + layouts: VideoroomLayoutDto[] + currentLayout: VideoroomLayoutDto + onChangeLayout: (layout: VideoroomLayoutDto) => void + onClose: () => void +} + +const getLayoutIcon = (type: string) => { + switch (type) { + case 'grid': + return + case 'speaker': + return + case 'presentation': + return + case 'sidebar': + return + case 'teacher-focus': + return + default: + return + } +} + +const LayoutPanel: React.FC = ({ + layouts, + currentLayout, + onChangeLayout, + onClose, +}) => { + return ( +
+
+
+

Video Layout Seçin

+ +
+
+ +
+
+ {layouts.map((layout) => ( + + ))} +
+
+
+ ) +} + +export default LayoutPanel diff --git a/ui/src/views/admin/videoroom/ParticipantsPanel.tsx b/ui/src/views/admin/videoroom/ParticipantsPanel.tsx new file mode 100644 index 0000000..6fa751a --- /dev/null +++ b/ui/src/views/admin/videoroom/ParticipantsPanel.tsx @@ -0,0 +1,226 @@ +import { VideoroomAttendanceDto, VideoroomParticipantDto } from '@/proxy/videoroom/models' +import React, { useState } from 'react' +import { + FaTimes, + FaUsers, + FaClipboardList, + FaHandPaper, + FaMicrophone, + FaMicrophoneSlash, + FaVideoSlash, + FaUserTimes, +} from 'react-icons/fa' + +interface ParticipantsPanelProps { + user: { id: string; name: string; role: string } + participants: VideoroomParticipantDto[] + attendanceRecords: VideoroomAttendanceDto[] + onMuteParticipant: (participantId: string, isMuted: boolean, isTeacher: boolean) => void + onKickParticipant: (participantId: string, participantName: string) => void + onApproveHandRaise: (participantId: string) => void + onDismissHandRaise: (participantId: string) => void + onClose: () => void + formatTime: (timestamp: string) => string + formatDuration: (minutes: number) => string +} + +const ParticipantsPanel: React.FC = ({ + user, + participants, + attendanceRecords, + onMuteParticipant, + onKickParticipant, + onClose, + onApproveHandRaise, + onDismissHandRaise, + formatTime, + formatDuration, +}) => { + const [activeTab, setActiveTab] = useState<'participants' | 'attendance'>('participants') + + // El kaldıranları bul + const handRaised = participants.filter((p) => p.isHandRaised) + + return ( +
+ {/* Header */} +
+
+

+ Katılımcılar ({participants.length + 1}) +

+ +
+ + {/* El kaldıranlar göstergesi */} + {user.role === 'teacher' && handRaised.length > 0 && ( +
+ + + {handRaised.length} kişi el kaldırdı: + + + {handRaised.map((p) => p.name).join(', ')} + +
+ )} + + {/* Tab Navigation */} +
+ + + {user.role === 'teacher' && ( + + )} +
+
+ + {/* Participants Tab */} + {activeTab === 'participants' && ( +
+
+ {/* Current User */} +
+
+
+ {user.name.charAt(0)} +
+ {user.name} (Siz) +
+ {user.role === 'teacher' && ( + + Öğretmen + + )} +
+ + {/* Other Participants */} + {participants.map((participant) => ( +
+
+
+ {participant.name.charAt(0)} +
+ {participant.name} + + {/* Hand Raise Indicator & Teacher Control */} + {participant.isHandRaised && + (user.role === 'teacher' && !participant.isTeacher ? ( + + ) : ( + + ))} +
+ +
+ {/* Hand Raise Controls kaldırıldı, kontrol yukarıya taşındı */} + + {/* Mute / Unmute Button */} + {user.role === 'teacher' && !participant.isTeacher && ( + + )} + + {/* Video muted indicator */} + {participant.isVideoMuted && } + + {participant.isTeacher && ( + + Öğretmen + + )} + + {/* Kick Button (Teacher Only) */} + {user.role === 'teacher' && !participant.isTeacher && ( + + )} +
+
+ ))} +
+
+ )} + + {/* Attendance Tab */} + {activeTab === 'attendance' && ( +
+ {attendanceRecords.length === 0 ? ( +
+ +

Henüz katılım kaydı bulunmamaktadır.

+
+ ) : ( +
+ {attendanceRecords.map((record) => ( +
+
+

{record.studentName}

+ + {formatDuration(record.totalDurationMinutes)} + +
+
+
Giriş: {formatTime(record.joinTime)}
+
+ Çıkış: {record.leaveTime ? formatTime(record.leaveTime) : 'Devam ediyor'} +
+
+
+ ))} +
+ )} +
+ )} +
+ ) +} + +export default ParticipantsPanel diff --git a/ui/src/views/admin/videoroom/RoomDetail.tsx b/ui/src/views/admin/videoroom/RoomDetail.tsx new file mode 100644 index 0000000..3633375 --- /dev/null +++ b/ui/src/views/admin/videoroom/RoomDetail.tsx @@ -0,0 +1,1320 @@ +import React, { useState, useEffect, useRef } from 'react' +import { motion } from 'framer-motion' +import { + FaComments, + FaExpand, + FaHandPaper, + FaVolumeMute, + FaVolumeUp, + FaFile, + FaDesktop, + FaMicrophone, + FaMicrophoneSlash, + FaVideo, + FaVideoSlash, + FaPhone, + FaTimes, + FaCompress, + FaUserFriends, + FaLayerGroup, + FaFilePdf, + FaFileWord, + FaFileImage, + FaFileAlt, + FaBars, +} from 'react-icons/fa' +import { SignalRService } from '@/services/videoroom/signalr' +import { WebRTCService } from '@/services/videoroom/webrtc' +import { useStoreState } from '@/store/store' +import { KickParticipantModal } from '@/views/admin/videoroom/KickParticipantModal' +import { useParams } from 'react-router-dom' +import { + getVideoroomAttandances, + getVideoroomById, + getVideoroomChats, +} from '@/services/videoroom.service' +import { showDbDateAsIs } from '@/utils/dateUtils' +import { useNavigate } from 'react-router-dom' +import { endVideoroom } from '@/services/videoroom.service' +import { ROUTES_ENUM } from '@/routes/route.constant' +import { Helmet } from 'react-helmet' +import { useLocalization } from '@/utils/hooks/useLocalization' +import ChatPanel from '@/views/admin/videoroom/ChatPanel' +import ParticipantsPanel from '@/views/admin/videoroom/ParticipantsPanel' +import DocumentsPanel from '@/views/admin/videoroom/DocumentsPanel' +import LayoutPanel from '@/views/admin/videoroom/LayoutPanel' +import { ScreenSharePanel } from '@/views/admin/videoroom/ScreenSharePanel' +import { RoomParticipant } from '@/views/admin/videoroom/RoomParticipant' +import toast from '@/components/ui/toast/toast' +import Notification from '@/components/ui/Notification' +import { + VideoroomDocumentDto, + VideoroomAttendanceDto, + VideoroomChatDto, + VideoroomDto, + VideoroomParticipantDto, + VideoroomSettingsDto, + MessageType, + VideoroomLayoutDto, +} from '@/proxy/videoroom/models' + +type SidePanelType = + | 'chat' + | 'participants' + | 'documents' + | 'handraises' + | 'layout' + | 'settings' + | null + +const newClassSession: VideoroomDto = { + id: '', + name: '', + teacherId: '', + teacherName: '', + scheduledStartTime: '', + scheduledEndTime: '', + actualStartTime: '', + actualEndTime: '', + participantCount: 0, + settingsDto: undefined, +} + +const RoomDetail: React.FC = () => { + // El kaldırma onayla/iptal fonksiyonları en başta tanımlanmalı + // (props olarak kullanılmadan önce) + const handleApproveHandRaise = async (participantId: string) => { + if (signalRServiceRef.current && user.role === 'teacher') { + await signalRServiceRef.current.approveHandRaise(classSession.id, participantId) + } + } + + const handleDismissHandRaise = async (participantId: string) => { + if (signalRServiceRef.current && user.role === 'teacher') { + await signalRServiceRef.current.dismissHandRaise(classSession.id, participantId) + } + } + + const params = useParams() + const navigate = useNavigate() + const { user } = useStoreState((state) => state.auth) + const { translate } = useLocalization() + + const [classSession, setClassSession] = useState(newClassSession) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [participants, setParticipants] = useState([]) + const [localStream, setLocalStream] = useState(null) + const [isAudioEnabled, setIsAudioEnabled] = useState(true) + const [isVideoEnabled, setIsVideoEnabled] = useState(true) + const [attendanceRecords, setAttendanceRecords] = useState([]) + const [chatMessages, setChatMessages] = useState([]) + const [currentLayout, setCurrentLayout] = useState(null) + + const [focusedParticipant, setFocusedParticipant] = useState() + const [hasRaisedHand, setHasRaisedHand] = useState(false) + const [isAllMuted, setIsAllMuted] = useState(false) + const [kickingParticipant, setKickingParticipant] = useState<{ id: string; name: string } | null>( + null, + ) + const [documents, setDocuments] = useState([]) + const [isScreenSharing, setIsScreenSharing] = useState(false) + const [screenStream, setScreenStream] = useState() + const [screenSharer, setScreenSharer] = useState() + const [isFullscreen, setIsFullscreen] = useState(false) + const [activeSidePanel, setActiveSidePanel] = useState(null) + const [newMessage, setNewMessage] = useState('') + const [messageMode, setMessageMode] = useState('public') + const [selectedRecipient, setSelectedRecipient] = useState<{ id: string; name: string } | null>( + null, + ) + const raisedHandsCount = participants.filter((p) => p.isHandRaised).length + const messagesEndRef = useRef(null) + const [classSettings, setClassSettings] = useState({ + allowHandRaise: true, + defaultMicrophoneState: 'muted', + defaultCameraState: 'on', + defaultLayout: 'grid', + allowStudentScreenShare: false, + allowStudentChat: true, + allowPrivateMessages: true, + autoMuteNewParticipants: true, + }) + + const signalRServiceRef = useRef() + const webRTCServiceRef = useRef() + const [teacherDisconnected, setTeacherDisconnected] = useState(false) + + const layouts: VideoroomLayoutDto[] = [ + { + id: 'grid', + name: 'Izgara Görünümü', + type: 'grid', + description: 'Tüm katılımcılar eşit boyutta görünür', + }, + { + id: 'sidebar', + name: 'Sunum Modu', + type: 'sidebar', + description: 'Ana konuşmacı büyük, diğerleri yan panelde', + }, + { + id: 'teacher-focus', + name: 'Öğretmen Odaklı', + type: 'teacher-focus', + description: 'Öğretmen tam ekranda görünür, öğrenciler küçük panelde', + }, + ] + + const fetchClassAttendances = async () => { + if (!params?.id) return + const attResult = await getVideoroomAttandances(params.id) + if (attResult && attResult.data) { + setAttendanceRecords(attResult.data) + } + } + + const fetchClassChats = async () => { + if (!params?.id) return + const chatResult = await getVideoroomChats(params.id) + if (chatResult && chatResult.data) { + setChatMessages(chatResult.data || []) + } + } + + const fetchClassDetails = async () => { + const classEntity = await getVideoroomById(params?.id ?? '') + if (classEntity) { + classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime) + setClassSession(classEntity.data) + } + } + + useEffect(() => { + fetchClassDetails() + fetchClassChats() + fetchClassAttendances() + }, []) + + useEffect(() => { + if (classSession.id) { + initializeServices() + return () => { + cleanup() + } + } + }, [classSession.id]) + + // Apply class settings + useEffect(() => { + if (classSession?.settingsDto) { + setClassSettings(classSession.settingsDto) + const selectedLayout = + layouts.find((l) => l.id === classSession.settingsDto!.defaultLayout) || layouts[0] + setCurrentLayout(selectedLayout) + + // Apply default audio/video states for new participants + if (user.role === 'student') { + setIsAudioEnabled(classSession.settingsDto.defaultMicrophoneState === 'unmuted') + setIsVideoEnabled(classSession.settingsDto.defaultCameraState === 'on') + } + } + }, [classSession?.settingsDto, user.role]) + + useEffect(() => { + scrollToBottom() + }, [chatMessages]) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + const initializeServices = async () => { + try { + // Initialize SignalR + signalRServiceRef.current = new SignalRService() + await signalRServiceRef.current.start() + + const micEnabled = classSession.settingsDto?.defaultMicrophoneState === 'unmuted' + const camEnabled = classSession.settingsDto?.defaultCameraState === 'on' + + // WebRTC başlat + webRTCServiceRef.current = new WebRTCService() + webRTCServiceRef.current.setSignalRService(signalRServiceRef.current, classSession.id) + + const stream = await webRTCServiceRef.current.initializeLocalStream(micEnabled, camEnabled) + if (stream) { + setLocalStream(stream) + } + setIsAudioEnabled(micEnabled) + setIsVideoEnabled(camEnabled) + + // Setup WebRTC remote stream handler + webRTCServiceRef.current.onRemoteStreamReceived((userId, stream) => { + setParticipants((prev) => prev.map((p) => (p.id === userId ? { ...p, stream } : p))) + }) + + webRTCServiceRef.current.setIceCandidateHandler(async (toUserId, candidate) => { + if (signalRServiceRef.current) { + await signalRServiceRef.current.sendIceCandidate(classSession.id, toUserId, candidate) + } + }) + + signalRServiceRef.current.setOfferReceivedHandler(async (fromUserId, offer) => { + if (!webRTCServiceRef.current?.getPeerConnection(fromUserId)) { + await webRTCServiceRef.current?.createPeerConnection(fromUserId) + } + const answer = await webRTCServiceRef.current?.createAnswer(fromUserId, offer) + if (answer) { + await signalRServiceRef.current?.sendAnswer(classSession.id, fromUserId, answer) + } + }) + + signalRServiceRef.current.setAnswerReceivedHandler(async (fromUserId, answer) => { + await webRTCServiceRef.current?.handleAnswer(fromUserId, answer) + }) + + signalRServiceRef.current.setIceCandidateReceivedHandler(async (fromUserId, candidate) => { + await webRTCServiceRef.current?.addIceCandidate(fromUserId, candidate) + }) + + // 🔑 Yeni katılan birini gördüğünde + signalRServiceRef.current.setParticipantJoinHandler( + async (remoteUserId: string, name: string, isTeacher: boolean, isActive: boolean) => { + if (remoteUserId === user.id) return + if (!isActive) return + + toast.push(, { + placement: 'top-end', + }) + + // State’e ekle + setParticipants((prev) => { + if (prev.find((p) => p.id === remoteUserId)) return prev + return [ + ...prev, + { + id: remoteUserId, + name, + sessionId: classSession.id, + isTeacher, + isAudioMuted: classSettings.defaultMicrophoneState === 'muted', + isVideoMuted: classSettings.defaultCameraState === 'off', + isActive: true, + }, + ] + }) + + // PeerConnection hazırla + if (!webRTCServiceRef.current?.getPeerConnection(remoteUserId)) { + await webRTCServiceRef.current?.createPeerConnection(remoteUserId) + } + + // 🔑 Çakışmayı önle: sadece id’si küçük olan offer başlatır + if (user.id < remoteUserId) { + const offer = await webRTCServiceRef.current!.createOffer(remoteUserId) + await signalRServiceRef.current?.sendOffer(classSession.id, remoteUserId, offer) + } + }, + ) + + // 🔑 Odaya girdiğinde var olan katılımcılar + signalRServiceRef.current.setExistingParticipantsHandler( + async ( + existing: { userId: string; userName: string; isTeacher: boolean; isActive: boolean }[], + ) => { + for (const participant of existing) { + if (!participant.isActive) continue + if (participant.userId === user.id) continue + + // State’e ekle + setParticipants((prev) => { + if (prev.find((p) => p.id === participant.userId)) return prev + return [ + ...prev, + { + id: participant.userId, + name: participant.userName, + sessionId: classSession.id, + isTeacher: participant.isTeacher, + isAudioMuted: classSettings.defaultMicrophoneState === 'muted', + isVideoMuted: classSettings.defaultCameraState === 'off', + isActive: true, + }, + ] + }) + + // PeerConnection hazırla + if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) { + await webRTCServiceRef.current?.createPeerConnection(participant.userId) + } + + // 🔑 Çakışmayı önle + if (user.id < participant.userId) { + const offer = await webRTCServiceRef.current!.createOffer(participant.userId) + await signalRServiceRef.current?.sendOffer(classSession.id, participant.userId, offer) + } + } + }, + ) + + signalRServiceRef.current.setForceCleanupHandler(() => { + webRTCServiceRef.current?.closeAllConnections() + localStream?.getTracks().forEach((t) => t.stop()) + }) + + signalRServiceRef.current.setParticipantLeaveHandler(({ userId, sessionId, userName }) => { + if (userId !== user.id) { + toast.push( + , + { placement: 'top-end' }, + ) + } + + // peer connection’ı kapat + webRTCServiceRef.current?.closePeerConnection(userId) + + // katılımcıyı state’den tamamen sil + setParticipants((prev) => + prev.filter((p) => !(p.id === userId && p.sessionId === sessionId)), + ) + + if (participants.find((p) => p.id === userId)?.isTeacher) { + setTeacherDisconnected(true) + } + }) + + signalRServiceRef.current.setAttendanceUpdatedHandler((record) => { + setAttendanceRecords((prev) => { + const existing = prev.find((r) => r.id === record.id) + if (existing) { + return prev.map((r) => (r.id === record.id ? record : r)) + } + return [...prev, record] + }) + }) + + signalRServiceRef.current.setChatMessageReceivedHandler((message) => { + setChatMessages((prev) => [...prev, message]) + }) + + signalRServiceRef.current.setParticipantMutedHandler(async (userId, isMuted) => { + setParticipants((prev) => + prev.map((p) => (p.id === userId ? { ...p, isAudioMuted: isMuted } : p)), + ) + + // Eğer mute edilen kişi currentUser ise → kendi mikrofonunu kapat + if (userId === user.id) { + await webRTCServiceRef.current?.toggleAudio(!isMuted) + setIsAudioEnabled(!isMuted) + } + }) + + // Hand raise events + signalRServiceRef.current.setHandRaiseReceivedHandler((studentId) => { + setParticipants((prev) => + prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: true } : p)), + ) + }) + + signalRServiceRef.current.setHandRaiseDismissedHandler((studentId) => { + setParticipants((prev) => + prev.map((p) => (p.id === studentId ? { ...p, isHandRaised: false } : p)), + ) + + // 👇 kendi state’ini de sıfırla + if (studentId === user.id) { + setHasRaisedHand(false) + } + }) + + // Join the class + await signalRServiceRef.current.joinClass( + classSession.id, + user.id, + user.name, + user.role === 'teacher', + true, + ) + } catch (error) { + toast.push( + , + { placement: 'top-end' }, + ) + } + } + + const cleanup = async () => { + if (signalRServiceRef.current) { + // ✅ önce LeaveClass + await signalRServiceRef.current.leaveClass(classSession.id) + // sonra disconnect + await signalRServiceRef.current.disconnect() + } + + webRTCServiceRef.current?.closeAllConnections() + } + + const handleToggleAudio = () => { + setIsAudioEnabled(!isAudioEnabled) + webRTCServiceRef.current?.toggleAudio(!isAudioEnabled) + } + + const handleToggleVideo = () => { + setIsVideoEnabled(!isVideoEnabled) + webRTCServiceRef.current?.toggleVideo(!isVideoEnabled) + } + + const handleLeaveCall = async () => { + try { + // Eğer teacher ise sınıfı kapat + if (user.role === 'teacher' && user.id === classSession.teacherId) { + await endVideoroom(classSession.id) + } + + // Bağlantıları kapat + await cleanup() + + // Başka sayfaya yönlendir + navigate(ROUTES_ENUM.protected.admin.videoroom.roomList) + } catch (err) { + toast.push(, { + placement: 'top-end', + }) + navigate(ROUTES_ENUM.protected.admin.videoroom.roomList) + } + } + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault() + if (newMessage.trim() && signalRServiceRef.current) { + if (messageMode === 'private' && selectedRecipient) { + try { + await signalRServiceRef.current.sendPrivateMessage( + classSession.id, + user.id, + user.name, + newMessage.trim(), + selectedRecipient.id, + selectedRecipient.name, + user.role === 'teacher', + ) + } catch (error) { + toast.push(, { + placement: 'top-end', + }) + } + } else if (messageMode === 'announcement' && user.role === 'teacher') { + try { + await signalRServiceRef.current.sendAnnouncement( + classSession.id, + user.id, + user.name, + newMessage.trim(), + user.role === 'teacher', + ) + } catch (error) { + toast.push(, { + placement: 'top-end', + }) + } + } else { + try { + await signalRServiceRef.current.sendChatMessage( + classSession.id, + user.id, + user.name, + newMessage.trim(), + user.role === 'teacher', + ) + } catch (error) { + toast.push(, { + placement: 'top-end', + }) + } + } + setNewMessage('') + } + } + + const handleMuteParticipant = async ( + participantId: string, + isMuted: boolean, + isTeacher: boolean, + ) => { + if (signalRServiceRef.current && user.role === 'teacher') { + try { + await signalRServiceRef.current.muteParticipant( + classSession.id, + participantId, + isMuted, + isTeacher, + ) + } catch (err) { + toast.push(, { + placement: 'top-end', + }) + } + } + } + + const handleMuteAll = async () => { + if (signalRServiceRef.current && user.role === 'teacher') { + const newMuteState = !isAllMuted + setIsAllMuted(newMuteState) + + // Mute all participants except teacher + for (const participant of participants) { + if (!participant.isTeacher) { + await signalRServiceRef.current.muteParticipant( + classSession.id, + participant.id, + newMuteState, + user.role === 'teacher', + ) + } + } + } + } + + const handleRaiseHand = async () => { + if ( + signalRServiceRef.current && + user.role === 'student' && + !hasRaisedHand && + classSettings.allowHandRaise + ) { + await signalRServiceRef.current.raiseHand(classSession.id, user.id, user.name) + setHasRaisedHand(true) + } + } + + const handleKickParticipant = async (participantId: string, participantName: string) => { + if (signalRServiceRef.current && user.role === 'teacher') { + try { + await signalRServiceRef.current.kickParticipant( + classSession.id, + participantId, + participantName, + ) + setAttendanceRecords((prev) => + prev.map((r) => { + if (r.studentId === participantId && !r.leaveTime) { + const leaveTime = new Date().toISOString() + const join = new Date(r.joinTime) + const leave = new Date(leaveTime) + const totalDurationMinutes = Math.max( + 1, + Math.round((leave.getTime() - join.getTime()) / 60000), + ) + return { ...r, leaveTime, totalDurationMinutes } + } + return r + }), + ) + } catch (error) { + toast.push(, { + placement: 'top-end', + }) + } + } + } + + const handleUploadDocument = async (file: File) => { + // In a real app, this would upload to a server + const newDoc: VideoroomDocumentDto = { + id: crypto.randomUUID(), + name: file.name, + url: URL.createObjectURL(file), + type: file.type, + size: file.size, + uploadedAt: new Date().toISOString(), + uploadedBy: user.name, + } + + setDocuments((prev) => [...prev, newDoc]) + } + + const handleDeleteDocument = (documentId: string) => { + setDocuments((prev) => prev.filter((d) => d.id !== documentId)) + } + + const handleViewDocument = (document: VideoroomDocumentDto) => { + window.open(document.url, '_blank') + } + + const handleStartScreenShare = async () => { + try { + // 1. sadece ekran videosu al + const screen = await navigator.mediaDevices.getDisplayMedia({ + video: true, + }) + + // 2. mikrofonu ayrı al + let mic: MediaStream | null = null + try { + mic = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (err) { + toast.push( + , + ) + } + + // 3. merge et + if (mic) { + mic.getAudioTracks().forEach((track) => screen.addTrack(track)) + } + + setScreenStream(screen) + setIsScreenSharing(true) + setScreenSharer(user.name) + webRTCServiceRef.current?.addStreamToPeers(screen) + + // Handle stream end + screen.getVideoTracks()[0].onended = () => { + handleStopScreenShare() + } + } catch (error) { + toast.push(, { + placement: 'top-end', + }) + } + } + + const handleStopScreenShare = () => { + if (screenStream) { + // PeerConnections’tan kaldır + screenStream.getTracks().forEach((track) => { + webRTCServiceRef.current?.removeTrackFromPeers(track) + track.stop() + }) + setScreenStream(undefined) + } + setIsScreenSharing(false) + setScreenSharer(undefined) + } + + const handleLayoutChange = (layout: VideoroomLayoutDto) => { + setCurrentLayout(layout) + if (layout.type === 'grid') { + setFocusedParticipant(undefined) + } + } + + const handleParticipantFocus = (participantId: string | undefined) => { + setFocusedParticipant(participantId) + } + + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen() + setIsFullscreen(true) + } else { + document.exitFullscreen() + setIsFullscreen(false) + } + } + + const toggleSidePanel = (panelType: SidePanelType) => { + setActiveSidePanel(activeSidePanel === panelType ? null : panelType) + } + + const handleSettingsChange = (newSettings: Partial) => { + setClassSettings((prev) => ({ ...prev, ...newSettings })) + } + + const formatTime = (timestamp: string) => { + return new Date(timestamp).toLocaleTimeString('tr-TR', { + hour: '2-digit', + minute: '2-digit', + }) + } + + const formatDuration = (minutes: number) => { + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + if (hours > 0) { + return `${hours}h ${mins}m` + } + return `${mins}m` + } + + const getFileIcon = (type: string) => { + if (type.includes('pdf')) return + if ( + type.includes('word') || + type.includes('doc') || + type.includes('presentation') || + type.includes('powerpoint') + ) + return + if (type.includes('image')) return + return + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // ...existing code... + // ...existing code... + // --- yan panel fonksiyonu --- + const renderSidePanel = () => { + if (!activeSidePanel) return null + + switch (activeSidePanel) { + case 'chat': + return ( + setActiveSidePanel(null)} + formatTime={formatTime} + classSettings={classSettings} + /> + ) + + case 'participants': + return ( + setActiveSidePanel(null)} + formatTime={formatTime} + formatDuration={formatDuration} + /> + ) + + // ...existing code... + case 'documents': + return ( + setActiveSidePanel(null)} + formatFileSize={formatFileSize} + getFileIcon={getFileIcon} + /> + ) + + case 'layout': + return ( + setActiveSidePanel(null)} + /> + ) + + default: + return null + } + } + + useEffect(() => { + window.addEventListener('beforeunload', handleLeaveCall) + window.addEventListener('pagehide', handleLeaveCall) + + return () => { + window.removeEventListener('beforeunload', handleLeaveCall) + window.removeEventListener('pagehide', handleLeaveCall) + } + }, [classSession.id]) + + return ( + <> + + + {teacherDisconnected && ( +
+ Öğretmenin bağlantısı koptu, tekrar bağlanmasını bekleyin... +
+ )} + +
+ + {/* Main Content Area */} +
+ {/* Left Content Area - Video and Screen Share */} +
+ {/* Video Container - Panel kapalıyken ortalanmış */} +
+ {/* Screen Share Panel */} + {(isScreenSharing || screenStream) && ( +
+ +
+ )} + {/* Video Grid */} +
+ p.isActive)} + localStream={user.role === 'observer' ? undefined : localStream} + currentUserId={user.id} + currentUserName={user.name} + isTeacher={user.role === 'teacher'} + isAudioEnabled={isAudioEnabled} + isVideoEnabled={isVideoEnabled} + onToggleAudio={user.role === 'observer' ? () => {} : handleToggleAudio} + onToggleVideo={user.role === 'observer' ? () => {} : handleToggleVideo} + onLeaveCall={handleLeaveCall} + onMuteParticipant={handleMuteParticipant} + layout={currentLayout ?? layouts[0]} + focusedParticipant={focusedParticipant} + onParticipantFocus={handleParticipantFocus} + hasSidePanel={!!activeSidePanel} + onKickParticipant={ + user.role === 'teacher' + ? (participantId) => { + const participant = participants.find((p) => p.id === participantId) + if (participant) { + setKickingParticipant({ id: participant.id, name: participant.name }) + } + } + : undefined + } + /> +
+
+
+ + {/* Side Panel */} + {activeSidePanel && ( +
+ {renderSidePanel()} +
+ )} +
+ + {/* Bottom Control Bar - Google Meet Style */} +
+ {/* Mobile Layout */} +
+ {/* Left Side - Main Controls */} +
+ {/* Audio Control */} + {user.role !== 'observer' && ( + + )} + + {/* Video Control */} + {user.role !== 'observer' && ( + + )} + + {/* Screen Share */} + {(user.role === 'teacher' || classSettings.allowStudentScreenShare) && ( + + )} + + {/* Hand Raise (Students) */} + {user.role === 'student' && classSettings.allowHandRaise && ( + + )} + + {/* Leave Call */} + +
+ + {/* Right Side - Panel Controls */} +
+ +
+ + {/* Hamburger Menu Modal */} + {mobileMenuOpen && ( + <> + {/* Overlay */} +
setMobileMenuOpen(false)} + /> + {/* Drawer */} + +
+ Menü + +
+
+ + + + + {user.role === 'teacher' && ( + + )} + +
+
+ + )} +
+ + {/* Desktop Layout */} +
+ {/* Left Side - Meeting Info */} +
+
+ {classSession?.name} +
+ + {new Date().toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })} + +
+
+ + {/* Center - Main Controls */} +
+ {/* Audio Control */} + {user.role !== 'observer' && ( + + )} + + {/* Video Control */} + {user.role !== 'observer' && ( + + )} + + {/* Screen Share */} + {(user.role === 'teacher' || classSettings.allowStudentScreenShare) && ( + + )} + + {/* Hand Raise (Students) */} + {user.role === 'student' && classSettings.allowHandRaise && ( + + )} + + {/* Leave Call */} + +
+ + {/* Right Side - Panel Controls */} +
+ {/* Fullscreen Toggle */} + + + {/* Chat */} + {((user.role !== 'observer' && classSettings.allowStudentChat) || + user.role === 'teacher') && ( + + )} + + {/* Participants */} + + + {/* Teacher Only Options */} + {user.role === 'teacher' && ( + <> + {/* Documents Button */} + + + {/* Mute All Button */} + + + )} + + {/* Layout Button */} + +
+
+
+ + {/* Kick Participant Modal */} + setKickingParticipant(null)} + onConfirm={handleKickParticipant} + /> + +
+ + ) +} + +export default RoomDetail diff --git a/ui/src/views/admin/videoroom/RoomList.tsx b/ui/src/views/admin/videoroom/RoomList.tsx new file mode 100644 index 0000000..dba89be --- /dev/null +++ b/ui/src/views/admin/videoroom/RoomList.tsx @@ -0,0 +1,918 @@ +import React, { useEffect, useState } from 'react' +import { showDbDateAsIs } from '@/utils/dateUtils' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { + FaPlus, + FaCalendarAlt, + FaClock, + FaUsers, + FaPlay, + FaEdit, + FaTrash, + FaEye, + FaHourglassEnd, + FaDoorOpen, + FaSearch, + FaFilter, +} from 'react-icons/fa' + +import { useStoreState } from '@/store/store' +import { ROUTES_ENUM } from '@/routes/route.constant' +import { Container } from '@/components/shared' +import { + createVideoroom, + deleteVideoroom, + getVideorooms, + startVideoroom, + updateVideoroom, +} from '@/services/videoroom.service' +import { Helmet } from 'react-helmet' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { VideoroomDto } from '@/proxy/videoroom/models' + +export interface RoomProps { + status: string + className: string + showButtons: boolean + title: string + classes: string + event?: () => void +} + +const RoomList: React.FC = () => { + const navigate = useNavigate() + const { user } = useStoreState((state) => state.auth) + const { translate } = useLocalization() + + const newClassEntity: VideoroomDto = { + id: crypto.randomUUID(), + name: '', + description: '', + subject: '', + teacherId: user.id, + teacherName: user.name, + scheduledStartTime: '', + scheduledEndTime: '', + duration: 60, + maxParticipants: 30, + participantCount: 0, + settingsDto: { + allowHandRaise: true, + allowStudentChat: true, + allowPrivateMessages: true, + allowStudentScreenShare: false, + defaultMicrophoneState: 'muted', + defaultCameraState: 'on', + defaultLayout: 'grid', + autoMuteNewParticipants: true, + }, + } + const [videoList, setVideoist] = useState([]) + const [videoroom, setVideoroom] = useState(newClassEntity) + + const [showCreateModal, setShowCreateModal] = useState(false) + const [showEditModal, setShowEditModal] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) + + // Filter/search state + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('') + + const getVideoroomList = async ( + skipCount = 0, + maxResultCount = 1000, + sorting = '', + search = '', + status = '', + ) => { + try { + const result = await getVideorooms({ + sorting, + skipCount, + maxResultCount, + search, + status, + }) + + const items = (result.data.items || []).map((item) => ({ + ...item, + scheduledStartTime: item.scheduledStartTime + ? showDbDateAsIs(item.scheduledStartTime) + : null, + actualStartTime: item.actualStartTime ? showDbDateAsIs(item.actualStartTime) : null, + })) as VideoroomDto[] + + setVideoist(items) + } catch (error) { + console.error('Error fetching videorooms:', error) + } + } + + useEffect(() => { + getVideoroomList(0, 1000, '', searchTerm, statusFilter) + }, [searchTerm, statusFilter]) + + const handleCreateClass = async (e: React.FormEvent) => { + e.preventDefault() + + try { + await createVideoroom(videoroom) + + getVideoroomList() + setShowCreateModal(false) + setVideoroom(newClassEntity) + } catch (error) { + console.error('Sınıf oluştururken hata oluştu:', error) + } + } + + const handleEditClass = async (e: React.FormEvent) => { + e.preventDefault() + if (!videoroom) return + + try { + await updateVideoroom(videoroom) + + getVideoroomList() + setShowEditModal(false) + setVideoroom(newClassEntity) + resetForm() + } catch (error) { + console.error('Sınıf oluştururken hata oluştu:', error) + } + } + + const openEditModal = (classSession: VideoroomDto) => { + setVideoroom(classSession) + setShowEditModal(true) + } + + const openDeleteModal = (classSession: VideoroomDto) => { + setVideoroom(classSession) + setShowDeleteModal(true) + } + + const handleDeleteClass = async () => { + if (!videoroom) return + + try { + await deleteVideoroom(videoroom.id!) + getVideoroomList() + setShowDeleteModal(false) + setVideoroom(newClassEntity) + } catch (error) { + console.error('Sınıf silinirken hata oluştu:', error) + } + } + + const resetForm = () => { + setVideoroom(newClassEntity) + } + + const handleStartClass = async (classSession: VideoroomDto) => { + await startVideoroom(classSession.id!) + getVideoroomList() + + handleJoinClass(classSession) + } + + const handleJoinClass = (classSession: VideoroomDto) => { + if (classSession.id) { + navigate(ROUTES_ENUM.protected.admin.videoroom.roomDetail.replace(':id', classSession.id)) + } + } + + const handlePlanningClass = (classSession: VideoroomDto) => { + if (classSession.id) { + navigate(ROUTES_ENUM.protected.admin.videoroom.planning.replace(':id', classSession.id)) + } + } + + const widgets = () => { + return { + totalCount: videoList.length, + activeCount: videoList.filter((c) => !c.actualStartTime && !c.actualEndTime).length, + openCount: videoList.filter( + (c) => c.actualStartTime && !c.actualEndTime, // && canJoinClass(c.actualStartTime), + ).length, + passiveCount: videoList.filter((c) => c.actualStartTime && c.actualEndTime).length, + } + } + + const getClassProps = (classSession: VideoroomDto): RoomProps => { + //Aktif -> boş boş + if (!classSession.actualStartTime && !classSession.actualEndTime) { + return { + status: 'Aktif', + className: 'bg-blue-100 text-blue-800', + showButtons: true, + title: + user.role === 'teacher' && classSession.teacherId === user.id ? 'Dersi Başlat' : 'Katıl', + classes: + 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', + event: () => { + user.role === 'teacher' && classSession.teacherId === user.id + ? handleStartClass(classSession) + : handleJoinClass(classSession) + }, + } + } + + //Katılıma Açık -> dolu boş + if ( + classSession.actualStartTime && + !classSession.actualEndTime + //&& canJoinClass(classSession.actualStartTime) + ) { + return { + status: 'Katılım Açık', + className: 'bg-yellow-100 text-yellow-800', + showButtons: true, + title: + user.role === 'teacher' && classSession.teacherId === user.id ? 'Sınıfa Git' : 'Katıl', + classes: + 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', + event: () => { + handleJoinClass(classSession) + }, + } + } + + //Pasif + return { + status: 'Pasif', + className: 'bg-gray-100 text-gray-800', + showButtons: false, + title: '', + classes: '', + event: () => {}, + } + } + + return ( + <> + + + {/* Stats Cards */} +
+ +
+
+ +
+
+

Toplam Sınıf

+

+ {widgets().totalCount}{' '} +

+
+
+
+ + {/* Aktif Sınıf */} + +
+
+ +
+
+

Aktif Sınıf

+

+ {widgets().activeCount} +

+
+
+
+ + {/* Katılıma Açık */} + +
+
+ +
+
+

Katılıma Açık

+

{widgets().openCount}

+
+
+
+ + {/* Pasif Sınıf */} + +
+
+ +
+
+

Pasif Sınıf

+

+ {widgets().passiveCount} +

+
+
+
+ + {/* Toplam Katılımcı */} + +
+
+ +
+
+

Toplam Katılımcı

+

+ {videoList.reduce((sum, c) => sum + c.participantCount, 0)} +

+
+
+
+
+ + {/* Filter Bar */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + +
+
+
+ + {/* Scheduled Classes */} +
+
+

Programlı Sınıflar

+ {user.role === 'teacher' && ( + + )} +
+
+ {videoList.length === 0 ? ( +
+ +

Henüz programlanmış sınıf bulunmamaktadır.

+
+ ) : ( +
+ {videoList.map((classSession, index) => { + const { status, className, showButtons, title, classes, event } = + getClassProps(classSession) + return ( + +
+
+

+ {classSession.name} +

+ + {status} + +
+ + {/* Sağ kısım: buton */} + {showButtons && ( +
+ {/* {user.role === 'teacher' && classSession.teacherId === user.id && ( */} + {user.role === 'teacher' && ( + <> + + + + + + + )} + + +
+ )} +
+ +
+

{classSession.subject}

+
+ +
+ + {classSession.description} + +
+ +
+
+
+ + + {showDbDateAsIs(classSession.scheduledStartTime)} + +
+ +
+ + {classSession.duration} dakika +
+ +
+ {classSession.scheduledEndTime && ( + <> + + + {showDbDateAsIs(classSession.scheduledEndTime!)} + + + )} +
+ +
+ + + {classSession.participantCount}/{classSession.maxParticipants} + +
+
+
+
+ ) + })} +
+ )} +
+
+ + {/* Class Modal (Create/Edit) */} + {(showCreateModal || (showEditModal && videoroom)) && ( +
+ +
+

+ {showCreateModal ? 'Yeni Sınıf Oluştur' : 'Sınıfı Düzenle'} +

+
+ +
+
+ + setVideoroom({ ...videoroom, name: 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" + placeholder="Örn: Matematik 101 - Diferansiyel Denklemler" + /> +
+ +
+ +