online classroom SignalR
This commit is contained in:
parent
76615b074b
commit
e96faabd76
12 changed files with 102 additions and 69 deletions
|
|
@ -8,13 +8,13 @@ namespace Kurs.Platform.Classrooms;
|
|||
|
||||
public interface IClassroomAppService : IApplicationService
|
||||
{
|
||||
Task<ClassroomDto> CreateAsync(ClassroomDto input);
|
||||
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
|
||||
Task<ClassroomDto> GetAsync(Guid id);
|
||||
Task<PagedResultDto<ClassroomDto>> GetListAsync(PagedAndSortedResultRequestDto input);
|
||||
Task<ClassroomDto> CreateAsync(ClassroomDto input);
|
||||
Task<ClassroomDto> UpdateAsync(Guid id, ClassroomDto input);
|
||||
Task DeleteAsync(Guid id);
|
||||
// Task<ClassroomDto> StartClassAsync(Guid id);
|
||||
// Task EndClassAsync(Guid id);
|
||||
Task<ClassroomDto> StartClassAsync(Guid id);
|
||||
Task EndClassAsync(Guid id);
|
||||
Task<ClassroomDto> JoinClassAsync(Guid id);
|
||||
Task LeaveClassAsync(Guid id);
|
||||
Task<List<ClassAttendanceDto>> GetAttendanceAsync(Guid sessionId);
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ public class ClassroomAppService : PlatformAppService, IClassroomAppService
|
|||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<ClassroomDto> StartClassroomAsync(Guid id)
|
||||
public async Task<ClassroomDto> StartClassAsync(Guid id)
|
||||
{
|
||||
var classSession = await _classSessionRepository.GetAsync(id);
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ public class ClassroomAppService : PlatformAppService, IClassroomAppService
|
|||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task EndClassroomAsync(Guid id)
|
||||
public async Task EndClassAsync(Guid id)
|
||||
{
|
||||
var classSession = await _classSessionRepository.GetAsync(id);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace Kurs.Platform.Entities;
|
|||
public class ClassChat : FullAuditedEntity<Guid>
|
||||
{
|
||||
public Guid SessionId { get; set; }
|
||||
public Guid SenderId { get; set; }
|
||||
public Guid? SenderId { get; set; }
|
||||
public string SenderName { get; set; }
|
||||
public string Message { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
|
@ -22,7 +22,7 @@ public class ClassChat : FullAuditedEntity<Guid>
|
|||
public ClassChat(
|
||||
Guid id,
|
||||
Guid sessionId,
|
||||
Guid senderId,
|
||||
Guid? senderId,
|
||||
string senderName,
|
||||
string message,
|
||||
bool isTeacher
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Volo.Abp.Domain.Repositories;
|
|||
using Kurs.Platform.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volo.Abp.Guids;
|
||||
using Volo.Abp.Users;
|
||||
|
||||
namespace Kurs.Platform.SignalR.Hubs;
|
||||
|
||||
|
|
@ -17,21 +18,25 @@ public class ClassroomHub : Hub
|
|||
private readonly IRepository<ClassChat, Guid> _chatMessageRepository;
|
||||
private readonly ILogger<ClassroomHub> _logger;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ClassroomHub(
|
||||
IRepository<Classroom, Guid> classSessionRepository,
|
||||
IRepository<ClassParticipant, Guid> participantRepository,
|
||||
IRepository<ClassChat, Guid> chatMessageRepository,
|
||||
ILogger<ClassroomHub> logger,
|
||||
IGuidGenerator guidGenerator)
|
||||
IGuidGenerator guidGenerator,
|
||||
ICurrentUser currentUser)
|
||||
{
|
||||
_classSessionRepository = classSessionRepository;
|
||||
_participantRepository = participantRepository;
|
||||
_chatMessageRepository = chatMessageRepository;
|
||||
_logger = logger;
|
||||
_guidGenerator = guidGenerator;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HubMethodName("JoinClass")]
|
||||
public async Task JoinClassAsync(Guid sessionId, string userName)
|
||||
{
|
||||
var classSession = await _classSessionRepository.GetAsync(sessionId);
|
||||
|
|
@ -47,7 +52,7 @@ public class ClassroomHub : Hub
|
|||
|
||||
// Update participant connection
|
||||
var participant = await _participantRepository.FirstOrDefaultAsync(
|
||||
x => x.SessionId == sessionId && x.UserId == Context.UserIdentifier.To<Guid>()
|
||||
x => x.SessionId == sessionId && x.UserId == _currentUser.Id
|
||||
);
|
||||
|
||||
if (participant != null)
|
||||
|
|
@ -58,16 +63,17 @@ public class ClassroomHub : Hub
|
|||
|
||||
// Notify others
|
||||
await Clients.Group(sessionId.ToString())
|
||||
.SendAsync("ParticipantJoined", Context.UserIdentifier, userName);
|
||||
.SendAsync("ParticipantJoined", _currentUser.Id, userName);
|
||||
_logger.LogInformation($"User {userName} joined class {sessionId}");
|
||||
}
|
||||
|
||||
[HubMethodName("LeaveClass")]
|
||||
public async Task LeaveClassAsync(Guid sessionId)
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, sessionId.ToString());
|
||||
await Clients.Group(sessionId.ToString())
|
||||
.SendAsync("ParticipantLeft", Context.UserIdentifier);
|
||||
_logger.LogInformation($"User {Context.UserIdentifier} left class {sessionId}");
|
||||
.SendAsync("ParticipantLeft", _currentUser);
|
||||
_logger.LogInformation($"User {_currentUser} left class {sessionId}");
|
||||
}
|
||||
|
||||
public async Task SendSignalingMessageAsync(SignalingMessageDto message)
|
||||
|
|
@ -80,8 +86,8 @@ public class ClassroomHub : Hub
|
|||
|
||||
public async Task SendChatMessageAsync(Guid sessionId, string message)
|
||||
{
|
||||
var userId = Context.UserIdentifier.To<Guid>();
|
||||
var userName = Context.User?.Identity?.Name ?? "Unknown";
|
||||
var userName = _currentUser.UserName;
|
||||
var userId = _currentUser.Id;
|
||||
|
||||
// Check if user is teacher
|
||||
var participant = await _participantRepository.FirstOrDefaultAsync(
|
||||
|
|
@ -118,7 +124,7 @@ public class ClassroomHub : Hub
|
|||
public async Task MuteParticipantAsync(Guid sessionId, Guid participantId, bool isMuted)
|
||||
{
|
||||
var teacherParticipant = await _participantRepository.FirstOrDefaultAsync(
|
||||
x => x.SessionId == sessionId && x.UserId == Context.UserIdentifier.To<Guid>()
|
||||
x => x.SessionId == sessionId && x.UserId == _currentUser.Id
|
||||
);
|
||||
|
||||
if (teacherParticipant?.IsTeacher != true)
|
||||
|
|
@ -149,7 +155,7 @@ public class ClassroomHub : Hub
|
|||
public override async Task OnDisconnectedAsync(Exception exception)
|
||||
{
|
||||
// Handle cleanup when user disconnects
|
||||
var userId = Context.UserIdentifier?.To<Guid>();
|
||||
var userId = _currentUser.Id;
|
||||
if (userId.HasValue)
|
||||
{
|
||||
var participants = await _participantRepository.GetListAsync(
|
||||
|
|
@ -11,6 +11,7 @@ using Kurs.Platform.BlobStoring;
|
|||
using Kurs.Platform.EntityFrameworkCore;
|
||||
using Kurs.Platform.Extensions;
|
||||
using Kurs.Platform.Identity;
|
||||
using Kurs.Platform.SignalR.Hubs;
|
||||
using Kurs.Settings;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
|
|
@ -112,6 +113,8 @@ public class PlatformHttpApiHostModule : AbpModule
|
|||
ConfigureHangfire(context, configuration);
|
||||
ConfigureBlobStoring(configuration);
|
||||
|
||||
context.Services.AddSignalR();
|
||||
|
||||
Configure<AbpExceptionHttpStatusCodeOptions>(options =>
|
||||
{
|
||||
options.Map(AppErrorCodes.NoAuth, System.Net.HttpStatusCode.Unauthorized);
|
||||
|
|
@ -329,7 +332,7 @@ public class PlatformHttpApiHostModule : AbpModule
|
|||
fileSystem.BasePath = configuration["App:CdnPath"];
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
options.Containers.Configure<ImportBlobContainer>(container =>
|
||||
{
|
||||
container.UseFileSystem(fileSystem =>
|
||||
|
|
@ -400,6 +403,11 @@ public class PlatformHttpApiHostModule : AbpModule
|
|||
AsyncAuthorization = [new AbpHangfireAuthorizationFilter()]
|
||||
});
|
||||
}
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapHub<ClassroomHub>("/classroomhub");
|
||||
});
|
||||
|
||||
app.UseConfiguredEndpoints();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface User {
|
|||
}
|
||||
|
||||
export interface ClassroomDto {
|
||||
id?: string
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
subject?: string
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const ROUTES_ENUM = {
|
|||
classroom: {
|
||||
dashboard: '/admin/classroom/dashboard',
|
||||
classes: '/admin/classroom/classes',
|
||||
classroom: '/admin/classroom/room/:id',
|
||||
roomDetail: '/admin/classroom/room/:id',
|
||||
},
|
||||
},
|
||||
accessDenied: '/admin/access-denied',
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import { PagedAndSortedResultRequestDto, PagedResultDto } from '@/proxy'
|
|||
export const getClassroomById = (id: string) =>
|
||||
apiService.fetchData<ClassroomDto>({
|
||||
method: 'GET',
|
||||
url: `/api/app/classroom`,
|
||||
params: { id },
|
||||
url: `/api/app/classroom/${id}`,
|
||||
})
|
||||
|
||||
export const getClassrooms = (input: PagedAndSortedResultRequestDto) =>
|
||||
|
|
@ -39,11 +38,11 @@ export const deleteClassroom = (id: string) =>
|
|||
export const startClassroom = (id: string) =>
|
||||
apiService.fetchData({
|
||||
method: 'PUT',
|
||||
url: `/api/app/${id}/start-classroom`,
|
||||
url: `/api/app/classroom/${id}/start-class`,
|
||||
})
|
||||
|
||||
export const endClassroom = (id: string) =>
|
||||
apiService.fetchData({
|
||||
method: 'PUT',
|
||||
url: `/api/app/${id}/end-classroom`,
|
||||
url: `/api/app/classroom/${id}/end-class`,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
HandRaiseDto,
|
||||
SignalingMessageDto,
|
||||
} from '@/proxy/classroom/models'
|
||||
import { store } from '@/store/store'
|
||||
import * as signalR from '@microsoft/signalr'
|
||||
|
||||
export class SignalRService {
|
||||
|
|
@ -20,15 +21,17 @@ export class SignalRService {
|
|||
private onHandRaiseDismissed?: (handRaiseId: string) => void
|
||||
|
||||
constructor() {
|
||||
const { auth } = store.getState()
|
||||
|
||||
// Only initialize connection if not in demo mode
|
||||
if (!this.demoMode) {
|
||||
// In production, replace with your actual SignalR hub URL
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('https://localhost:5001/classroomhub', {
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
.withUrl('https://localhost:44344/classroomhub', {
|
||||
accessTokenFactory: () => auth.session.token || '',
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Information)
|
||||
.build()
|
||||
|
||||
this.setupEventHandlers()
|
||||
|
|
@ -77,6 +80,10 @@ export class SignalRService {
|
|||
this.connection.onclose(() => {
|
||||
console.log('SignalR connection closed')
|
||||
})
|
||||
|
||||
this.connection.on('Error', (message: string) => {
|
||||
console.error('Hub error:', message)
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
|
|
@ -98,7 +105,7 @@ export class SignalRService {
|
|||
}
|
||||
}
|
||||
|
||||
async joinClass(sessionId: string, userId: string, userName: string): Promise<void> {
|
||||
async joinClass(sessionId: string, userName: string): Promise<void> {
|
||||
if (this.demoMode || !this.isConnected) {
|
||||
console.log('Demo mode: Simulating join class for', userName)
|
||||
// Simulate successful join in demo mode
|
||||
|
|
@ -107,24 +114,26 @@ export class SignalRService {
|
|||
}
|
||||
|
||||
try {
|
||||
await this.connection.invoke('JoinClass', sessionId, userId, userName)
|
||||
await this.connection.invoke('JoinClass', sessionId, userName)
|
||||
} catch (error) {
|
||||
console.error('Error joining class:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async leaveClass(sessionId: string, userId: string): Promise<void> {
|
||||
async leaveClass(sessionId: string): Promise<void> {
|
||||
const { auth } = store.getState()
|
||||
|
||||
if (this.demoMode || !this.isConnected) {
|
||||
console.log('Demo mode: Simulating leave class for user', userId)
|
||||
console.log('Demo mode: Simulating leave class for user', auth.user.id)
|
||||
// Simulate successful leave in demo mode
|
||||
setTimeout(() => {
|
||||
this.onParticipantLeft?.(userId)
|
||||
this.onParticipantLeft?.(auth.user.id)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.invoke('LeaveClass', sessionId, userId)
|
||||
await this.connection.invoke('LeaveClass', sessionId)
|
||||
} catch (error) {
|
||||
console.error('Error leaving class:', error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ platformApiService.interceptors.response.use(
|
|||
email: tokenDetails?.email,
|
||||
authority: [tokenDetails?.role],
|
||||
name: `${tokenDetails?.given_name} ${tokenDetails?.family_name}`.trim(),
|
||||
role: 'teacher',
|
||||
},
|
||||
})
|
||||
setIsRefreshing(false)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ const ClassList: React.FC = () => {
|
|||
setClassroom(newClassEntity)
|
||||
|
||||
if (classroom.id) {
|
||||
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', classroom.id))
|
||||
handleJoinClass(classroom)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sınıf oluştururken hata oluştu:', error)
|
||||
|
|
@ -152,8 +152,12 @@ const ClassList: React.FC = () => {
|
|||
await startClassroom(classSession.id!)
|
||||
getClassroomList()
|
||||
|
||||
handleJoinClass(classSession)
|
||||
}
|
||||
|
||||
const handleJoinClass = (classSession: ClassroomDto) => {
|
||||
if (classSession.id) {
|
||||
navigate(ROUTES_ENUM.protected.admin.classroom.classroom.replace(':id', classSession.id))
|
||||
navigate(ROUTES_ENUM.protected.admin.classroom.roomDetail.replace(':id', classSession.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -313,16 +317,21 @@ const ClassList: React.FC = () => {
|
|||
<button
|
||||
onClick={() => openEditModal(classSession)}
|
||||
disabled={classSession.isActive}
|
||||
className="flex px-3 sm:px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||
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.isActive}
|
||||
className="flex px-3 sm:px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700"
|
||||
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} />
|
||||
|
|
@ -334,16 +343,10 @@ const ClassList: React.FC = () => {
|
|||
<button
|
||||
onClick={() =>
|
||||
user.role === 'teacher' && classSession.teacherId === user.id
|
||||
? handleStartClass(classSession)
|
||||
: (() => {
|
||||
if (classSession.id)
|
||||
navigate(
|
||||
ROUTES_ENUM.protected.admin.classroom.classroom.replace(
|
||||
':id',
|
||||
classSession.id,
|
||||
),
|
||||
)
|
||||
})()
|
||||
? classSession.isActive
|
||||
? handleJoinClass(classSession)
|
||||
: handleStartClass(classSession)
|
||||
: handleJoinClass(classSession)
|
||||
}
|
||||
className={`px-3 sm:px-4 py-2 rounded-lg transition-colors ${
|
||||
user.role === 'teacher' && classSession.teacherId === user.id
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ const newClassSession: ClassroomDto = {
|
|||
isActive: false,
|
||||
isScheduled: false,
|
||||
participantCount: 0,
|
||||
settings: undefined,
|
||||
settingsDto: undefined,
|
||||
canJoin: false,
|
||||
}
|
||||
|
||||
|
|
@ -157,28 +157,42 @@ const RoomDetail: React.FC = () => {
|
|||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
initializeServices()
|
||||
return () => {
|
||||
cleanup()
|
||||
const fetchClassDetails = async () => {
|
||||
const classEntity = await getClassroomById(params?.id ?? '')
|
||||
if (classEntity) {
|
||||
classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime)
|
||||
setClassSession(classEntity.data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchClassDetails()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (classSession.id) {
|
||||
initializeServices()
|
||||
return () => {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}, [classSession.id])
|
||||
|
||||
// Apply class settings
|
||||
useEffect(() => {
|
||||
if (classSession?.settings) {
|
||||
setClassSettings(classSession.settings)
|
||||
if (classSession?.settingsDto) {
|
||||
setClassSettings(classSession.settingsDto)
|
||||
const selectedLayout =
|
||||
layouts.find((l) => l.id === classSession.settings!.defaultLayout) || layouts[0]
|
||||
layouts.find((l) => l.id === classSession.settingsDto!.defaultLayout) || layouts[0]
|
||||
setCurrentLayout(selectedLayout)
|
||||
|
||||
// Apply default audio/video states for new participants
|
||||
if (user.role === 'student') {
|
||||
setIsAudioEnabled(classSession.settings.defaultMicrophoneState === 'unmuted')
|
||||
setIsVideoEnabled(classSession.settings.defaultCameraState === 'on')
|
||||
setIsAudioEnabled(classSession.settingsDto.defaultMicrophoneState === 'unmuted')
|
||||
setIsVideoEnabled(classSession.settingsDto.defaultCameraState === 'on')
|
||||
}
|
||||
}
|
||||
}, [classSession?.settings, user.role])
|
||||
}, [classSession?.settingsDto, user.role])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
|
|
@ -190,13 +204,6 @@ const RoomDetail: React.FC = () => {
|
|||
|
||||
const initializeServices = async () => {
|
||||
try {
|
||||
//ClassEntity
|
||||
const classEntity = await getClassroomById(params?.id ?? '')
|
||||
if (classEntity) {
|
||||
classEntity.data.scheduledStartTime = showDbDateAsIs(classEntity.data.scheduledStartTime)
|
||||
setClassSession(classEntity.data)
|
||||
}
|
||||
|
||||
// Initialize SignalR
|
||||
signalRServiceRef.current = new SignalRService()
|
||||
await signalRServiceRef.current.start()
|
||||
|
|
@ -275,7 +282,7 @@ const RoomDetail: React.FC = () => {
|
|||
})
|
||||
|
||||
// Join the class
|
||||
await signalRServiceRef.current.joinClass(classSession.id, user.id, user.name)
|
||||
await signalRServiceRef.current.joinClass(classSession.id, user.name)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize services:', error)
|
||||
}
|
||||
|
|
@ -283,7 +290,7 @@ const RoomDetail: React.FC = () => {
|
|||
|
||||
const cleanup = async () => {
|
||||
if (signalRServiceRef.current) {
|
||||
await signalRServiceRef.current.leaveClass(classSession.id, user.id)
|
||||
await signalRServiceRef.current.leaveClass(classSession.id)
|
||||
await signalRServiceRef.current.disconnect()
|
||||
}
|
||||
|
||||
|
|
@ -518,9 +525,9 @@ const RoomDetail: React.FC = () => {
|
|||
})
|
||||
|
||||
// Add attendance record
|
||||
setAttendanceRecords((prev) => {
|
||||
setAttendanceRecords((prev: any) => {
|
||||
// Check if student already has an active attendance record
|
||||
const existingRecord = prev.find((r) => r.studentId === studentId && !r.leaveTime)
|
||||
const existingRecord = prev.find((r: any) => r.studentId === studentId && !r.leaveTime)
|
||||
if (existingRecord) return prev
|
||||
|
||||
return [
|
||||
|
|
|
|||
Loading…
Reference in a new issue