IsKicked prop ayrı oluşturuldu
This commit is contained in:
parent
3b72c8e28a
commit
ae3a59e619
8 changed files with 128 additions and 32 deletions
|
|
@ -12,6 +12,7 @@ public class ClassroomParticipantDto
|
||||||
public bool IsAudioMuted { get; set; }
|
public bool IsAudioMuted { get; set; }
|
||||||
public bool IsVideoMuted { get; set; }
|
public bool IsVideoMuted { get; set; }
|
||||||
public bool IsHandRaised { get; set; }
|
public bool IsHandRaised { get; set; }
|
||||||
|
public bool IsKicked { get; set; }
|
||||||
public DateTime JoinTime { get; set; }
|
public DateTime JoinTime { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ public class ClassroomParticipant : FullAuditedEntity<Guid>
|
||||||
public bool IsAudioMuted { get; set; } = false;
|
public bool IsAudioMuted { get; set; } = false;
|
||||||
public bool IsVideoMuted { get; set; } = false;
|
public bool IsVideoMuted { get; set; } = false;
|
||||||
public bool IsHandRaised { get; set; } = false;
|
public bool IsHandRaised { get; set; } = false;
|
||||||
|
public bool IsKicked { get; set; } = false;
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public DateTime JoinTime { get; set; }
|
public DateTime JoinTime { get; set; }
|
||||||
public string ConnectionId { get; set; }
|
public string ConnectionId { get; set; }
|
||||||
|
|
@ -32,6 +33,7 @@ public class ClassroomParticipant : FullAuditedEntity<Guid>
|
||||||
bool isAudioMuted,
|
bool isAudioMuted,
|
||||||
bool isVideoMuted,
|
bool isVideoMuted,
|
||||||
bool isHandRaised,
|
bool isHandRaised,
|
||||||
|
bool isKicked,
|
||||||
bool isActive
|
bool isActive
|
||||||
) : base(id)
|
) : base(id)
|
||||||
{
|
{
|
||||||
|
|
@ -43,6 +45,7 @@ public class ClassroomParticipant : FullAuditedEntity<Guid>
|
||||||
IsVideoMuted = isVideoMuted;
|
IsVideoMuted = isVideoMuted;
|
||||||
IsHandRaised = isHandRaised;
|
IsHandRaised = isHandRaised;
|
||||||
IsActive = isActive;
|
IsActive = isActive;
|
||||||
|
IsKicked = isKicked;
|
||||||
JoinTime = DateTime.UtcNow;
|
JoinTime = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,14 @@ public class ClassroomHub : Hub
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🚨 Kick edilmiş kullanıcı tekrar giriş yapamaz
|
// 🚨 Kick edilmiş kullanıcı tekrar giriş yapamaz
|
||||||
if (participant != null && !participant.IsActive)
|
if (participant != null && participant.IsKicked)
|
||||||
{
|
{
|
||||||
await Clients.Caller.SendAsync("Error", "You are not allowed to rejoin this class.");
|
await Clients.Caller.SendAsync("Error", "You are not allowed to rejoin this class.");
|
||||||
Context.Abort(); // bağlantıyı anında kapat
|
Context.Abort(); // bağlantıyı anında kapat
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (participant == null)
|
if (participant == null)
|
||||||
{
|
{
|
||||||
participant = new ClassroomParticipant(
|
participant = new ClassroomParticipant(
|
||||||
|
|
@ -155,7 +156,8 @@ public class ClassroomHub : Hub
|
||||||
isTeacher,
|
isTeacher,
|
||||||
initialMuteState,
|
initialMuteState,
|
||||||
classroomSettings.DefaultCameraState == "off",
|
classroomSettings.DefaultCameraState == "off",
|
||||||
false,
|
false, //isHandRaised
|
||||||
|
false, //isKicked
|
||||||
isActive
|
isActive
|
||||||
);
|
);
|
||||||
await _participantRepository.InsertAsync(participant, autoSave: true);
|
await _participantRepository.InsertAsync(participant, autoSave: true);
|
||||||
|
|
@ -393,11 +395,18 @@ public class ClassroomHub : Hub
|
||||||
.SendAsync("ForceDisconnect", "You have been removed from the class.");
|
.SendAsync("ForceDisconnect", "You have been removed from the class.");
|
||||||
|
|
||||||
await Groups.RemoveFromGroupAsync(participant.ConnectionId, sessionId.ToString());
|
await Groups.RemoveFromGroupAsync(participant.ConnectionId, sessionId.ToString());
|
||||||
await DeactivateParticipantAsync(participant);
|
|
||||||
|
// ❌ pasif + ✅ kicked işaretle
|
||||||
|
participant.IsActive = false;
|
||||||
|
participant.IsKicked = true;
|
||||||
|
participant.ConnectionId = null;
|
||||||
|
await _participantRepository.UpdateAsync(participant, autoSave: true);
|
||||||
|
|
||||||
await Clients.Group(sessionId.ToString())
|
await Clients.Group(sessionId.ToString())
|
||||||
.SendAsync("ParticipantLeft", new { UserId = participantId, SessionId = sessionId });
|
.SendAsync("ParticipantLeft", new { UserId = participantId, SessionId = sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 3. Diğerlerine duyur
|
// 3. Diğerlerine duyur
|
||||||
_logger.LogInformation("👢 Participant {ParticipantId} kicked from session {SessionId}", participantId, sessionId);
|
_logger.LogInformation("👢 Participant {ParticipantId} kicked from session {SessionId}", participantId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
@ -521,11 +530,13 @@ public class ClassroomHub : Hub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔑 Participant pasifleştir
|
// Eğer kullanıcı kick edilmemişse pasifleştir
|
||||||
participant.IsActive = false;
|
if (!participant.IsKicked)
|
||||||
participant.ConnectionId = null;
|
{
|
||||||
|
participant.IsActive = false;
|
||||||
await _participantRepository.UpdateAsync(participant, autoSave: true);
|
participant.ConnectionId = null;
|
||||||
|
await _participantRepository.UpdateAsync(participant, autoSave: true);
|
||||||
|
}
|
||||||
|
|
||||||
// 🔑 Frontend’e bildir
|
// 🔑 Frontend’e bildir
|
||||||
await Clients.Group(participant.SessionId.ToString())
|
await Clients.Group(participant.SessionId.ToString())
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export interface ClassroomParticipantDto {
|
||||||
isAudioMuted?: boolean
|
isAudioMuted?: boolean
|
||||||
isVideoMuted?: boolean
|
isVideoMuted?: boolean
|
||||||
isHandRaised?: boolean
|
isHandRaised?: boolean
|
||||||
|
isKicked?: boolean
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
stream?: MediaStream
|
stream?: MediaStream
|
||||||
screenStream?: MediaStream
|
screenStream?: MediaStream
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export class SignalRService {
|
||||||
.withUrl(`${import.meta.env.VITE_API_URL}/classroomhub`, {
|
.withUrl(`${import.meta.env.VITE_API_URL}/classroomhub`, {
|
||||||
accessTokenFactory: () => auth.session.token || '',
|
accessTokenFactory: () => auth.session.token || '',
|
||||||
})
|
})
|
||||||
//.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
.configureLogging(signalR.LogLevel.Information)
|
.configureLogging(signalR.LogLevel.Information)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|
@ -97,8 +97,15 @@ export class SignalRService {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
this.connection.onreconnected(() => {
|
this.connection.onreconnected(async () => {
|
||||||
this.isConnected = true
|
this.isConnected = true
|
||||||
|
console.warn('🔄 SignalR reconnected')
|
||||||
|
|
||||||
|
// Eğer sınıftayken bağlantı koptuysa → tekrar join et
|
||||||
|
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 (err) => {
|
this.connection.onclose(async (err) => {
|
||||||
|
|
@ -128,34 +135,45 @@ export class SignalRService {
|
||||||
this.connection.onreconnecting((err) => {
|
this.connection.onreconnecting((err) => {
|
||||||
if (this.isKicked) {
|
if (this.isKicked) {
|
||||||
console.warn('Reconnect blocked because user was kicked')
|
console.warn('Reconnect blocked because user was kicked')
|
||||||
// ❌ otomatik reconnect'i iptal etmek için stop çağır
|
|
||||||
this.connection.stop()
|
this.connection.stop()
|
||||||
throw new Error('Reconnect blocked after kick')
|
throw new Error('Reconnect blocked after kick')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
//2. tane problem var.
|
|
||||||
//1. problem kick yapamıyorum
|
|
||||||
//2. Öğrenci veya öğretmen farklı bir sayfaya gidince ekranda donuk şekilde duruyor.
|
|
||||||
|
|
||||||
this.connection.on('ForceDisconnect', async (message: string) => {
|
this.connection.on('ForceDisconnect', async (message: string) => {
|
||||||
console.warn('⚠️ ForceDisconnect received:', message)
|
console.warn('⚠️ ForceDisconnect received:', message)
|
||||||
|
|
||||||
this.isKicked = true // ✅ kick yediğini işaretle
|
this.isKicked = true
|
||||||
await this.connection.stop()
|
await this.connection.stop()
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
|
||||||
|
// ✅ frontend state’den de çıkar
|
||||||
|
if (this.currentSessionId && store.getState().auth.user) {
|
||||||
|
this.onParticipantLeft?.({
|
||||||
|
userId: store.getState().auth.user.id,
|
||||||
|
sessionId: this.currentSessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSessionId = undefined
|
||||||
window.location.href = ROUTES_ENUM.protected.admin.classroom.classes
|
window.location.href = ROUTES_ENUM.protected.admin.classroom.classes
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.connection.start()
|
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
|
this.isConnected = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting SignalR connection:', error)
|
console.error('Error starting SignalR connection:', error)
|
||||||
// Switch to demo mode if connection fails
|
alert(
|
||||||
|
'⚠️ Sunucuya bağlanılamadı. Lütfen sayfayı yenileyin veya internet bağlantınızı kontrol edin.',
|
||||||
|
)
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -481,11 +499,18 @@ export class SignalRService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (this.isConnected && this.connection) {
|
if (this.isConnected && this.currentSessionId) {
|
||||||
await this.connection.stop()
|
try {
|
||||||
this.isConnected = false
|
await this.connection.invoke('LeaveClass', this.currentSessionId)
|
||||||
this.currentSessionId = undefined
|
} catch (err) {
|
||||||
|
console.warn('LeaveClass gönderilemedi:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.stop()
|
||||||
|
}
|
||||||
|
this.isConnected = false
|
||||||
|
this.currentSessionId = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnectionState(): boolean {
|
getConnectionState(): boolean {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export class WebRTCService {
|
||||||
private localStream: MediaStream | null = null
|
private localStream: MediaStream | null = null
|
||||||
private onRemoteStream?: (userId: string, stream: MediaStream) => void
|
private onRemoteStream?: (userId: string, stream: MediaStream) => void
|
||||||
private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void
|
private onIceCandidate?: (userId: string, candidate: RTCIceCandidateInit) => void
|
||||||
|
private candidateBuffer: Map<string, RTCIceCandidateInit[]> = new Map()
|
||||||
|
|
||||||
private rtcConfiguration: RTCConfiguration = {
|
private rtcConfiguration: RTCConfiguration = {
|
||||||
iceServers: [
|
iceServers: [
|
||||||
|
|
@ -11,6 +12,17 @@ export class WebRTCService {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// private rtcConfiguration: RTCConfiguration = {
|
||||||
|
// iceServers: [
|
||||||
|
// { urls: 'stun:stun.l.google.com:19302' }, // STUN
|
||||||
|
// {
|
||||||
|
// urls: ['turn:your-server-ip:3478?transport=udp', 'turn:your-server-ip:3478?transport=tcp'],
|
||||||
|
// username: 'kurs', // static user/pass kullanmak istersen
|
||||||
|
// credential: 'kurs12345',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local stream'i başlatır. Kamera/mikrofon ayarlarını parametreden alır.
|
* Local stream'i başlatır. Kamera/mikrofon ayarlarını parametreden alır.
|
||||||
*/
|
*/
|
||||||
|
|
@ -71,6 +83,19 @@ export class WebRTCService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En sona ekle
|
||||||
|
if (this.candidateBuffer.has(userId)) {
|
||||||
|
for (const cand of this.candidateBuffer.get(userId)!) {
|
||||||
|
try {
|
||||||
|
await peerConnection.addIceCandidate(cand)
|
||||||
|
console.log(`Buffered ICE candidate eklendi [${userId}]`)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Buffered candidate eklenemedi [${userId}]:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.candidateBuffer.delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
return peerConnection
|
return peerConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +144,15 @@ export class WebRTCService {
|
||||||
|
|
||||||
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
async addIceCandidate(userId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
const pc = this.peerConnections.get(userId)
|
const pc = this.peerConnections.get(userId)
|
||||||
if (!pc) throw new Error('Peer connection not found')
|
if (!pc) {
|
||||||
|
// Peer yoksa buffer’a at
|
||||||
|
if (!this.candidateBuffer.has(userId)) {
|
||||||
|
this.candidateBuffer.set(userId, [])
|
||||||
|
}
|
||||||
|
this.candidateBuffer.get(userId)!.push(candidate)
|
||||||
|
console.warn(`ICE candidate bufferlandı [${userId}]`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') {
|
if (pc.signalingState === 'stable' || pc.signalingState === 'have-remote-offer') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -128,7 +161,12 @@ export class WebRTCService {
|
||||||
console.warn(`ICE candidate eklenemedi [${userId}]:`, err)
|
console.warn(`ICE candidate eklenemedi [${userId}]:`, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(`ICE candidate atlandı [${userId}], signalingState=${pc.signalingState}`)
|
// signalling hazır değilse → buffer’a at
|
||||||
|
if (!this.candidateBuffer.has(userId)) {
|
||||||
|
this.candidateBuffer.set(userId, [])
|
||||||
|
}
|
||||||
|
this.candidateBuffer.get(userId)!.push(candidate)
|
||||||
|
console.warn(`ICE candidate bufferlandı [${userId}], state=${pc.signalingState}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,7 @@ const ClassList: React.FC = () => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={event}
|
onClick={event}
|
||||||
|
disabled={status === 'Katılıma Açık' ? true : false}
|
||||||
className={`px-3 sm:px-4 py-2 rounded-lg transition-colors ${
|
className={`px-3 sm:px-4 py-2 rounded-lg transition-colors ${
|
||||||
classes
|
classes
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ const RoomDetail: React.FC = () => {
|
||||||
|
|
||||||
const signalRServiceRef = useRef<SignalRService>()
|
const signalRServiceRef = useRef<SignalRService>()
|
||||||
const webRTCServiceRef = useRef<WebRTCService>()
|
const webRTCServiceRef = useRef<WebRTCService>()
|
||||||
|
const [teacherDisconnected, setTeacherDisconnected] = useState(false)
|
||||||
|
|
||||||
const layouts: VideoLayoutDto[] = [
|
const layouts: VideoLayoutDto[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -281,6 +282,7 @@ const RoomDetail: React.FC = () => {
|
||||||
async (userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
|
async (userId: string, name: string, isTeacher: boolean, isActive: boolean) => {
|
||||||
if (userId === user.id) return
|
if (userId === user.id) return
|
||||||
if (!isActive) return // ❌ pasif kullanıcıyı ekleme
|
if (!isActive) return // ❌ pasif kullanıcıyı ekleme
|
||||||
|
if (isTeacher) setTeacherDisconnected(false)
|
||||||
|
|
||||||
console.log(`Participant joined: ${name}, isTeacher: ${isTeacher}`)
|
console.log(`Participant joined: ${name}, isTeacher: ${isTeacher}`)
|
||||||
|
|
||||||
|
|
@ -308,9 +310,11 @@ const RoomDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ öğretmen ise her zaman offer başlatır
|
// ✅ öğretmen ise her zaman offer başlatır
|
||||||
if (isActive && user.id < userId) {
|
if (isActive) {
|
||||||
const offer = await webRTCServiceRef.current!.createOffer(userId)
|
if (user.role === 'teacher') {
|
||||||
await signalRServiceRef.current?.sendOffer(classSession.id, userId, offer)
|
const offer = await webRTCServiceRef.current!.createOffer(userId)
|
||||||
|
await signalRServiceRef.current?.sendOffer(classSession.id, userId, offer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
@ -350,15 +354,15 @@ const RoomDetail: React.FC = () => {
|
||||||
if (teacherExists) {
|
if (teacherExists) {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
for (const participant of existing) {
|
for (const participant of existing) {
|
||||||
if (!participant.isActive) continue // ❌ pasifleri bağlama
|
if (!participant.isActive) continue
|
||||||
if (participant.userId === user.id) continue
|
if (participant.userId === user.id) continue
|
||||||
|
|
||||||
if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) {
|
if (!webRTCServiceRef.current?.getPeerConnection(participant.userId)) {
|
||||||
await webRTCServiceRef.current?.createPeerConnection(participant.userId)
|
await webRTCServiceRef.current?.createPeerConnection(participant.userId)
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
participant.isTeacher ||
|
// ✅ sadece öğretmen offer başlatır
|
||||||
(participant.isActive && user.id < participant.userId)
|
if (user.role === 'teacher') {
|
||||||
) {
|
|
||||||
const offer = await webRTCServiceRef.current!.createOffer(participant.userId)
|
const offer = await webRTCServiceRef.current!.createOffer(participant.userId)
|
||||||
await signalRServiceRef.current?.sendOffer(
|
await signalRServiceRef.current?.sendOffer(
|
||||||
classSession.id,
|
classSession.id,
|
||||||
|
|
@ -385,6 +389,10 @@ const RoomDetail: React.FC = () => {
|
||||||
setParticipants((prev) =>
|
setParticipants((prev) =>
|
||||||
prev.filter((p) => !(p.id === userId && p.sessionId === sessionId)),
|
prev.filter((p) => !(p.id === userId && p.sessionId === sessionId)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (participants.find((p) => p.id === userId)?.isTeacher) {
|
||||||
|
setTeacherDisconnected(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
signalRServiceRef.current.setAttendanceUpdatedHandler((record) => {
|
signalRServiceRef.current.setAttendanceUpdatedHandler((record) => {
|
||||||
|
|
@ -446,7 +454,9 @@ const RoomDetail: React.FC = () => {
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
if (signalRServiceRef.current) {
|
if (signalRServiceRef.current) {
|
||||||
|
// ✅ önce LeaveClass
|
||||||
await signalRServiceRef.current.leaveClass(classSession.id)
|
await signalRServiceRef.current.leaveClass(classSession.id)
|
||||||
|
// sonra disconnect
|
||||||
await signalRServiceRef.current.disconnect()
|
await signalRServiceRef.current.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,6 +820,12 @@ const RoomDetail: React.FC = () => {
|
||||||
defaultTitle="Kurs Platform"
|
defaultTitle="Kurs Platform"
|
||||||
></Helmet>
|
></Helmet>
|
||||||
|
|
||||||
|
{teacherDisconnected && (
|
||||||
|
<div className="bg-red-600 text-white p-2 text-center">
|
||||||
|
Öğretmenin bağlantısı koptu, tekrar bağlanmasını bekleyin...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="min-h-screen h-screen flex flex-col bg-gray-900 text-white overflow-hidden">
|
<div className="min-h-screen h-screen flex flex-col bg-gray-900 text-white overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue