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:
2026-04-27 14:04:06 -04:00
parent 7a2afce796
commit f4cddf7345
12 changed files with 1002 additions and 5 deletions
@@ -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>
);
}