feat: add study rooms feature with BigBlueButton integration
- Create database migrations for study_rooms table in both cms-service and lms-service. - Implement study room handlers in lms-service for listing, creating, joining, ending, and deleting study rooms. - Develop frontend components for managing study rooms in both experience and studio applications. - Add UI for creating new study rooms, displaying active and ended rooms, and joining sessions. - Include instructions for configuring BigBlueButton server settings. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -433,6 +433,25 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Salas de Estudio BBB */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/courses/${params.id}/study-rooms`}
|
||||
className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-zinc-900 px-5 py-4 hover:border-blue-400/50 dark:hover:border-blue-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-blue-500/10 flex items-center justify-center text-blue-600 dark:text-blue-400">
|
||||
<Video size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Salas de Estudio</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">Sesiones grupales con BigBlueButton</p>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink size={14} className="text-gray-400 group-hover:text-blue-500 transition-colors" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Announcements Section */}
|
||||
<div className="mb-16">
|
||||
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { lmsApi, StudyRoom } from "@/lib/api";
|
||||
import { Video, Users, Clock, ExternalLink, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: "Programada",
|
||||
active: "En curso",
|
||||
ended: "Finalizada",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",
|
||||
active: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||
ended: "bg-gray-100 text-gray-500 dark:bg-white/10 dark:text-white/40",
|
||||
};
|
||||
|
||||
export default function StudyRoomsPage() {
|
||||
const { id } = useParams() as { id: string };
|
||||
const router = useRouter();
|
||||
const [rooms, setRooms] = useState<StudyRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joiningId, setJoiningId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await lmsApi.listCourseStudyRooms(id);
|
||||
setRooms(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "No se pudieron cargar las salas");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRooms();
|
||||
}, [loadRooms]);
|
||||
|
||||
const join = async (room: StudyRoom) => {
|
||||
setJoiningId(room.id);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await lmsApi.joinStudyRoom(id, room.id);
|
||||
window.open(result.join_url, "_blank", "noopener,noreferrer");
|
||||
void loadRooms();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "No se pudo unir a la sala");
|
||||
} finally {
|
||||
setJoiningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeRooms = rooms.filter((r) => r.status !== "ended");
|
||||
const endedRooms = rooms.filter((r) => r.status === "ended");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 dark:bg-zinc-950 px-4 py-8 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-black flex items-center gap-2">
|
||||
<Video className="w-5 h-5" /> Salas de Estudio
|
||||
</h1>
|
||||
<p className="text-xs text-black/50 dark:text-white/50">Únete a sesiones en vivo con tu grupo</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void loadRooms()}
|
||||
className="ml-auto p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
||||
title="Recargar"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && rooms.length === 0 && (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-black/20 border-t-black dark:border-white/20 dark:border-t-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rooms.length === 0 && (
|
||||
<div className="text-center py-16 text-black/40 dark:text-white/40">
|
||||
<Video className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">No hay salas de estudio activas para este curso.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeRooms.length > 0 && (
|
||||
<section className="space-y-3 mb-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-wider text-black/40 dark:text-white/40">
|
||||
Salas disponibles
|
||||
</h2>
|
||||
{activeRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className="rounded-2xl border border-black/10 dark:border-white/10 bg-white dark:bg-zinc-900 p-5 flex flex-wrap items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-sm">{room.title}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${STATUS_COLOR[room.status]}`}>
|
||||
{STATUS_LABEL[room.status]}
|
||||
</span>
|
||||
</div>
|
||||
{room.description && (
|
||||
<p className="text-xs text-black/50 dark:text-white/50 mb-2">{room.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[11px] text-black/40 dark:text-white/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> Máx. {room.max_participants} participantes
|
||||
</span>
|
||||
{room.started_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(room.started_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void join(room)}
|
||||
disabled={joiningId === room.id}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{joiningId === room.id ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
)}
|
||||
{joiningId === room.id ? "Conectando..." : "Unirse"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{endedRooms.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xs font-black uppercase tracking-wider text-black/30 dark:text-white/30">
|
||||
Salas finalizadas
|
||||
</h2>
|
||||
{endedRooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className="rounded-xl border border-black/5 dark:border-white/5 bg-white/50 dark:bg-white/5 px-4 py-3 flex items-center justify-between gap-3 opacity-60"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{room.title}</span>
|
||||
{room.ended_at && (
|
||||
<span className="ml-2 text-[11px] text-black/40 dark:text-white/40">
|
||||
Finalizada {new Date(room.ended_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${STATUS_COLOR.ended}`}>
|
||||
{STATUS_LABEL.ended}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1339,4 +1339,30 @@ export const lmsApi = {
|
||||
getEnabledPlugins(): Promise<OrgPlugin[]> {
|
||||
return apiFetch('/plugins/enabled', {}, true);
|
||||
},
|
||||
|
||||
listCourseStudyRooms(courseId: string): Promise<StudyRoom[]> {
|
||||
return apiFetch(`/courses/${courseId}/study-rooms`);
|
||||
},
|
||||
|
||||
joinStudyRoom(courseId: string, roomId: string): Promise<{ room_id: string; join_url: string }> {
|
||||
return apiFetch(`/courses/${courseId}/study-rooms/${roomId}/join`, { method: 'POST' });
|
||||
},
|
||||
};
|
||||
|
||||
export interface StudyRoom {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
course_id: string;
|
||||
created_by: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'active' | 'ended';
|
||||
bbb_meeting_id?: string;
|
||||
join_url?: string;
|
||||
scheduled_at?: string;
|
||||
started_at?: string;
|
||||
ended_at?: string;
|
||||
max_participants: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user