feat: add comprehensive discussion forums with threads, nested replies, voting, and moderation, alongside updates to authentication flows.

This commit is contained in:
2026-01-26 17:51:49 -03:00
parent d3a019541d
commit 26f4283d0e
24 changed files with 1998 additions and 314 deletions
+11
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^11.2.10",
"lucide-react": "^0.395.0",
"next": "14.2.21",
@@ -1872,6 +1873,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^11.2.10",
"lucide-react": "^0.395.0",
"next": "14.2.21",
+174 -186
View File
@@ -4,20 +4,28 @@ import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { lmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { GraduationCap, Lock, Mail, User, Building2 } from "lucide-react";
import { GraduationCap, Lock, Mail, User, Building2, ChevronLeft, ArrowRight } from "lucide-react";
type ViewMode = 'selection' | 'personal' | 'enterprise';
export default function ExperienceLoginPage() {
const router = useRouter();
const { login } = useAuth();
const [isLogin, setIsLogin] = useState(true);
// State
const [viewMode, setViewMode] = useState<ViewMode>('selection');
const [isLogin, setIsLogin] = useState(true); // For Personal flow
// Form Inputs
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [fullName, setFullName] = useState("");
const [orgIdForSSO, setOrgIdForSSO] = useState("");
// UI State
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [ssoMode, setSSOMode] = useState(false);
const [orgIdForSSO, setOrgIdForSSO] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -25,227 +33,207 @@ export default function ExperienceLoginPage() {
setLoading(true);
try {
if (isLogin) {
const response = await lmsApi.login({ email, password });
// Verify user is a student
if (response.user.role !== "student") {
setError("Acceso denegado. Este portal es solo para estudiantes. Utiliza el portal de Studio para instructores.");
setLoading(false);
return;
if (viewMode === 'personal') {
if (isLogin) {
const response = await lmsApi.login({ email, password });
if (response.user.role !== "student") {
throw new Error("Acceso denegado. Este portal es solo para estudiantes.");
}
login(response.user, response.token);
router.push("/");
} else {
const response = await lmsApi.register({
email,
password,
full_name: fullName,
organization_name: organizationName,
});
login(response.user, response.token);
router.push("/");
}
login(response.user, response.token);
router.push("/");
} else {
const response = await lmsApi.register({
email,
password,
full_name: fullName,
organization_name: organizationName,
});
login(response.user, response.token);
router.push("/");
} else if (viewMode === 'enterprise') {
if (!orgIdForSSO) {
throw new Error("El ID de la organización es requerido");
}
lmsApi.initSSOLogin(orgIdForSSO);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Falló la autenticación");
} finally {
setLoading(false);
}
};
const handleBack = () => {
setError("");
setViewMode('selection');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-indigo-950 to-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="w-full max-w-md animate-in fade-in zoom-in duration-500">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4 shadow-lg shadow-indigo-600/30">
<GraduationCap className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-black text-white mb-2">Experiencia OpenCCB</h1>
Portal de Aprendizaje para Estudiantes
<h1 className="text-3xl font-black text-white mb-2 tracking-tight">Experiencia OpenCCB</h1>
<p className="text-indigo-200/60 font-medium">Portal de Aprendizaje para Estudiantes</p>
</div>
{/* Login/Register Form */}
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl p-8">
<div className="flex gap-2 mb-6 bg-white/5 rounded-xl p-1">
<button
onClick={() => {
setIsLogin(true);
setSSOMode(false);
}}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${isLogin && !ssoMode ? "bg-indigo-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Iniciar Sesión
</button>
<button
onClick={() => {
setIsLogin(false);
setSSOMode(false);
}}
className={`flex-1 py-2 px-4 rounded-lg font-bold transition-all ${!isLogin && !ssoMode ? "bg-indigo-600 text-white" : "text-gray-400 hover:text-white"
}`}
>
Registrarse
</button>
</div>
{/* Main Content Card */}
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl overflow-hidden relative">
<form onSubmit={handleSubmit} className="space-y-4">
{!ssoMode ? (
<>
{/* View: SELECTION */}
{viewMode === 'selection' && (
<div className="p-8 space-y-4">
<h2 className="text-xl font-bold text-white text-center mb-6">¿Cómo deseas ingresar?</h2>
<button
onClick={() => setViewMode('personal')}
className="w-full group p-4 rounded-2xl bg-white/5 hover:bg-indigo-600/20 border border-white/10 hover:border-indigo-500/50 transition-all text-left flex items-center gap-4"
>
<div className="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center text-indigo-400 group-hover:text-indigo-200 transition-colors">
<User size={24} />
</div>
<div className="flex-1">
<div className="font-bold text-white text-lg">Personas</div>
<div className="text-xs text-gray-400">Acceso con correo personal</div>
</div>
<ArrowRight className="text-indigo-500 opacity-0 group-hover:opacity-100 transition-all -translate-x-2 group-hover:translate-x-0" />
</button>
<button
onClick={() => setViewMode('enterprise')}
className="w-full group p-4 rounded-2xl bg-white/5 hover:bg-emerald-600/20 border border-white/10 hover:border-emerald-500/50 transition-all text-left flex items-center gap-4"
>
<div className="w-12 h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center text-emerald-400 group-hover:text-emerald-200 transition-colors">
<Building2 size={24} />
</div>
<div className="flex-1">
<div className="font-bold text-white text-lg">Empresas</div>
<div className="text-xs text-gray-400">Acceso corporativo (SSO)</div>
</div>
<ArrowRight className="text-emerald-500 opacity-0 group-hover:opacity-100 transition-all -translate-x-2 group-hover:translate-x-0" />
</button>
</div>
)}
{/* View: PERSONAL (Email/Pass) */}
{viewMode === 'personal' && (
<div className="p-8">
<button onClick={handleBack} className="flex items-center gap-2 text-xs font-bold text-gray-500 hover:text-white mb-6 transition-colors">
<ChevronLeft size={14} /> Volver
</button>
<div className="flex gap-2 mb-6 bg-white/5 rounded-xl p-1">
<button
onClick={() => setIsLogin(true)}
className={`flex-1 py-2 px-4 rounded-lg font-bold text-sm transition-all ${isLogin ? "bg-indigo-600 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>
Iniciar Sesión
</button>
<button
onClick={() => setIsLogin(false)}
className={`flex-1 py-2 px-4 rounded-lg font-bold text-sm transition-all ${!isLogin ? "bg-indigo-600 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
>
Registrarse
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Nombre Completo
</label>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Nombre Completo</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Jane Smith"
required
/>
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="text" value={fullName} onChange={e => setFullName(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="Juan Pérez" />
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Nombre de la Organización (Opcional)
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Tu Escuela o Empresa"
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">Si se deja en blanco, se creará una organización basada en el dominio de tu correo electrónico.</p>
</div>
</>
)}
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Correo Electrónico
</label>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Correo Electrónico</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="estudiante@ejemplo.com"
required
/>
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="nombre@correo.com" />
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
Contraseña
</label>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Contraseña</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="••••••••"
required
/>
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="password" value={password} onChange={e => setPassword(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors" placeholder="••••••••" />
</div>
</div>
</>
) : (
<div>
<label className="block text-sm font-bold text-gray-300 mb-2">
ID de la Organización
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={orgIdForSSO}
onChange={(e) => setOrgIdForSSO(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl py-3 pl-11 pr-4 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="00000000-0000-0000-0000-000000000000"
required
/>
</div>
<p className="text-xs text-gray-500 mt-2 pl-1">
Contacta a tu administrador si no conoces el ID de tu organización.
</p>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 text-red-400 text-sm">
{error}
</div>
)}
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-300 text-xs p-3 rounded-lg font-medium">{error}</div>}
<button
type="submit"
disabled={loading}
onClick={(e) => {
if (ssoMode) {
e.preventDefault();
if (!orgIdForSSO) {
setError("El ID de la organización es requerido");
return;
}
lmsApi.initSSOLogin(orgIdForSSO);
}
}}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Procesando..." : ssoMode ? "Continuar con SSO" : isLogin ? "Iniciar Sesión" : "Crear Cuenta"}
</button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-white/10"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-slate-950 px-2 text-gray-500">O bien</span>
</div>
<button disabled={loading} type="submit" className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-indigo-600/20 disabled:opacity-50 mt-2">
{loading ? "Procesando..." : isLogin ? "Ingresar" : "Crear Cuenta"}
</button>
</form>
</div>
)}
<button
type="button"
onClick={() => {
setSSOMode(!ssoMode);
setError("");
}}
className="w-full bg-white/5 hover:bg-white/10 text-white font-bold py-3 rounded-xl border border-white/10 transition-colors"
>
{ssoMode ? "Usar Correo y Contraseña" : "Iniciar Sesión con SSO de Empresa"}
</button>
</form>
{/* View: ENTERPRISE (Domain Login) */}
{viewMode === 'enterprise' && (
<div className="p-8">
<button onClick={handleBack} className="flex items-center gap-2 text-xs font-bold text-gray-500 hover:text-white mb-6 transition-colors">
<ChevronLeft size={14} /> Volver
</button>
<div className="mt-6 pt-6 border-t border-white/10 text-center">
<p className="text-sm text-gray-400">
¿Eres un instructor?{" "}
<a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
Ir al Portal de Instructores
</a>
</p>
</div>
<div className="text-center mb-6">
<div className="mx-auto w-12 h-12 bg-emerald-500/20 rounded-full flex items-center justify-center text-emerald-400 mb-3">
<Building2 size={24} />
</div>
<h3 className="text-lg font-bold text-white">Acceso Corporativo</h3>
<p className="text-xs text-gray-400">Ingresa las credenciales de tu empresa</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Dominio de la Empresa</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="text" value={organizationName} onChange={e => setOrganizationName(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors font-mono" placeholder="acme-corp" />
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Usuario / Correo</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="usuario@empresa.com" />
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Contraseña</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input required type="password" value={password} onChange={e => setPassword(e.target.value)} className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 pl-10 pr-4 text-white text-sm focus:border-emerald-500 focus:outline-none transition-colors" placeholder="••••••••" />
</div>
</div>
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-300 text-xs p-3 rounded-lg font-medium">{error}</div>}
<button disabled={loading} type="submit" className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-emerald-600/20 disabled:opacity-50 mt-2">
{loading ? "Validando..." : "Iniciar Sesión"}
</button>
</form>
</div>
)}
</div>
<p className="text-center text-xs text-gray-500 mt-6">
Experiencia OpenCCB - Portal de Aprendizaje para Estudiantes
</p>
{/* Footer */}
<div className="mt-8 text-center border-t border-white/5 pt-6">
<p className="text-xs text-gray-500">
¿Eres instructor? <a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold transition-colors">Ir al Portal de Instructores</a>
</p>
</div>
</div>
</div>
);
+6 -120
View File
@@ -1,132 +1,18 @@
"use client";
import { useState } from "react";
import { lmsApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [organizationName, setOrganizationName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await lmsApi.register({ email, password, full_name: fullName, organization_name: organizationName });
login(res.user, res.token);
router.push("/");
} catch (err) {
const message = err instanceof Error ? err.message : "El registro falló. Por favor, inténtalo de nuevo.";
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
router.replace("/auth/login");
}, [router]);
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-6 bg-[#050505]">
<div className="w-full max-w-md space-y-8 animate-in fade-in zoom-in duration-500">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-blue-600/10 border border-blue-500/20 flex items-center justify-center mx-auto text-blue-500 mb-6">
<UserPlus size={32} />
</div>
<h1 className="text-3xl font-black tracking-tighter text-white">Crear Cuenta</h1>
<p className="text-gray-500 font-bold uppercase tracking-widest text-[10px]">Únete a la próxima generación de aprendices</p>
</div>
<div className="glass-card p-8 border-white/5 bg-white/[0.02]">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-xs font-bold text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Nombre Completo</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
required
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="John Doe"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Dirección de Correo Electrónico</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Contraseña</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500 px-1">Nombre de la Organización (Opcional)</label>
<div className="relative">
<Building2 className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Tu Escuela o Empresa"
className="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-12 pr-4 text-sm text-white focus:outline-none focus:border-blue-500 transition-all"
/>
</div>
<p className="text-[10px] text-gray-600 px-1">Si está en blanco, usaremos el dominio de tu correo electrónico.</p>
</div>
<button
disabled={loading}
type="submit"
className="btn-premium w-full !py-4 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50"
>
{loading ? "Creando..." : "Comenzar a Aprender"}
</button>
</form>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-widest text-gray-600">
¿Ya tienes una cuenta? <Link href="/auth/login" className="text-blue-500 hover:text-blue-400">Inicia sesión aquí</Link>
</p>
</div>
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
<div className="animate-pulse text-gray-500 font-mono text-sm">Redirecting to login...</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } fr
import Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import DiscussionBoard from "@/components/DiscussionBoard";
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const { user } = useAuth();
@@ -250,6 +251,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
))}
</div>
{/* Discussions Section */}
<div className="mt-20">
<DiscussionBoard courseId={params.id} />
</div>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import React, { useState, useEffect } from "react";
import { lmsApi, ThreadWithAuthor } from "@/lib/api";
import ThreadList from "./ThreadList";
import ThreadDetail from "./ThreadDetail";
import NewThreadModal from "./NewThreadModal";
import { MessageSquarePlus, Filter } from "lucide-react";
interface DiscussionBoardProps {
courseId: string;
lessonId?: string;
}
type FilterType = 'all' | 'my_threads' | 'unanswered' | 'resolved';
export default function DiscussionBoard({ courseId, lessonId }: DiscussionBoardProps) {
const [threads, setThreads] = useState<ThreadWithAuthor[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<FilterType>('all');
const [page, setPage] = useState(1);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [showNewThreadModal, setShowNewThreadModal] = useState(false);
useEffect(() => {
loadThreads();
}, [courseId, filter, page, lessonId]);
const loadThreads = async () => {
setLoading(true);
try {
const data = await lmsApi.getDiscussions(courseId, filter, lessonId, page);
setThreads(data);
} catch (error) {
console.error("Error loading discussions:", error);
} finally {
setLoading(false);
}
};
const handleThreadClick = (threadId: string) => {
setSelectedThreadId(threadId);
};
const handleBack = () => {
setSelectedThreadId(null);
loadThreads(); // Reload list in case of changes
};
const handleNewThreadSuccess = () => {
setShowNewThreadModal(false);
loadThreads();
};
// If viewing a specific thread
if (selectedThreadId) {
return <ThreadDetail threadId={selectedThreadId} onBack={handleBack} />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4 flex-wrap">
<h1 className="text-3xl font-black text-white">Discusiones</h1>
<div className="flex-1"></div>
<button
onClick={() => setShowNewThreadModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-600/20"
>
<MessageSquarePlus size={20} />
Nuevo Hilo
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-2 overflow-x-auto pb-2">
<div className="flex items-center gap-2 text-gray-400 flex-shrink-0">
<Filter size={16} />
<span className="text-sm font-bold">Filtrar:</span>
</div>
{[
{ value: 'all', label: 'Todos' },
{ value: 'my_threads', label: 'Mis Hilos' },
{ value: 'unanswered', label: 'Sin Responder' },
{ value: 'resolved', label: 'Resueltos' }
].map((filterOption) => (
<button
key={filterOption.value}
onClick={() => {
setFilter(filterOption.value as FilterType);
setPage(1);
}}
className={`px-4 py-2 rounded-xl font-bold text-sm transition-all whitespace-nowrap ${filter === filterOption.value
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
}`}
>
{filterOption.label}
</button>
))}
</div>
{/* Thread List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : (
<ThreadList threads={threads} onThreadClick={handleThreadClick} />
)}
{/* Pagination */}
{threads.length === 50 && (
<div className="flex items-center justify-center gap-2">
{page > 1 && (
<button
onClick={() => setPage(page - 1)}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all border border-white/10"
>
Anterior
</button>
)}
<span className="text-gray-400 text-sm font-bold">Página {page}</span>
<button
onClick={() => setPage(page + 1)}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all border border-white/10"
>
Siguiente
</button>
</div>
)}
{/* New Thread Modal */}
{showNewThreadModal && (
<NewThreadModal
courseId={courseId}
lessonId={lessonId}
onClose={() => setShowNewThreadModal(false)}
onSuccess={handleNewThreadSuccess}
/>
)}
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import React, { useState } from "react";
import { lmsApi, CreateThreadPayload } from "@/lib/api";
import { X, Send } from "lucide-react";
interface NewThreadModalProps {
courseId: string;
lessonId?: string;
onClose: () => void;
onSuccess: () => void;
}
export default function NewThreadModal({ courseId, lessonId, onClose, onSuccess }: NewThreadModalProps) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !content.trim()) {
setError("El título y el contenido son requeridos");
return;
}
setSubmitting(true);
setError("");
try {
const payload: CreateThreadPayload = {
title: title.trim(),
content: content.trim(),
lesson_id: lessonId
};
await lmsApi.createThread(courseId, payload);
onSuccess();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Error al crear el hilo");
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-slate-900 border border-white/10 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-white/10">
<h2 className="text-2xl font-black text-white">Nuevo Hilo de Discusión</h2>
<button
onClick={onClose}
className="w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-all"
>
<X size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-300 text-sm p-3 rounded-lg">
{error}
</div>
)}
{/* Title */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Título
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="¿Cuál es tu pregunta o tema?"
className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors"
disabled={submitting}
maxLength={200}
/>
<p className="text-xs text-gray-500">{title.length}/200 caracteres</p>
</div>
{/* Content */}
<div className="space-y-2">
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Contenido
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Describe tu pregunta o tema en detalle..."
className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors resize-none"
rows={8}
disabled={submitting}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-3 pt-4">
<button
type="submit"
disabled={!title.trim() || !content.trim() || submitting}
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-600/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={16} />
{submitting ? "Creando..." : "Crear Hilo"}
</button>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-6 py-3 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white font-bold rounded-xl transition-all border border-white/10"
>
Cancelar
</button>
</div>
</form>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import React, { useState } from "react";
import { PostWithAuthor } from "@/lib/api";
import { ThumbsUp, MessageSquare, CheckCircle2, User } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
interface PostCardProps {
post: PostWithAuthor;
onReply: (parentId: string) => void;
onVote: (postId: string, voteType: 'upvote' | 'downvote') => void;
onEndorse?: (postId: string) => void;
depth: number;
isInstructor?: boolean;
}
export default function PostCard({ post, onReply, onVote, onEndorse, depth, isInstructor }: PostCardProps) {
const [isVoting, setIsVoting] = useState(false);
const maxDepth = 5; // Limit nesting depth for UX
const handleVote = async (voteType: 'upvote' | 'downvote') => {
if (isVoting) return;
setIsVoting(true);
try {
await onVote(post.id, voteType);
} finally {
setIsVoting(false);
}
};
const indentClass = depth > 0 ? `ml-${Math.min(depth * 4, 16)} pl-4 border-l-2 border-white/10` : '';
return (
<div className={`${indentClass} ${depth > 0 ? 'mt-4' : 'mt-6'}`}>
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-4 hover:bg-white/[0.07] transition-all">
{/* Header */}
<div className="flex items-start gap-3 mb-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-400 flex-shrink-0">
{post.author_avatar ? (
<img src={post.author_avatar} alt={post.author_name} className="w-full h-full rounded-full object-cover" />
) : (
<User size={20} />
)}
</div>
{/* Author Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-white text-sm">{post.author_name}</span>
{post.is_endorsed && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-green-400 text-xs font-bold">
<CheckCircle2 size={12} />
Respuesta Correcta
</span>
)}
<span className="text-gray-500 text-xs">
{formatDistanceToNow(new Date(post.created_at), { addSuffix: true, locale: es })}
</span>
</div>
</div>
</div>
{/* Content */}
<div className="text-gray-300 text-sm mb-4 whitespace-pre-wrap break-words">
{post.content}
</div>
{/* Actions */}
<div className="flex items-center gap-4 text-sm">
{/* Upvote Button */}
<button
onClick={() => handleVote('upvote')}
disabled={isVoting}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all ${post.user_vote === 'upvote'
? 'bg-indigo-600/30 text-indigo-400 border border-indigo-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
} disabled:opacity-50`}
>
<ThumbsUp size={14} />
<span className="font-bold">{post.upvotes}</span>
</button>
{/* Reply Button */}
{depth < maxDepth && (
<button
onClick={() => onReply(post.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10 transition-all"
>
<MessageSquare size={14} />
<span className="font-bold">Responder</span>
</button>
)}
{/* Endorse Button (Instructor Only) */}
{isInstructor && onEndorse && (
<button
onClick={() => onEndorse(post.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all ${post.is_endorsed
? 'bg-green-600/30 text-green-400 border border-green-500/50'
: 'bg-white/5 text-gray-400 hover:bg-green-600/20 hover:text-green-400 border border-white/10'
}`}
>
<CheckCircle2 size={14} />
<span className="font-bold text-xs">{post.is_endorsed ? 'Aprobada' : 'Aprobar'}</span>
</button>
)}
</div>
</div>
{/* Nested Replies */}
{post.replies && post.replies.length > 0 && (
<div className="mt-2">
{post.replies.map((reply) => (
<PostCard
key={reply.id}
post={reply}
onReply={onReply}
onVote={onVote}
onEndorse={onEndorse}
depth={depth + 1}
isInstructor={isInstructor}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
import React, { useState } from "react";
import { Send, X } from "lucide-react";
interface ReplyEditorProps {
threadId: string;
parentPostId?: string | null;
onSubmit: (content: string) => Promise<void>;
onCancel: () => void;
placeholder?: string;
}
export default function ReplyEditor({ threadId, parentPostId, onSubmit, onCancel, placeholder }: ReplyEditorProps) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
setContent("");
} catch (error) {
console.error("Error submitting reply:", error);
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-4 mt-4">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder || "Escribe tu respuesta..."}
className="w-full bg-slate-900/50 border border-white/10 rounded-xl py-3 px-4 text-white text-sm focus:border-indigo-500 focus:outline-none transition-colors resize-none"
rows={4}
disabled={submitting}
/>
<div className="flex items-center gap-2 mt-3">
<button
type="submit"
disabled={!content.trim() || submitting}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-600/20 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Send size={16} />
{submitting ? "Enviando..." : "Enviar"}
</button>
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white font-bold rounded-xl transition-all border border-white/10 text-sm"
>
<X size={16} />
Cancelar
</button>
</div>
</form>
);
}
@@ -0,0 +1,251 @@
"use client";
import React, { useState, useEffect } from "react";
import { lmsApi, ThreadWithAuthor, PostWithAuthor } from "@/lib/api";
import PostCard from "./PostCard";
import ReplyEditor from "./ReplyEditor";
import { ArrowLeft, Pin, Lock, Bell, BellOff, Eye, MessageSquare } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
import { useAuth } from "@/context/AuthContext";
interface ThreadDetailProps {
threadId: string;
onBack: () => void;
}
export default function ThreadDetail({ threadId, onBack }: ThreadDetailProps) {
const { user } = useAuth();
const [thread, setThread] = useState<ThreadWithAuthor | null>(null);
const [posts, setPosts] = useState<PostWithAuthor[]>([]);
const [loading, setLoading] = useState(true);
const [showReplyEditor, setShowReplyEditor] = useState(false);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [isSubscribed, setIsSubscribed] = useState(false);
const isInstructor = user?.role === 'instructor' || user?.role === 'admin';
useEffect(() => {
loadThread();
}, [threadId]);
const loadThread = async () => {
setLoading(true);
try {
const data = await lmsApi.getThreadDetail(threadId);
setThread(data.thread);
setPosts(data.posts);
} catch (error) {
console.error("Error loading thread:", error);
} finally {
setLoading(false);
}
};
const handleReply = (parentId: string | null) => {
setReplyingTo(parentId);
setShowReplyEditor(true);
};
const handleSubmitReply = async (content: string) => {
try {
await lmsApi.createPost(threadId, {
content,
parent_post_id: replyingTo || undefined
});
setShowReplyEditor(false);
setReplyingTo(null);
await loadThread(); // Reload to show new post
} catch (error) {
console.error("Error creating post:", error);
throw error;
}
};
const handleVote = async (postId: string, voteType: 'upvote' | 'downvote') => {
try {
await lmsApi.votePost(postId, voteType);
await loadThread(); // Reload to update vote counts
} catch (error) {
console.error("Error voting:", error);
}
};
const handleEndorse = async (postId: string) => {
try {
await lmsApi.endorsePost(postId);
await loadThread(); // Reload to update endorsed status
} catch (error) {
console.error("Error endorsing post:", error);
}
};
const handlePin = async () => {
try {
await lmsApi.pinThread(threadId);
await loadThread();
} catch (error) {
console.error("Error pinning thread:", error);
}
};
const handleLock = async () => {
try {
await lmsApi.lockThread(threadId);
await loadThread();
} catch (error) {
console.error("Error locking thread:", error);
}
};
const handleSubscribe = async () => {
try {
if (isSubscribed) {
await lmsApi.unsubscribeThread(threadId);
} else {
await lmsApi.subscribeThread(threadId);
}
setIsSubscribed(!isSubscribed);
} catch (error) {
console.error("Error toggling subscription:", error);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
if (!thread) {
return (
<div className="text-center py-12">
<p className="text-gray-400">Hilo no encontrado</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white font-bold rounded-xl transition-all border border-white/10"
>
<ArrowLeft size={16} />
Volver
</button>
<div className="flex-1"></div>
{/* Instructor Actions */}
{isInstructor && (
<>
<button
onClick={handlePin}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold transition-all text-sm ${thread.is_pinned
? 'bg-indigo-600/30 text-indigo-400 border border-indigo-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
}`}
>
<Pin size={16} />
{thread.is_pinned ? 'Desfijar' : 'Fijar'}
</button>
<button
onClick={handleLock}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold transition-all text-sm ${thread.is_locked
? 'bg-red-600/30 text-red-400 border border-red-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
}`}
>
<Lock size={16} />
{thread.is_locked ? 'Desbloquear' : 'Bloquear'}
</button>
</>
)}
{/* Subscribe Button */}
<button
onClick={handleSubscribe}
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-bold transition-all text-sm ${isSubscribed
? 'bg-indigo-600/30 text-indigo-400 border border-indigo-500/50'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white border border-white/10'
}`}
>
{isSubscribed ? <BellOff size={16} /> : <Bell size={16} />}
{isSubscribed ? 'Desuscribirse' : 'Suscribirse'}
</button>
</div>
{/* Thread Card */}
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-6">
<h1 className="text-2xl font-black text-white mb-4">{thread.title}</h1>
<div className="flex items-center gap-3 text-sm text-gray-500 mb-4">
<span className="font-bold text-white">{thread.author_name}</span>
<span></span>
<span>{formatDistanceToNow(new Date(thread.created_at), { addSuffix: true, locale: es })}</span>
<span></span>
<span className="flex items-center gap-1">
<Eye size={14} />
{thread.view_count} vistas
</span>
<span></span>
<span className="flex items-center gap-1">
<MessageSquare size={14} />
{thread.post_count} respuestas
</span>
</div>
<div className="text-gray-300 whitespace-pre-wrap break-words mb-6">
{thread.content}
</div>
{!thread.is_locked && (
<button
onClick={() => handleReply(null)}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-600/20"
>
<MessageSquare size={16} />
Responder
</button>
)}
</div>
{/* Reply Editor */}
{showReplyEditor && !thread.is_locked && (
<ReplyEditor
threadId={threadId}
parentPostId={replyingTo}
onSubmit={handleSubmitReply}
onCancel={() => {
setShowReplyEditor(false);
setReplyingTo(null);
}}
/>
)}
{/* Posts */}
<div>
<h2 className="text-lg font-bold text-white mb-4">
{posts.length} {posts.length === 1 ? 'Respuesta' : 'Respuestas'}
</h2>
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onReply={handleReply}
onVote={handleVote}
onEndorse={isInstructor ? handleEndorse : undefined}
depth={0}
isInstructor={isInstructor}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import React from "react";
import { ThreadWithAuthor } from "@/lib/api";
import { MessageSquare, Eye, Pin, Lock, CheckCircle2, User } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { es } from "date-fns/locale";
interface ThreadListProps {
threads: ThreadWithAuthor[];
onThreadClick: (threadId: string) => void;
}
export default function ThreadList({ threads, onThreadClick }: ThreadListProps) {
if (threads.length === 0) {
return (
<div className="text-center py-12">
<MessageSquare className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 font-bold">No hay hilos de discusión todavía</p>
<p className="text-gray-500 text-sm mt-1"> el primero en iniciar una conversación</p>
</div>
);
}
return (
<div className="space-y-3">
{threads.map((thread) => (
<button
key={thread.id}
onClick={() => onThreadClick(thread.id)}
className="w-full text-left bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl p-4 hover:bg-white/[0.07] hover:border-indigo-500/30 transition-all group"
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-indigo-600/20 flex items-center justify-center text-indigo-400 flex-shrink-0">
{thread.author_avatar ? (
<img src={thread.author_avatar} alt={thread.author_name} className="w-full h-full rounded-full object-cover" />
) : (
<User size={20} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title and Badges */}
<div className="flex items-start gap-2 mb-1">
<h3 className="font-bold text-white text-base group-hover:text-indigo-400 transition-colors flex-1">
{thread.title}
</h3>
{/* Badges */}
<div className="flex items-center gap-1 flex-shrink-0">
{thread.is_pinned && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-indigo-600/30 border border-indigo-500/50 rounded-full text-indigo-400" title="Fijado">
<Pin size={12} />
</span>
)}
{thread.is_locked && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-600/30 border border-red-500/50 rounded-full text-red-400" title="Bloqueado">
<Lock size={12} />
</span>
)}
{thread.has_endorsed_answer && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-green-600/30 border border-green-500/50 rounded-full text-green-400" title="Resuelto">
<CheckCircle2 size={12} />
</span>
)}
</div>
</div>
{/* Metadata */}
<div className="flex items-center gap-3 text-xs text-gray-500 flex-wrap">
<span className="font-bold">{thread.author_name}</span>
<span></span>
<span>{formatDistanceToNow(new Date(thread.created_at), { addSuffix: true, locale: es })}</span>
<span></span>
<span className="flex items-center gap-1">
<MessageSquare size={12} />
{thread.post_count} {thread.post_count === 1 ? 'respuesta' : 'respuestas'}
</span>
<span></span>
<span className="flex items-center gap-1">
<Eye size={12} />
{thread.view_count} {thread.view_count === 1 ? 'vista' : 'vistas'}
</span>
</div>
{/* Preview */}
<p className="text-gray-400 text-sm mt-2 line-clamp-2">
{thread.content}
</p>
</div>
</div>
</button>
))}
</div>
);
}
+146
View File
@@ -191,6 +191,87 @@ export interface Module {
lessons: Lesson[];
}
// Discussion Forums Types
export interface DiscussionThread {
id: string;
organization_id: string;
course_id: string;
lesson_id?: string;
author_id: string;
title: string;
content: string;
is_pinned: boolean;
is_locked: boolean;
view_count: number;
created_at: string;
updated_at: string;
}
export interface ThreadWithAuthor {
id: string;
organization_id: string;
course_id: string;
lesson_id?: string;
author_id: string;
title: string;
content: string;
is_pinned: boolean;
is_locked: boolean;
view_count: number;
created_at: string;
updated_at: string;
author_name: string;
author_avatar?: string;
post_count: number;
has_endorsed_answer: boolean;
}
export interface DiscussionPost {
id: string;
organization_id: string;
thread_id: string;
parent_post_id?: string;
author_id: string;
content: string;
upvotes: number;
is_endorsed: boolean;
created_at: string;
updated_at: string;
}
export interface PostWithAuthor {
id: string;
organization_id: string;
thread_id: string;
parent_post_id?: string;
author_id: string;
content: string;
upvotes: number;
is_endorsed: boolean;
created_at: string;
updated_at: string;
author_name: string;
author_avatar?: string;
user_vote?: 'upvote' | 'downvote';
replies: PostWithAuthor[];
}
export interface CreateThreadPayload {
title: string;
content: string;
lesson_id?: string;
}
export interface CreatePostPayload {
content: string;
parent_post_id?: string;
}
export interface VotePayload {
vote_type: 'upvote' | 'downvote';
}
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null;
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
@@ -362,5 +443,70 @@ export const lmsApi = {
},
async getLessonFeedback(lessonId: string): Promise<{ response: string, session_id: string }> {
return apiFetch(`/lessons/${lessonId}/feedback`);
},
// Discussion Forums API
async getDiscussions(courseId: string, filter?: string, lessonId?: string, page?: number): Promise<ThreadWithAuthor[]> {
const params = new URLSearchParams();
if (filter) params.append('filter', filter);
if (lessonId) params.append('lesson_id', lessonId);
if (page) params.append('page', page.toString());
const query = params.toString() ? `?${params.toString()}` : '';
return apiFetch(`/courses/${courseId}/discussions${query}`);
},
async createThread(courseId: string, payload: CreateThreadPayload): Promise<DiscussionThread> {
return apiFetch(`/courses/${courseId}/discussions`, {
method: 'POST',
body: JSON.stringify(payload)
});
},
async getThreadDetail(threadId: string): Promise<{ thread: ThreadWithAuthor, posts: PostWithAuthor[] }> {
return apiFetch(`/discussions/${threadId}`);
},
async createPost(threadId: string, payload: CreatePostPayload): Promise<DiscussionPost> {
return apiFetch(`/discussions/${threadId}/posts`, {
method: 'POST',
body: JSON.stringify(payload)
});
},
async votePost(postId: string, voteType: 'upvote' | 'downvote'): Promise<void> {
return apiFetch(`/posts/${postId}/vote`, {
method: 'POST',
body: JSON.stringify({ vote_type: voteType })
});
},
async endorsePost(postId: string): Promise<void> {
return apiFetch(`/posts/${postId}/endorse`, {
method: 'POST'
});
},
async pinThread(threadId: string): Promise<void> {
return apiFetch(`/discussions/${threadId}/pin`, {
method: 'POST'
});
},
async lockThread(threadId: string): Promise<void> {
return apiFetch(`/discussions/${threadId}/lock`, {
method: 'POST'
});
},
async subscribeThread(threadId: string): Promise<void> {
return apiFetch(`/discussions/${threadId}/subscribe`, {
method: 'POST'
});
},
async unsubscribeThread(threadId: string): Promise<void> {
return apiFetch(`/discussions/${threadId}/unsubscribe`, {
method: 'POST'
});
}
};