Video Rooms

This commit is contained in:
Sedat ÖZTÜRK 2026-05-08 08:34:29 +03:00
parent 6fa266f23e
commit bdc7f744aa
41 changed files with 6534 additions and 128 deletions

View file

@ -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<VideoroomDto> GetAsync(Guid id);
Task<PagedResultDto<VideoroomDto>> GetListAsync(VideoroomFilterInputDto input);
Task<VideoroomDto> CreateAsync(VideoroomDto input);
Task<VideoroomDto> UpdateAsync(Guid id, VideoroomDto input);
Task DeleteAsync(Guid id);
Task<VideoroomDto> StartClassAsync(Guid id);
Task EndClassAsync(Guid id);
Task<VideoroomDto> JoinClassAsync(Guid id);
Task LeaveClassAsync(Guid id);
Task<List<VideoroomAttendanceDto>> GetAttendanceAsync(Guid sessionId);
}

View file

@ -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; }
}

View file

@ -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<Guid>
{
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<VideoroomSettingsDto>(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<Guid>
{
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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -650,7 +650,7 @@ public class PublicAppService : PlatformAppService
var features = new List<HomeFeatureDto>
{
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" },

View file

@ -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<Videoroom, Guid> _classSessionRepository;
private readonly IRepository<VideoroomParticipant, Guid> _participantRepository;
private readonly IRepository<VideoroomAttandance, Guid> _attendanceRepository;
private readonly IRepository<VideoroomChat, Guid> _chatRepository;
public VideoroomAppService(
IRepository<Videoroom, Guid> classSessionRepository,
IRepository<VideoroomParticipant, Guid> participantRepository,
IRepository<VideoroomAttandance, Guid> attendanceRepository,
IRepository<VideoroomChat, Guid> chatRepository)
{
_classSessionRepository = classSessionRepository;
_participantRepository = participantRepository;
_attendanceRepository = attendanceRepository;
_chatRepository = chatRepository;
}
public async Task<VideoroomDto> GetAsync(Guid id)
{
var classSession = await _classSessionRepository.GetAsync(id);
return ObjectMapper.Map<Videoroom, VideoroomDto>(classSession);
}
public async Task<PagedResultDto<VideoroomDto>> 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<VideoroomDto>(
totalCount,
ObjectMapper.Map<List<Videoroom>, List<VideoroomDto>>(items)
);
}
public async Task<VideoroomDto> 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<Videoroom, VideoroomDto>(classSession);
}
public async Task<VideoroomDto> 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<Videoroom, VideoroomDto>(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<VideoroomDto> 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<Videoroom, VideoroomDto>(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<VideoroomDto> 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<VideoroomSettingsDto>(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<Videoroom, VideoroomDto>(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<List<VideoroomAttendanceDto>> GetAttendanceAsync(Guid sessionId)
{
var attendanceRecords = await _attendanceRepository.GetListAsync(
x => x.SessionId == sessionId
);
return ObjectMapper.Map<List<VideoroomAttandance>, List<VideoroomAttendanceDto>>(attendanceRecords);
}
public async Task<List<VideoroomParticipantDto>> GetParticipantAsync(Guid sessionId)
{
var participantRecords = await _participantRepository.GetListAsync(
x => x.SessionId == sessionId
);
return ObjectMapper.Map<List<VideoroomParticipant>, List<VideoroomParticipantDto>>(participantRecords);
}
public async Task<List<VideoroomChatDto>> GetChatAsync(Guid sessionId)
{
var chatRecords = await _chatRepository.GetListAsync(
x => x.SessionId == sessionId
);
return ObjectMapper.Map<List<VideoroomChat>, List<VideoroomChatDto>>(chatRecords);
}
}

View file

@ -0,0 +1,16 @@
using AutoMapper;
using Sozsoft.Platform.Entities;
namespace Sozsoft.Platform.VideoRooms;
public class VideoroomAutoMapperProfile : Profile
{
public VideoroomAutoMapperProfile()
{
CreateMap<Videoroom, VideoroomDto>();
CreateMap<VideoroomAttandance, VideoroomAttendanceDto>();
CreateMap<VideoroomParticipant, VideoroomParticipantDto>();
CreateMap<VideoroomChat, VideoroomChatDto>();
}
}

View file

@ -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",

View file

@ -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",

View file

@ -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"
}
]
}

View file

@ -80,5 +80,9 @@ public enum TableNameEnum
EventType,
Event,
EventPhoto,
EventComment
EventComment,
Videoroom,
VideoroomParticipant,
VideoroomAttandance,
VideoroomChat
}

View file

@ -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) },

View file

@ -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<Guid>, 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<VideoroomParticipant> Participants { get; set; } = new HashSet<VideoroomParticipant>();
public virtual ICollection<VideoroomAttandance> AttendanceRecords { get; set; } = new HashSet<VideoroomAttandance>();
public virtual ICollection<VideoroomChat> ChatMessages { get; set; } = new HashSet<VideoroomChat>();
}

View file

@ -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<Guid>, 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;
}
}
}

View file

@ -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<Guid>, 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;
}
}

View file

@ -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<Guid>, 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;
}
}

View file

@ -131,6 +131,13 @@ public class PlatformDbContext :
public DbSet<SocialLike> SocialLikes { get; set; }
#endregion
#region VideoCall
public DbSet<Videoroom> ClassSessions { get; set; }
public DbSet<VideoroomParticipant> Participants { get; set; }
public DbSet<VideoroomAttandance> AttendanceRecords { get; set; }
public DbSet<VideoroomChat> ChatMessages { get; set; }
#endregion
public PlatformDbContext(DbContextOptions<PlatformDbContext> options)
: base(options)
{
@ -1267,5 +1274,77 @@ public class PlatformDbContext :
.HasForeignKey(x => x.EventId)
.OnDelete(DeleteBehavior.Cascade);
});
//Videoroom
builder.Entity<Videoroom>(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<VideoroomParticipant>(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<VideoroomAttandance>(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<VideoroomChat>(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);
});
}
}

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations
{
[DbContext(typeof(PlatformDbContext))]
[Migration("20260507140651_Initial")]
[Migration("20260507202053_Initial")]
partial class Initial
{
/// <inheritdoc />
@ -4894,6 +4894,356 @@ namespace Sozsoft.Platform.Migrations
b.ToTable("Sas_H_UomCategory", (string)null);
});
modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEndTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStartTime")
.HasColumnType("datetime2");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<int>("Duration")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<int>("MaxParticipants")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("ParticipantCount")
.HasColumnType("int");
b.Property<DateTime?>("ScheduledEndTime")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledStartTime")
.HasColumnType("datetime2");
b.Property<string>("SettingsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Subject")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("TeacherId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TeacherName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<DateTime?>("LeaveTime")
.HasColumnType("datetime2");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("StudentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StudentName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("MessageType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid?>("RecipientId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RecipientName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid?>("SenderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SenderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<DateTime>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ConnectionId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsAudioMuted")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsHandRaised")
.HasColumnType("bit");
b.Property<bool>("IsKicked")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<bool>("IsVideoMuted")
.HasColumnType("bit");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<Guid>("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");

View file

@ -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<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BranchId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
Subject = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
TeacherId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
TeacherName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ScheduledStartTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ScheduledEndTime = table.Column<DateTime>(type: "datetime2", nullable: true),
Duration = table.Column<int>(type: "int", nullable: false),
ActualStartTime = table.Column<DateTime>(type: "datetime2", nullable: true),
ActualEndTime = table.Column<DateTime>(type: "datetime2", nullable: true),
MaxParticipants = table.Column<int>(type: "int", nullable: false),
ParticipantCount = table.Column<int>(type: "int", nullable: false),
SettingsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(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<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BranchId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StudentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
StudentName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
JoinTime = table.Column<DateTime>(type: "datetime2", nullable: false),
LeaveTime = table.Column<DateTime>(type: "datetime2", nullable: true),
TotalDurationMinutes = table.Column<int>(type: "int", nullable: false),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(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<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BranchId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SenderId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SenderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Message = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
Timestamp = table.Column<DateTime>(type: "datetime2", nullable: false),
RecipientId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RecipientName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
IsTeacher = table.Column<bool>(type: "bit", nullable: false),
MessageType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(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<Guid>(type: "uniqueidentifier", nullable: false),
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
BranchId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
SessionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IsTeacher = table.Column<bool>(type: "bit", nullable: false),
IsAudioMuted = table.Column<bool>(type: "bit", nullable: false),
IsVideoMuted = table.Column<bool>(type: "bit", nullable: false),
IsHandRaised = table.Column<bool>(type: "bit", nullable: false),
IsKicked = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
JoinTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ConnectionId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeletionTime = table.Column<DateTime>(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");

View file

@ -4891,6 +4891,356 @@ namespace Sozsoft.Platform.Migrations
b.ToTable("Sas_H_UomCategory", (string)null);
});
modelBuilder.Entity("Sozsoft.Platform.Entities.Videoroom", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEndTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStartTime")
.HasColumnType("datetime2");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<int>("Duration")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<int>("MaxParticipants")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("ParticipantCount")
.HasColumnType("int");
b.Property<DateTime?>("ScheduledEndTime")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledStartTime")
.HasColumnType("datetime2");
b.Property<string>("SettingsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Subject")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("TeacherId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TeacherName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<DateTime?>("LeaveTime")
.HasColumnType("datetime2");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("StudentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StudentName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("MessageType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid?>("RecipientId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RecipientName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid?>("SenderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SenderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<DateTime>("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<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BranchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ConnectionId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime>("CreationTime")
.HasColumnType("datetime2")
.HasColumnName("CreationTime");
b.Property<Guid?>("CreatorId")
.HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId");
b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2")
.HasColumnName("DeletionTime");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsAudioMuted")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false)
.HasColumnName("IsDeleted");
b.Property<bool>("IsHandRaised")
.HasColumnType("bit");
b.Property<bool>("IsKicked")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsTeacher")
.HasColumnType("bit");
b.Property<bool>("IsVideoMuted")
.HasColumnType("bit");
b.Property<DateTime>("JoinTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2")
.HasColumnName("LastModificationTime");
b.Property<Guid?>("LastModifierId")
.HasColumnType("uniqueidentifier")
.HasColumnName("LastModifierId");
b.Property<Guid>("SessionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("TenantId")
.HasColumnType("uniqueidentifier")
.HasColumnName("TenantId");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<Guid>("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");

View file

@ -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";

View file

@ -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
}

View file

@ -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',

View file

@ -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<ClassroomDto>({
method: 'GET',
url: `/api/app/classroom/${id}`,
})
export const getClassrooms = (input: ClassroomFilterInputDto) =>
apiService.fetchData<PagedResultDto<ClassroomDto>>({
method: 'GET',
url: `/api/app/classroom`,
params: input,
})
export const createClassroom = (input: ClassroomDto) =>
apiService.fetchData<ClassroomDto>({
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<ClassroomAttendanceDto[]>({
method: 'GET',
url: `/api/app/classroom/attendance/${id}`,
})
export const getClassroomParticipants = (id: string) =>
apiService.fetchData<ClassroomParticipantDto[]>({
method: 'GET',
url: `/api/app/classroom/participant/${id}`,
})
export const getClassroomChats = (id: string) =>
apiService.fetchData<ClassroomChatDto[]>({
method: 'GET',
url: `/api/app/classroom/chat/${id}`,
})

View file

@ -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<VideoroomDto>({
method: 'GET',
url: `/api/app/videoroom/${id}`,
})
export const getVideorooms = (input: VideoroomFilterInputDto) =>
apiService.fetchData<PagedResultDto<VideoroomDto>>({
method: 'GET',
url: `/api/app/videoroom`,
params: input,
})
export const createVideoroom = (input: VideoroomDto) =>
apiService.fetchData<VideoroomDto>({
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<VideoroomAttendanceDto[]>({
method: 'GET',
url: `/api/app/videoroom/attendance/${id}`,
})
export const getVideoroomParticipants = (id: string) =>
apiService.fetchData<VideoroomParticipantDto[]>({
method: 'GET',
url: `/api/app/videoroom/participant/${id}`,
})
export const getVideoroomChats = (id: string) =>
apiService.fetchData<VideoroomChatDto[]>({
method: 'GET',
url: `/api/app/videoroom/chat/${id}`,
})

View file

@ -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(<Notification title="🔄 Bağlantı tekrar kuruldu" type="success" />, {
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(
<Notification title="⚠️ Bağlantı koptu, yeniden bağlanılıyor..." type="warning" />,
{ 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(<Notification title={`❌ Hata: ${message}`} type="danger" />, {
placement: 'top-end',
})
})
this.connection.on('Warning', (message: string) => {
toast.push(<Notification title={`⚠️ Uyarı: ${message}`} type="warning" />, {
placement: 'top-end',
})
})
this.connection.on('Info', (message: string) => {
toast.push(<Notification title={` Bilgi: ${message}`} type="info" />, {
placement: 'top-end',
})
})
this.connection.onreconnecting(() => {
if (this.isKicked) {
toast.push(
<Notification
title="❌ Sınıftan çıkarıldığınız için yeniden bağlanma engellendi"
type="danger"
/>,
)
this.connection.stop()
throw new Error('Reconnect blocked after kick')
}
})
this.connection.on('ForceDisconnect', async (message: string) => {
this.isKicked = true
toast.push(<Notification title={`❌ Sınıftan çıkarıldınız: ${message}`} type="danger" />, {
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<void> {
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(<Notification title="✅ Bağlantı kuruldu" type="success" />, {
placement: 'top-end',
})
} catch {
toast.push(
<Notification
title="⚠️ Sunucuya bağlanılamadı. Lütfen sayfayı yenileyin veya internet bağlantınızı kontrol edin."
type="danger"
/>,
{ placement: 'top-end' },
)
this.isConnected = false
}
}
async joinClass(
sessionId: string,
userId: string,
userName: string,
isTeacher: boolean,
isActive: boolean,
): Promise<void> {
if (!this.isConnected) {
toast.push(
<Notification
title="⚠️ Bağlantı yok. Sınıfa katılmadan önce bağlantıyı kontrol edin."
type="warning"
/>,
)
return
}
this.currentSessionId = sessionId
try {
await this.connection.invoke('JoinClass', sessionId, userId, userName, isTeacher, isActive)
} catch {
toast.push(<Notification title="❌ Sınıfa katılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
async leaveClass(sessionId: string): Promise<void> {
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(<Notification title="⚠️ Çıkış başarısız" type="warning" />, {
placement: 'top-end',
})
}
}
async sendChatMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
isTeacher: boolean,
): Promise<void> {
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(<Notification title="❌ Mesaj gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async sendPrivateMessage(
sessionId: string,
senderId: string,
senderName: string,
message: string,
recipientId: string,
recipientName: string,
isTeacher: boolean,
): Promise<void> {
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(<Notification title="❌ Özel mesaj gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async sendAnnouncement(
sessionId: string,
senderId: string,
senderName: string,
message: string,
isTeacher: boolean,
): Promise<void> {
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(<Notification title="❌ Duyuru gönderilemedi" type="danger" />, {
placement: 'top-end',
})
}
}
async muteParticipant(
sessionId: string,
userId: string,
isMuted: boolean,
isTeacher: boolean,
): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onParticipantMuted?.(userId, isMuted)
}, 100)
return
}
try {
await this.connection.invoke('MuteParticipant', sessionId, userId, isMuted, isTeacher)
} catch {
toast.push(<Notification title="⚠️ Katılımcı susturulamadı" type="warning" />, {
placement: 'top-end',
})
}
}
async raiseHand(sessionId: string, studentId: string, studentName: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseReceived?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('RaiseHand', sessionId, studentId, studentName)
} catch {
toast.push(<Notification title="❌ El kaldırma başarısız" type="danger" />, {
placement: 'top-end',
})
}
}
async kickParticipant(sessionId: string, participantId: string, userName: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onParticipantLeft?.({ userId: participantId, sessionId, userName })
}, 100)
return
}
try {
await this.connection.invoke('KickParticipant', sessionId, participantId)
} catch {
toast.push(<Notification title="❌ Katılımcı atılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
async approveHandRaise(sessionId: string, studentId: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseDismissed?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('ApproveHandRaise', sessionId, studentId)
} catch {
toast.push(<Notification title="⚠️ El kaldırma onayı başarısız" type="warning" />, {
placement: 'top-end',
})
}
}
async dismissHandRaise(sessionId: string, studentId: string): Promise<void> {
if (!this.isConnected) {
setTimeout(() => {
this.onHandRaiseDismissed?.(studentId)
}, 100)
return
}
try {
await this.connection.invoke('DismissHandRaise', sessionId, studentId)
} catch {
toast.push(<Notification title="⚠️ El indirme başarısız" type="warning" />, {
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<void> {
if (this.isConnected && this.currentSessionId) {
try {
await this.connection.invoke('LeaveClass', this.currentSessionId)
} catch {
toast.push(<Notification title="⚠️ Bağlantı koparılırken hata" type="warning" />, {
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
}
}

View file

@ -0,0 +1,358 @@
import { toast } from '@/components/ui'
import Notification from '@/components/ui/Notification'
export class WebRTCService {
private peerConnections: Map<string, RTCPeerConnection> = new Map()
private retryCounts: Map<string, number> = 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<string, RTCIceCandidateInit[]> = 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<MediaStream> {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
this.localStream.getAudioTracks().forEach((track) => (track.enabled = enableAudio))
this.localStream.getVideoTracks().forEach((track) => (track.enabled = enableVideo))
return this.localStream
} catch {
toast.push(
<Notification
title="❌ Kamera/Mikrofon erişilemedi. Tarayıcı ayarlarınızı veya izinleri kontrol edin."
type="danger"
/>,
{ placement: 'top-end' },
)
throw new Error('Media devices access failed')
}
}
async createPeerConnection(userId: string): Promise<RTCPeerConnection> {
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(
<Notification
title={`⚠️ Bağlantı başarısız, yeniden deneniyor (${retries + 1}/${this.maxRetries})`}
type="warning"
/>,
)
this.retryCounts.set(userId, retries + 1)
await this.restartIce(peerConnection, userId)
} else {
toast.push(
<Notification
title={`❌ Bağlantı kurulamadı (${this.maxRetries} deneme başarısız).`}
type="danger"
/>,
{ 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(
<Notification
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
type="warning"
/>,
)
}
}
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<RTCSessionDescriptionInit> {
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(<Notification title="❌ Offer oluşturulamadı" type="danger" />, {
placement: 'top-end',
})
throw new Error('Offer creation failed')
}
}
async createAnswer(
userId: string,
offer: RTCSessionDescriptionInit,
): Promise<RTCSessionDescriptionInit> {
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(<Notification title="❌ Answer oluşturulamadı" type="danger" />, {
placement: 'top-end',
})
throw new Error('Answer creation failed')
}
}
async handleAnswer(userId: string, answer: RTCSessionDescriptionInit): Promise<void> {
const peerConnection = this.peerConnections.get(userId)
if (!peerConnection) throw new Error('Peer connection not found')
await peerConnection.setRemoteDescription(answer)
}
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
const 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(
<Notification
title={`⚠️ ICE candidate eklenemedi. Kullanıcı: ${userId}`}
type="warning"
/>,
)
}
} 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<void> {
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(<Notification title="❌ Kamera açılamadı" type="danger" />, {
placement: 'top-end',
})
}
}
}
async toggleAudio(enabled: boolean): Promise<void> {
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(<Notification title="❌ Mikrofon açılamadı" type="danger" />, {
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(<Notification title="⚠️ Tekrar bağlanma başarısız" type="warning" />, {
placement: 'top-end',
})
}
} catch {
toast.push(<Notification title="❌ ICE restart başarısız" type="danger" />, {
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(<Notification title="⚠️ Track silinemedi" type="warning" />, {
placement: 'top-end',
})
}
if (sender.track?.readyState !== 'ended') {
sender.track?.stop()
}
}
})
})
}
}

View file

@ -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<RoleState>('role-selection')
const [currentClass, setCurrentClass] = useState<ClassroomDto | null>(null)
const [allClasses, setAllClasses] = useState<ClassroomDto[]>([])
const [currentClass, setCurrentClass] = useState<VideoroomDto | null>(null)
const [allClasses, setAllClasses] = useState<VideoroomDto[]>([])
const handleRoleSelect = (role: Role) => {
setUser({
@ -18,7 +18,7 @@ export function useClassroomLogic() {
setRoleState('dashboard')
}
const handleCreateClass = (classData: Partial<ClassroomDto>) => {
const handleCreateClass = (classData: Partial<VideoroomDto>) => {
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<ClassroomDto>) => {
const handleEditClass = (classId: string, classData: Partial<VideoroomDto>) => {
setAllClasses((prev) => prev.map((c) => (c.id === classId ? { ...c, ...classData } : c)))
}

View file

@ -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<ChatPanelProps> = ({
user,
participants,
chatMessages,
newMessage,
setNewMessage,
messageMode,
setMessageMode,
selectedRecipient,
setSelectedRecipient,
onSendMessage,
onClose,
formatTime,
classSettings,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chatMessages])
const availableRecipients = participants.filter((p) => p.id !== user.id)
return (
<div className="h-full bg-white flex flex-col text-gray-900">
{/* Header */}
<div className="p-3 sm:p-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-base sm:text-lg font-semibold">Sohbet</h3>
<button onClick={onClose}>
<FaTimes className="text-gray-500" size={16} />
</button>
</div>
{/* Mesaj Modu */}
<div className="p-3 border-b border-gray-200 bg-gray-50">
<div className="flex space-x-2 mb-2">
<button
onClick={() => {
setMessageMode('public')
setSelectedRecipient(null)
}}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'public'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaUsers size={12} />
<span>Herkese</span>
</button>
{classSettings.allowPrivateMessages && (
<button
onClick={() => setMessageMode('private')}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'private'
? 'bg-green-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaUser size={12} />
<span>Özel</span>
</button>
)}
{user.role === 'teacher' && (
<button
onClick={() => {
setMessageMode('announcement')
setSelectedRecipient(null)
}}
className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs ${
messageMode === 'announcement'
? 'bg-red-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
<FaBullhorn size={12} />
<span>Duyuru</span>
</button>
)}
</div>
{messageMode === 'private' && (
<select
value={selectedRecipient?.id || ''}
onChange={(e) => {
const recipient = availableRecipients.find((p) => p.id === e.target.value)
setSelectedRecipient(recipient ? { id: recipient.id, name: recipient.name } : null)
}}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded"
>
<option value="">Kişi seçin...</option>
{availableRecipients.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.isTeacher ? '(Öğretmen)' : ''}
</option>
))}
</select>
)}
</div>
{/* Mesaj Listesi */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{chatMessages.length === 0 ? (
<div className="text-center text-gray-500 text-sm">Henüz mesaj yok.</div>
) : (
chatMessages.map((m) => (
<div
key={m.id}
className={`${
m.messageType === 'announcement'
? 'w-full'
: m.senderId === user.id
? 'flex justify-end'
: 'flex justify-start'
}`}
>
<div
className={`max-w-xs px-3 py-2 rounded-lg ${
m.messageType === 'announcement'
? 'bg-red-100 text-red-800 border border-red-200 w-full text-center'
: m.messageType === 'private'
? m.senderId === user.id
? 'bg-green-600 text-white'
: 'bg-green-100 text-green-800 border'
: m.senderId === user.id
? 'bg-blue-600 text-white'
: m.isTeacher
? 'bg-yellow-100 text-yellow-800 border'
: 'bg-gray-100 text-gray-800'
}`}
>
{m.senderId !== user.id && (
<div className="text-xs font-semibold mb-1">
{m.senderName}
{m.isTeacher && ' (Öğretmen)'}
</div>
)}
<div className="text-sm">{m.message}</div>
<div className="text-xs mt-1 opacity-75">{formatTime(m.timestamp)}</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Mesaj Gönderme */}
<form onSubmit={onSendMessage} className="p-4 border-t border-gray-200">
<div className="flex space-x-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Mesaj yaz..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
<button
type="submit"
disabled={!newMessage.trim()}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
<FaPaperPlane size={16} />
</button>
</div>
</form>
</div>
)
}
export default ChatPanel

View file

@ -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 (
<>
<Helmet
titleTemplate="%s | Erp Platform"
title={translate('::' + 'App.Videoroom.Dashboard')}
defaultTitle="Erp Platform"
></Helmet>
<div className="flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center w-full max-w-4xl"
>
<p className="text-lg sm:text-xl text-gray-600 mb-8 sm:mb-12">Lütfen rolünüzü seçin</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => 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"
>
<FaGraduationCap size={48} className="mx-auto text-blue-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Öğretmen</h2>
<p className="text-gray-600 text-sm sm:text-base">
Ders başlatın, öğrencilerle iletişim kurun ve katılım raporlarını görün
</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => 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"
>
<FaUserCheck size={48} className="mx-auto text-green-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Öğrenci</h2>
<p className="text-gray-600 text-sm sm:text-base">
Aktif derslere katılın, öğretmeniniz ve diğer öğrencilerle etkileşim kurun
</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => 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"
>
<FaEye size={48} className="mx-auto text-purple-600 mb-4 sm:mb-4" />
<h2 className="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Gözlemci</h2>
<p className="text-gray-600 text-sm sm:text-base">
Sınıfı gözlemleyin, eğitim sürecini takip edin (ses/video paylaşımı yok)
</p>
</motion.button>
</div>
</motion.div>
</div>
</>
)
}
export default Dashboard

View file

@ -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<DocumentsPanelProps> = ({
user,
documents,
onUpload,
onDelete,
onView,
onClose,
formatFileSize,
getFileIcon,
}) => {
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
if (user.role !== 'teacher') return
const files = Array.from(e.target.files || [])
files.forEach((file) => onUpload(file))
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="h-full bg-white flex flex-col text-gray-900">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Sınıf Dokümanları</h3>
<button onClick={onClose}>
<FaTimes className="text-gray-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Upload Area (Teacher Only) */}
{user.role === 'teacher' && (
<div
className={`border-2 border-dashed rounded-lg p-6 mb-4 text-center transition-colors ${
dragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
}`}
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
>
<FaFile size={32} className="mx-auto text-gray-400 mb-2" />
<p className="text-sm font-medium text-gray-700 mb-2">Doküman Yükle</p>
<p className="text-xs text-gray-500 mb-3">Dosyaları buraya sürükleyin veya seçin</p>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 transition-colors"
>
Dosya Seç
</button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
accept=".pdf,.doc,.docx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.odp"
/>
</div>
)}
{/* Documents List */}
{documents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FaFile size={32} className="mx-auto mb-4 text-gray-300" />
<p className="text-sm">Henüz doküman yüklenmemiş.</p>
</div>
) : (
<div className="space-y-3">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:shadow-sm transition-shadow"
>
<div className="flex items-center space-x-3 min-w-0 flex-1">
<div className="text-lg flex-shrink-0">{getFileIcon(doc.type)}</div>
<div className="min-w-0 flex-1">
<h4 className="font-medium text-gray-800 text-sm truncate">{doc.name}</h4>
<p className="text-xs text-gray-600">
{formatFileSize(doc.size)} {' '}
{new Date(doc.uploadedAt).toLocaleDateString('tr-TR')}
</p>
<p className="text-xs text-gray-500">{doc.uploadedBy}</p>
</div>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
<button
onClick={() => onView(doc)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Görüntüle"
>
<FaEye size={12} />
</button>
<a
href={doc.url}
download={doc.name}
className="p-1 text-green-600 hover:bg-green-50 rounded transition-colors"
title="İndir"
>
<FaDownload size={12} />
</a>
{user.role === 'teacher' && (
<button
onClick={() => onDelete(doc.id)}
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
title="Sil"
>
<FaTrash size={12} />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default DocumentsPanel

View file

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

View file

@ -0,0 +1,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 <FaTh size={24} />
case 'speaker':
return <FaExpand size={24} />
case 'presentation':
return <FaDesktop size={24} />
case 'sidebar':
return <FaUsers size={24} />
case 'teacher-focus':
return <FaDesktop size={24} />
default:
return <FaTh size={24} />
}
}
const LayoutPanel: React.FC<LayoutPanelProps> = ({
layouts,
currentLayout,
onChangeLayout,
onClose,
}) => {
return (
<div className="h-full bg-white flex flex-col text-gray-900">
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Video Layout Seçin</h3>
<button onClick={onClose}>
<FaTimes className="text-gray-500" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-3">
{layouts.map((layout) => (
<button
key={layout.id}
onClick={() => onChangeLayout(layout)}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
currentLayout.id === layout.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center space-x-3 mb-2">
<div
className={`p-2 rounded-full ${
currentLayout.id === layout.id
? 'bg-blue-100 text-blue-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{getLayoutIcon(layout.type)}
</div>
<div>
<h4 className="font-medium text-gray-900 text-sm">{layout.name}</h4>
<p className="text-xs text-gray-600">{layout.description}</p>
</div>
</div>
{/* Layout Preview */}
<div className="bg-gray-100 rounded p-3 h-16 flex items-center justify-center">
{layout.type === 'grid' && (
<div className="grid grid-cols-2 gap-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="w-4 h-3 bg-blue-300 rounded"></div>
))}
</div>
)}
{layout.type === 'sidebar' && (
<div className="flex items-center space-x-2">
<div className="w-8 h-6 bg-blue-500 rounded"></div>
<div className="grid grid-cols-3 gap-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-1 h-1 bg-blue-300 rounded"></div>
))}
</div>
</div>
)}
{layout.type === 'teacher-focus' && (
<div className="space-y-1">
<div className="w-12 h-4 bg-green-500 rounded"></div>
<div className="flex space-x-1">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="w-1 h-1 bg-blue-300 rounded"></div>
))}
</div>
</div>
)}
</div>
</button>
))}
</div>
</div>
</div>
)
}
export default LayoutPanel

View file

@ -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<ParticipantsPanelProps> = ({
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 (
<div className="h-full bg-white flex flex-col text-gray-900">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">
Katılımcılar ({participants.length + 1})
</h3>
<button onClick={onClose}>
<FaTimes className="text-gray-500" />
</button>
</div>
{/* El kaldıranlar göstergesi */}
{user.role === 'teacher' && handRaised.length > 0 && (
<div className="mb-2 flex items-center space-x-2 p-2 bg-yellow-50 rounded">
<FaHandPaper className="text-yellow-600" />
<span className="font-medium text-yellow-800">
{handRaised.length} kişi el kaldırdı:
</span>
<span className="text-yellow-900 text-sm truncate">
{handRaised.map((p) => p.name).join(', ')}
</span>
</div>
)}
{/* Tab Navigation */}
<div className="flex space-x-1 bg-gray-100 rounded-lg p-1">
<button
onClick={() => setActiveTab('participants')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all ${
activeTab === 'participants'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<FaUsers className="inline mr-1" size={14} />
Katılımcılar
</button>
{user.role === 'teacher' && (
<button
onClick={() => setActiveTab('attendance')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all ${
activeTab === 'attendance'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<FaClipboardList className="inline mr-1" size={14} />
Katılım Raporu
</button>
)}
</div>
</div>
{/* Participants Tab */}
{activeTab === 'participants' && (
<div className="flex-1 overflow-y-auto p-2">
<div className="space-y-2">
{/* Current User */}
<div className="flex items-center justify-between p-2 rounded-lg bg-blue-50">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-semibold">
{user.name.charAt(0)}
</div>
<span className="text-gray-900">{user.name} (Siz)</span>
</div>
{user.role === 'teacher' && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Öğretmen
</span>
)}
</div>
{/* Other Participants */}
{participants.map((participant) => (
<div
key={participant.id}
className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50"
>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-500 rounded-full flex items-center justify-center text-white text-sm font-semibold">
{participant.name.charAt(0)}
</div>
<span className="text-gray-900">{participant.name}</span>
{/* Hand Raise Indicator & Teacher Control */}
{participant.isHandRaised &&
(user.role === 'teacher' && !participant.isTeacher ? (
<button
onClick={() => onDismissHandRaise(participant.id)}
className="ml-2 p-1 rounded bg-yellow-100 hover:bg-yellow-200"
title="El kaldırmayı kaldır"
>
<FaHandPaper className="text-yellow-600" />
</button>
) : (
<FaHandPaper className="text-yellow-600 ml-2" title="Parmak kaldırdı" />
))}
</div>
<div className="flex items-center space-x-1">
{/* Hand Raise Controls kaldırıldı, kontrol yukarıya taşındı */}
{/* Mute / Unmute Button */}
{user.role === 'teacher' && !participant.isTeacher && (
<button
onClick={() =>
onMuteParticipant(participant.id, !participant.isAudioMuted, true)
}
className={`p-1 rounded transition-colors ${
participant.isAudioMuted
? 'text-green-600 hover:bg-green-50'
: 'text-yellow-600 hover:bg-yellow-50'
}`}
title={participant.isAudioMuted ? 'Sesi Aç' : 'Sesi Kapat'}
>
{participant.isAudioMuted ? <FaMicrophone /> : <FaMicrophoneSlash />}
</button>
)}
{/* Video muted indicator */}
{participant.isVideoMuted && <FaVideoSlash className="text-red-500 text-sm" />}
{participant.isTeacher && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Öğretmen
</span>
)}
{/* Kick Button (Teacher Only) */}
{user.role === 'teacher' && !participant.isTeacher && (
<button
onClick={() => onKickParticipant(participant.id, participant.name)}
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
title="Sınıftan Çıkar"
>
<FaUserTimes size={12} />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Attendance Tab */}
{activeTab === 'attendance' && (
<div className="flex-1 overflow-y-auto p-4">
{attendanceRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FaClipboardList size={32} className="mx-auto mb-4 text-gray-300" />
<p className="text-sm text-gray-600">Henüz katılım kaydı bulunmamaktadır.</p>
</div>
) : (
<div className="space-y-3">
{attendanceRecords.map((record) => (
<div key={record.id} className="p-3 border border-gray-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-800">{record.studentName}</h4>
<span className="text-sm font-semibold text-blue-600">
{formatDuration(record.totalDurationMinutes)}
</span>
</div>
<div className="text-xs text-gray-600 space-y-1">
<div>Giriş: {formatTime(record.joinTime)}</div>
<div>
Çıkış: {record.leaveTime ? formatTime(record.leaveTime) : 'Devam ediyor'}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
export default ParticipantsPanel

File diff suppressed because it is too large Load diff

View file

@ -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<VideoroomDto[]>([])
const [videoroom, setVideoroom] = useState<VideoroomDto>(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 (
<>
<Helmet
titleTemplate="%s | Erp Platform"
title={translate('::' + 'App.Coordinator.Videoroom.List')}
defaultTitle="Erp Platform"
></Helmet>
<Container>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 sm:gap-6 mb-3">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-blue-100 rounded-full">
<FaCalendarAlt className="text-blue-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Toplam Sınıf</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{widgets().totalCount}{' '}
</p>
</div>
</div>
</motion.div>
{/* Aktif Sınıf */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-green-100 rounded-full">
<FaPlay className="text-green-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Aktif Sınıf</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{widgets().activeCount}
</p>
</div>
</div>
</motion.div>
{/* Katılıma Açık */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-blue-100 rounded-full">
<FaDoorOpen className="text-blue-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Katılıma ık</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">{widgets().openCount}</p>
</div>
</div>
</motion.div>
{/* Pasif Sınıf */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-gray-100 rounded-full">
<FaHourglassEnd className="text-gray-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Pasif Sınıf</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{widgets().passiveCount}
</p>
</div>
</div>
</motion.div>
{/* Toplam Katılımcı */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-lg shadow-md p-4 sm:p-6 sm:col-span-2 lg:col-span-1"
>
<div className="flex items-center">
<div className="p-2 sm:p-3 bg-purple-100 rounded-full">
<FaUsers className="text-purple-600" size={20} />
</div>
<div className="ml-3 sm:ml-4">
<p className="text-xs sm:text-sm font-medium text-gray-600">Toplam Katılımcı</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">
{videoList.reduce((sum, c) => sum + c.participantCount, 0)}
</p>
</div>
</div>
</motion.div>
</div>
{/* Filter Bar */}
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6 shadow-sm">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search class"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-4">
<FaFilter className="w-5 h-5 text-slate-500" />
<select
className="ml-2 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={{ minWidth: 120 }}
>
<option value="">All Status</option>
<option value="Active">Aktif</option>
<option value="Open">Katılıma ık</option>
<option value="Passive">Pasif</option>
</select>
</div>
</div>
</div>
{/* Scheduled Classes */}
<div className="bg-white rounded-lg shadow-md">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 p-4 sm:px-6 border-b border-gray-200">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">Programlı Sınıflar</h2>
{user.role === 'teacher' && (
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center justify-center space-x-2 bg-blue-600 text-white px-3 sm:px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors sm:w-auto"
>
<FaPlus size={15} />
<span className="hidden sm:inline">Yeni Sınıf Oluştur</span>
<span className="sm:hidden">Yeni Sınıf</span>
</button>
)}
</div>
<div className="p-4 sm:p-6">
{videoList.length === 0 ? (
<div className="text-center py-12">
<FaCalendarAlt size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-gray-500">Henüz programlanmış sınıf bulunmamaktadır.</p>
</div>
) : (
<div className="grid gap-4 sm:gap-6">
{videoList.map((classSession, index) => {
const { status, className, showButtons, title, classes, event } =
getClassProps(classSession)
return (
<motion.div
key={classSession.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="border border-gray-200 rounded-lg p-4 sm:p-6 hover:shadow-md transition-shadow"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center space-x-3">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 break-words">
{classSession.name}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${className}`}
>
{status}
</span>
</div>
{/* Sağ kısım: buton */}
{showButtons && (
<div className="flex space-x-2">
{/* {user.role === 'teacher' && classSession.teacherId === user.id && ( */}
{user.role === 'teacher' && (
<>
<button
onClick={() => handlePlanningClass(classSession)}
disabled={classSession.actualStartTime ? true : false}
className="flex px-3 sm:px-4 py-2 rounded-lg bg-yellow-600 text-white
hover:bg-yellow-700
disabled:bg-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-400"
title="Sınıfı Planla"
>
<FaUsers size={14} />
Planlama
</button>
<button
onClick={() => openEditModal(classSession)}
disabled={classSession.actualStartTime ? true : false}
className="flex px-3 sm:px-4 py-2 rounded-lg bg-blue-600 text-white
hover:bg-blue-700
disabled:bg-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-400"
title="Sınıfı Düzenle"
>
<FaEdit size={14} />
Düzenle
</button>
<button
onClick={() => openDeleteModal(classSession)}
disabled={classSession.actualStartTime ? true : false}
className="flex px-3 sm:px-4 py-2 rounded-lg bg-red-600 text-white
hover:bg-red-700
disabled:bg-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-400"
title="Sınıfı Sil"
>
<FaTrash size={14} />
Sil
</button>
</>
)}
<button
onClick={event}
disabled={status === 'Katılıma Açık' ? true : false}
className={`px-3 sm:px-4 py-2 rounded-lg transition-colors ${
classes
}`}
>
{title}
</button>
</div>
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<p className="text-gray-600 text-sm sm:text-base">{classSession.subject}</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<sub className="text-gray-500 mb-3 text-xs sm:text-sm">
{classSession.description}
</sub>
</div>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 md:gap-3 w-full text-xs sm:text-sm text-gray-600">
<div className="col-span-1 flex items-center gap-2 p-1 rounded-lg">
<FaCalendarAlt size={14} className="text-gray-500" />
<span className="truncate">
{showDbDateAsIs(classSession.scheduledStartTime)}
</span>
</div>
<div className="col-span-1 flex items-center gap-2 p-1 rounded-lg">
<FaClock size={14} className="text-gray-500" />
<span>{classSession.duration} dakika</span>
</div>
<div className="col-span-1 flex items-center gap-2 p-1 rounded-lg">
{classSession.scheduledEndTime && (
<>
<FaEye size={14} className="text-gray-500" />
<span className="truncate">
{showDbDateAsIs(classSession.scheduledEndTime!)}
</span>
</>
)}
</div>
<div className="col-span-1 flex items-center gap-2 p-1 rounded-lg">
<FaUsers size={14} className="text-gray-500" />
<span>
{classSession.participantCount}/{classSession.maxParticipants}
</span>
</div>
</div>
</div>
</motion.div>
)
})}
</div>
)}
</div>
</div>
{/* Class Modal (Create/Edit) */}
{(showCreateModal || (showEditModal && videoroom)) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-2xl w-full max-h-[95vh] overflow-y-auto"
>
<div className="p-3 sm:p-3 border-b border-gray-200">
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{showCreateModal ? 'Yeni Sınıf Oluştur' : 'Sınıfı Düzenle'}
</h2>
</div>
<form
onSubmit={showCreateModal ? handleCreateClass : handleEditClass}
className="p-4 sm:p-6 space-y-4 sm:space-y-6"
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sınıf Adı *
</label>
<input
type="text"
required
autoFocus={showCreateModal}
value={videoroom.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">ıklama</label>
<textarea
value={videoroom.description}
onChange={(e) => setVideoroom({ ...videoroom, description: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ders hakkında kısa açıklama..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Ders Konusu
</label>
<input
type="text"
value={videoroom.subject}
onChange={(e) => setVideoroom({ ...videoroom, subject: 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, Fizik, Kimya"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Başlangıç Tarihi ve Saati *
</label>
<input
type="datetime-local"
required
value={
videoroom.scheduledStartTime
? videoroom.scheduledStartTime.slice(0, 16)
: ''
}
onChange={(e) =>
setVideoroom({
...videoroom,
scheduledStartTime: 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"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Süre (dakika)
</label>
<input
type="number"
min="15"
max="480"
value={videoroom.duration}
onChange={(e) =>
setVideoroom({
...videoroom,
duration: parseInt(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Maksimum Katılımcı
</label>
<input
type="number"
min="1"
max="100"
value={videoroom.maxParticipants}
onChange={(e) =>
setVideoroom({
...videoroom,
maxParticipants: parseInt(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"
/>
</div>
</div>
{/* Sınıf Ayarları */}
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-2">Sınıf Ayarları</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-4">
<h4 className="font-medium text-gray-700">Katılımcı İzinleri</h4>{' '}
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={videoroom.settingsDto?.allowHandRaise}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
allowHandRaise: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Parmak kaldırma izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={videoroom.settingsDto?.allowStudentChat}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
allowStudentChat: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Öğrenci sohbet izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={videoroom.settingsDto?.allowPrivateMessages}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
allowPrivateMessages: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Özel mesaj izni</span>
</label>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={videoroom.settingsDto?.allowStudentScreenShare}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
allowStudentScreenShare: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Öğrenci ekran paylaşımı</span>
</label>
</div>
<div className="space-y-4">
<h4 className="font-medium text-gray-700">Varsayılan Ayarlar</h4>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan mikrofon durumu
</label>
<select
value={videoroom.settingsDto?.defaultMicrophoneState}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
defaultMicrophoneState: e.target.value as 'muted' | 'unmuted',
},
})
}
className="border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="muted">Kapalı</option>
<option value="unmuted">ık</option>
</select>
</div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan kamera durumu
</label>
<select
value={videoroom.settingsDto?.defaultCameraState}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
defaultCameraState: e.target.value as 'on' | 'off',
},
})
}
className="border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="on">ık</option>
<option value="off">Kapalı</option>
</select>
</div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 mb-1">
Varsayılan layout
</label>
<select
value={videoroom.settingsDto?.defaultLayout}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
defaultLayout: e.target.value,
},
})
}
className="border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="grid">Izgara Görünümü</option>
<option value="teacher-focus">Öğretmen Odaklı</option>
<option value="presentation">Sunum Modu</option>
<option value="sidebar">Yan Panel</option>
</select>
</div>
<label className="flex items-center space-x-3">
<input
type="checkbox"
checked={videoroom.settingsDto?.autoMuteNewParticipants}
onChange={(e) =>
setVideoroom({
...videoroom,
settingsDto: {
...videoroom.settingsDto!,
autoMuteNewParticipants: e.target.checked,
},
})
}
className="rounded"
/>
<span className="text-sm">Yeni katılımcıları otomatik sustur</span>
</label>
</div>
</div>
</div>
<div className="flex items-center justify-end space-x-4 pt-6 border-t border-gray-200">
<button
type="button"
onClick={() => {
if (showCreateModal) {
setShowCreateModal(false)
}
if (showEditModal) {
setShowEditModal(false)
setVideoroom(newClassEntity)
resetForm()
}
}}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{showCreateModal ? 'Sınıf Oluştur' : 'Değişiklikleri Kaydet'}
</button>
</div>
</form>
</motion.div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && videoroom && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white rounded-lg max-w-md w-full mx-4"
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className="p-3 bg-red-100 rounded-full mr-4">
<FaTrash className="text-red-600" size={24} />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Sınıfı Sil</h3>
<p className="text-sm text-gray-600">Bu işlem geri alınamaz</p>
</div>
</div>
<p className="text-gray-700 mb-6">
<strong>"{videoroom.name}"</strong> adlı sınıfı silmek istediğinizden emin
misiniz?
</p>
<div className="flex items-center justify-end space-x-4">
<button
onClick={() => {
setShowDeleteModal(false)
setVideoroom(newClassEntity)
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
İptal
</button>
<button
onClick={handleDeleteClass}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Sil
</button>
</div>
</div>
</motion.div>
</div>
)}
</Container>
</>
)
}
export default RoomList

View file

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

View file

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

View file

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

View file

@ -81,7 +81,7 @@ export function AdminView({
const navigationItems = [
{
id: 'stats' as AdminSection,
label: translate('::App.Coordinator.Classroom.Dashboard'),
label: translate('::App.Videoroom.Dashboard'),
icon: FaChartBar,
},
{